test(backend): cover lifecycle manager coordination paths (#2590)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-12 22:48:00 +01:00
committed by GitHub
parent 590ffb31f0
commit 64c0a20b75
17 changed files with 5105 additions and 0 deletions
@@ -0,0 +1,154 @@
package lifecycle
import (
"context"
"testing"
"github.com/stretchr/testify/require"
runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
)
// TestAppendAgentRuntimeDefaultsFillsOnlyMissingKeys pins the runtime
// environment invariant at the launch-definition boundary: every
// Agent.Runtime().Env entry becomes a definition, but a higher-precedence
// definition already present for that key wins.
func TestAppendAgentRuntimeDefaultsFillsOnlyMissingKeys(t *testing.T) {
agentConfig, ok := newTestRegistry().Get("claude-acp")
require.True(t, ok)
runtimeEnv := agentConfig.Runtime().Env
require.NotEmpty(t, runtimeEnv, "the fixture agent must declare runtime env for this to be meaningful")
var claimed string
for key := range runtimeEnv {
claimed = key
break
}
definitions := []runtimeenv.Definition{
{Key: claimed, Literal: "profile-wins", Origin: agentProfileOrigin},
}
appendAgentRuntimeDefaults(&definitions, agentConfig)
byKey := map[string]runtimeenv.Definition{}
for _, d := range definitions {
require.NotContains(t, byKey, d.Key, "duplicate definition for %q", d.Key)
byKey[d.Key] = d
}
require.Len(t, byKey, len(runtimeEnv),
"every Agent.Runtime().Env key must reach the launch environment exactly once")
require.Equal(t, "profile-wins", byKey[claimed].Literal,
"an existing higher-precedence definition must not be overwritten by the agent default")
require.Equal(t, agentProfileOrigin, byKey[claimed].Origin)
for key, value := range runtimeEnv {
if key == claimed {
continue
}
require.Equal(t, value, byKey[key].Literal, "runtime env %q lost its value", key)
require.Equal(t, managedAgentDefaultsOrigin, byKey[key].Origin)
}
}
func TestAppendAgentRuntimeDefaultsIgnoresMissingConfig(t *testing.T) {
definitions := []runtimeenv.Definition{{Key: "EXISTING", Literal: "value"}}
appendAgentRuntimeDefaults(&definitions, nil)
require.Len(t, definitions, 1)
}
func TestHasEnvironmentDefinition(t *testing.T) {
definitions := []runtimeenv.Definition{
{Key: "PATH", Literal: "/usr/bin"},
{Key: "ANTHROPIC_API_KEY", SecretID: "secret-1"},
}
require.True(t, hasEnvironmentDefinition(definitions, "PATH"))
require.True(t, hasEnvironmentDefinition(definitions, "ANTHROPIC_API_KEY"))
require.False(t, hasEnvironmentDefinition(definitions, "HTTPS_PROXY"))
require.False(t, hasEnvironmentDefinition(nil, "PATH"))
}
func TestAppendMapDefinitionsIsDeterministicallyOrdered(t *testing.T) {
var definitions []runtimeenv.Definition
appendMapDefinitions(&definitions, map[string]string{
"ZED": "3", "ALPHA": "1", "MIKE": "2",
}, agentProfileOrigin)
require.Equal(t, []runtimeenv.Definition{
{Key: "ALPHA", Literal: "1", Origin: agentProfileOrigin},
{Key: "MIKE", Literal: "2", Origin: agentProfileOrigin},
{Key: "ZED", Literal: "3", Origin: agentProfileOrigin},
}, definitions, "map iteration order must not leak into the launch environment")
var empty []runtimeenv.Definition
appendMapDefinitions(&empty, nil, agentProfileOrigin)
require.Empty(t, empty)
}
// TestAppendProfileDefinitionsDropsUnusableEntries pins the filter: a blank key
// or an entry with neither a literal nor a secret would produce an env var the
// agent cannot use.
func TestAppendProfileDefinitionsDropsUnusableEntries(t *testing.T) {
var definitions []runtimeenv.Definition
appendProfileDefinitions(&definitions, []settingsmodels.ProfileEnvVar{
{Key: " ", Value: "blank key"},
{Key: "EMPTY"},
{Key: "LITERAL", Value: "value"},
{Key: "SECRET", SecretID: "secret-1"},
}, agentProfileOrigin)
require.Equal(t, []runtimeenv.Definition{
{Key: "LITERAL", Literal: "value", Origin: agentProfileOrigin},
{Key: "SECRET", SecretID: "secret-1", Origin: agentProfileOrigin},
}, definitions)
}
func TestApprovedSecretEnvironmentKeysOnlyIncludesWorkspaceScoped(t *testing.T) {
approved := approvedSecretEnvironmentKeys([]runtimeenv.Definition{
{Key: "GLOBAL_SECRET", SecretID: "secret-1"},
{Key: "REPO_TOKEN", SecretID: "secret-2", WorkspaceID: "ws-1"},
{Key: " ", SecretID: "secret-3", WorkspaceID: "ws-1"},
{Key: "ANOTHER_TOKEN", SecretID: "secret-4", WorkspaceID: "ws-1"},
{Key: "REPO_TOKEN", SecretID: "secret-5", WorkspaceID: "ws-2"},
})
require.Equal(t, []string{"ANOTHER_TOKEN", "REPO_TOKEN"}, approved,
"only workspace-scoped bindings may be forwarded, deduplicated and sorted")
}
func TestResolveEnvironmentDefinitionLiteralNeedsNoSecretStore(t *testing.T) {
mgr := newTestManager(t)
value, err := mgr.resolveEnvironmentDefinition(context.Background(),
runtimeenv.Definition{Key: "PATH", Literal: "/usr/bin"})
require.NoError(t, err)
require.Equal(t, "/usr/bin", value)
}
func TestResolveEnvironmentDefinitionRequiresSecretStore(t *testing.T) {
mgr := newTestManager(t)
mgr.secretStore = nil
_, err := mgr.resolveEnvironmentDefinition(context.Background(),
runtimeenv.Definition{Key: "TOKEN", SecretID: "secret-1"})
require.ErrorContains(t, err, "secret store unavailable")
}
// TestResolveEnvironmentDefinitionRejectsUnscopedStoreForWorkspaceSecret pins
// the fail-closed rule: a workspace-scoped binding must never fall back to a
// global reveal, which would cross the workspace boundary.
func TestResolveEnvironmentDefinitionRejectsUnscopedStoreForWorkspaceSecret(t *testing.T) {
mgr := newTestManager(t)
mgr.secretStore = &mockSecretStore{}
_, err := mgr.resolveEnvironmentDefinition(context.Background(),
runtimeenv.Definition{Key: "REPO_TOKEN", SecretID: "secret-1", WorkspaceID: "ws-1"})
require.ErrorContains(t, err, "workspace-scoped secret storage is unavailable")
}
@@ -0,0 +1,210 @@
package lifecycle
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// TestHandleAvailableCommandsEventCachesAndPublishes pins both halves of the
// slash-command update: the execution caches the list for later reads, and the
// same list is published so the composer refreshes without a reload.
func TestHandleAvailableCommandsEventCachesAndPublishes(t *testing.T) {
h := newWorkspaceEventsHarness(t)
commands := []streams.AvailableCommand{
{Name: "review", Description: "Review the diff"},
{Name: "commit"},
}
h.mgr.handleAvailableCommandsEvent(h.exec, agentctl.AgentEvent{AvailableCommands: commands})
require.Equal(t, commands, h.mgr.GetAvailableCommandsForSession("session-1"),
"the cached list is what a late-joining subscriber reads back")
got := h.only(t)
require.Equal(t, events.AvailableCommandsUpdated, got.Event.Type)
payload, ok := got.Event.Data.(AvailableCommandsEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Equal(t, commands, payload.AvailableCommands)
}
func TestHandleAvailableCommandsEventIgnoresEmptyList(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.exec.SetAvailableCommands([]streams.AvailableCommand{{Name: "review"}})
h.mgr.handleAvailableCommandsEvent(h.exec, agentctl.AgentEvent{})
require.Zero(t, h.count(), "an empty list must not be published")
require.Len(t, h.mgr.GetAvailableCommandsForSession("session-1"), 1,
"an empty update must not wipe the commands the agent already reported")
}
// TestHandleAgentEventDropsStaleMCPAttachment pins the identity check: a
// diagnostic from a replaced execution must not be attributed to the live one.
func TestHandleAgentEventDropsStaleMCPAttachment(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{
Type: streams.EventTypeMCPAttachment,
MCPAttachmentAttempt: &streams.MCPAttachmentAttempt{
AttemptID: "attempt-1",
ExecutionID: "exec-replaced",
},
})
require.Zero(t, h.count(), "an attachment attempt from a replaced execution is dropped")
}
// TestHandleAgentEventStampsMCPAttachmentIdentity pins that the attempt is
// re-stamped with the live execution's identity before publication, and that a
// copy is stamped rather than the caller's struct.
func TestHandleAgentEventStampsMCPAttachmentIdentity(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.exec.AgentID = "claude-acp"
h.exec.Status = v1.AgentStatusReady
attempt := &streams.MCPAttachmentAttempt{AttemptID: "attempt-1"}
h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{
Type: streams.EventTypeMCPAttachment,
MCPAttachmentAttempt: attempt,
})
got := h.only(t)
payload, ok := got.Event.Data.(AgentStreamEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.NotNil(t, payload.Data)
published := payload.Data.MCPAttachmentAttempt
require.NotNil(t, published)
require.Equal(t, "task-1", published.TaskID)
require.Equal(t, "session-1", published.SessionID)
require.Equal(t, "exec-1", published.ExecutionID)
require.Equal(t, "claude-acp", published.AgentID)
require.Empty(t, attempt.ExecutionID,
"the caller's attempt must not be stamped in place; a copy is published")
require.Equal(t, v1.AgentStatusReady, h.exec.Status,
"attachment diagnostics are not model activity and must not flip Ready into Running")
}
// TestHandleStreamDisconnectIgnoresSupersededGeneration pins the guard that
// keeps an old prompt's disconnect from failing the execution that replaced it.
func TestHandleStreamDisconnectIgnoresSupersededGeneration(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.exec.Status = v1.AgentStatusRunning
h.exec.promptDoneCh = make(chan PromptCompletionSignal, 1)
generation, err := h.mgr.BeginPrompt("exec-1")
require.NoError(t, err)
h.mgr.handleStreamDisconnect(h.exec, errors.New("connection reset"), generation+1)
require.NotEqual(t, v1.AgentStatusFailed, h.exec.Status,
"a disconnect from a superseded prompt generation must not fail the live execution")
}
func TestHandleStreamDisconnectFailsOwningGeneration(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.exec.Status = v1.AgentStatusRunning
h.exec.promptDoneCh = make(chan PromptCompletionSignal, 1)
generation, err := h.mgr.BeginPrompt("exec-1")
require.NoError(t, err)
h.mgr.handleStreamDisconnect(h.exec, errors.New("connection reset"), generation)
require.Equal(t, v1.AgentStatusFailed, h.exec.Status,
"the owning prompt's disconnect fails the execution so the orchestrator can reconcile")
}
func TestNotifyWorktreeMaterializedPublishesAgentctlReady(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.exec.TaskEnvironmentID = "env-1"
h.mgr.NotifyWorktreeMaterialized(context.Background(), MaterializedWorktree{
TaskID: "task-1",
SessionID: "session-1",
WorktreeID: "wt-1",
WorktreePath: "/work/task-1/backend",
WorktreeBranch: "kandev/feature",
TaskWorkspacePath: "/work/task-1",
})
got := h.only(t)
require.Equal(t, events.AgentctlReady, got.Subject)
payload, ok := got.Event.Data.(AgentctlEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Equal(t, "task-1", payload.TaskID)
require.Equal(t, "session-1", payload.SessionID)
require.Equal(t, "exec-1", payload.AgentExecutionID,
"the live execution is resolved so the frontend can key the update")
require.Equal(t, "env-1", payload.TaskEnvironmentID)
require.Equal(t, "wt-1", payload.WorktreeID)
require.Equal(t, "/work/task-1/backend", payload.WorktreePath)
require.Equal(t, "kandev/feature", payload.WorktreeBranch)
require.Equal(t, "/work/task-1", payload.TaskWorkspacePath)
}
func TestNotifyWorktreeMaterializedWithoutExecutionStillPublishes(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.NotifyWorktreeMaterialized(context.Background(), MaterializedWorktree{
TaskID: "task-2", SessionID: "session-unknown", WorktreeID: "wt-2",
})
payload, ok := h.only(t).Event.Data.(AgentctlEventPayload)
require.True(t, ok)
require.Empty(t, payload.AgentExecutionID,
"a worktree materialized before its execution exists still notifies the frontend")
require.Equal(t, "wt-2", payload.WorktreeID)
}
func TestNotifyWorktreeMaterializedWithoutEventBusIsNoOp(t *testing.T) {
mgr := newTestManager(t)
mgr.eventPublisher = NewEventPublisher(nil, newTestLogger())
mgr.NotifyWorktreeMaterialized(context.Background(), MaterializedWorktree{SessionID: "session-1"})
}
func TestBranchSnapshotErrorWrapsCause(t *testing.T) {
cause := errors.New("write executors_running: disk full")
err := &BranchSnapshotError{Cause: cause}
require.Equal(t, "branch renamed but branch snapshot persistence failed: write executors_running: disk full",
err.Error())
require.ErrorIs(t, err, cause)
require.False(t, err.Retryable(),
"the branch already has its new name; retrying the Git op would target the wrong branch")
var nilErr *BranchSnapshotError
require.Equal(t, "branch renamed but branch snapshot persistence failed", nilErr.Error())
require.NoError(t, nilErr.Unwrap())
require.Equal(t, "branch renamed but branch snapshot persistence failed",
(&BranchSnapshotError{}).Error())
}
func TestMetadataIntAcceptsJSONRoundTrippedShapes(t *testing.T) {
require.Zero(t, metadataInt(nil, "port"))
require.Equal(t, 8080, metadataInt(map[string]interface{}{"port": 8080}, "port"))
require.Equal(t, 8080, metadataInt(map[string]interface{}{"port": int64(8080)}, "port"))
require.Equal(t, 8080, metadataInt(map[string]interface{}{"port": float64(8080)}, "port"),
"a JSON round-trip turns integers into float64")
require.Equal(t, 8080, metadataInt(map[string]interface{}{"port": "8080"}, "port"))
require.Zero(t, metadataInt(map[string]interface{}{"port": "not-a-number"}, "port"))
require.Zero(t, metadataInt(map[string]interface{}{"port": true}, "port"))
require.Zero(t, metadataInt(map[string]interface{}{}, "port"))
}
func TestAgentctlPIDFromExecution(t *testing.T) {
require.Zero(t, agentctlPIDFromExecution(nil))
execution := &AgentExecution{ID: "exec-1"}
require.Zero(t, agentctlPIDFromExecution(execution))
execution.setMetadataValue(MetadataKeySSHRemoteAgentctlPID, float64(4242))
require.Equal(t, 4242, agentctlPIDFromExecution(execution),
"the remote PID survives a persistence round-trip as a float")
}
@@ -0,0 +1,328 @@
package lifecycle
import (
"testing"
"time"
"github.com/stretchr/testify/require"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
)
// workspaceEventsHarness drives the workspace-stream callbacks the manager
// registers on the StreamManager and captures what reaches the event bus.
type workspaceEventsHarness struct {
mgr *Manager
bus *MockEventBusWithTracking
exec *AgentExecution
}
func newWorkspaceEventsHarness(t *testing.T) *workspaceEventsHarness {
t.Helper()
mgr, eventBus := createTestManagerWithTracking()
cleanupManagerStopCh(t, mgr)
execution := &AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"}
require.NoError(t, mgr.executionStore.Add(execution))
return &workspaceEventsHarness{mgr: mgr, bus: eventBus, exec: execution}
}
func (h *workspaceEventsHarness) only(t *testing.T) trackedEvent {
t.Helper()
h.bus.mu.Lock()
defer h.bus.mu.Unlock()
require.Len(t, h.bus.PublishedEvents, 1)
return h.bus.PublishedEvents[0]
}
func (h *workspaceEventsHarness) count() int {
h.bus.mu.Lock()
defer h.bus.mu.Unlock()
return len(h.bus.PublishedEvents)
}
// TestHandleGitCommitCreatedPublishesCommitPayload pins the projection the
// orchestrator's commit sync reads: every field of the agentctl notification
// must survive the translation, on the session-scoped git subject.
func TestHandleGitCommitCreatedPublishesCommitPayload(t *testing.T) {
h := newWorkspaceEventsHarness(t)
committedAt := time.Date(2026, 8, 12, 10, 30, 0, 0, time.UTC)
h.mgr.handleGitCommitCreated(h.exec, &agentctl.GitCommitNotification{
CommitSHA: "abc123",
ParentSHA: "parent456",
Message: "fix: stop the leak",
AuthorName: "Kandev Agent",
AuthorEmail: "agent@kandev.ai",
FilesChanged: 3,
Insertions: 42,
Deletions: 7,
CommittedAt: committedAt,
RepositoryName: "backend",
})
got := h.only(t)
require.Equal(t, events.BuildGitEventSubject("session-1"), got.Subject)
require.Equal(t, events.GitEvent, got.Event.Type)
payload, ok := got.Event.Data.(*GitEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Equal(t, GitEventTypeCommitCreated, payload.Type)
require.Equal(t, "task-1", payload.TaskID)
require.Equal(t, "session-1", payload.SessionID)
require.Equal(t, "exec-1", payload.AgentID)
require.NotNil(t, payload.Commit)
require.Equal(t, "abc123", payload.Commit.CommitSHA)
require.Equal(t, "parent456", payload.Commit.ParentSHA)
require.Equal(t, "fix: stop the leak", payload.Commit.Message)
require.Equal(t, "Kandev Agent", payload.Commit.AuthorName)
require.Equal(t, "agent@kandev.ai", payload.Commit.AuthorEmail)
require.Equal(t, 3, payload.Commit.FilesChanged)
require.Equal(t, 42, payload.Commit.Insertions)
require.Equal(t, 7, payload.Commit.Deletions)
require.Equal(t, committedAt.Format(time.RFC3339), payload.Commit.CommittedAt)
require.Equal(t, "backend", payload.Commit.RepositoryName,
"the per-repo tag must survive so multi-repo workspaces attribute the commit correctly")
}
// TestHandleGitResetDetectedPublishesResetPayload pins the HEAD-moved-backward
// projection the orchestrator uses to re-sync its commit list.
func TestHandleGitResetDetectedPublishesResetPayload(t *testing.T) {
h := newWorkspaceEventsHarness(t)
at := time.Date(2026, 8, 12, 11, 0, 0, 0, time.UTC)
h.mgr.handleGitResetDetected(h.exec, &agentctl.GitResetNotification{
PreviousHead: "head-after",
CurrentHead: "head-before",
RepositoryName: "backend",
Timestamp: at,
})
payload, ok := h.only(t).Event.Data.(*GitEventPayload)
require.True(t, ok)
require.Equal(t, GitEventTypeCommitsReset, payload.Type)
require.Equal(t, at.Format(time.RFC3339Nano), payload.Timestamp,
"the reset timestamp comes from the notification, not from publish time")
require.NotNil(t, payload.Reset)
require.Equal(t, "head-after", payload.Reset.PreviousHead)
require.Equal(t, "head-before", payload.Reset.CurrentHead)
require.Equal(t, "backend", payload.Reset.RepositoryName)
}
// TestHandleBranchSwitchPublishesBaseCommit pins that the new base commit rides
// along; the orchestrator needs it to rebase its diff range.
func TestHandleBranchSwitchPublishesBaseCommit(t *testing.T) {
h := newWorkspaceEventsHarness(t)
at := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
h.mgr.handleBranchSwitch(h.exec, &agentctl.GitBranchSwitchNotification{
PreviousBranch: "main",
CurrentBranch: "feature/cover",
CurrentHead: "head-sha",
BaseCommit: "base-sha",
RepositoryName: "backend",
Timestamp: at,
})
payload, ok := h.only(t).Event.Data.(*GitEventPayload)
require.True(t, ok)
require.Equal(t, GitEventTypeBranchSwitched, payload.Type)
require.NotNil(t, payload.BranchSwitch)
require.Equal(t, "main", payload.BranchSwitch.PreviousBranch)
require.Equal(t, "feature/cover", payload.BranchSwitch.CurrentBranch)
require.Equal(t, "head-sha", payload.BranchSwitch.CurrentHead)
require.Equal(t, "base-sha", payload.BranchSwitch.BaseCommit)
require.Equal(t, "backend", payload.BranchSwitch.RepositoryName)
require.Equal(t, at.Format(time.RFC3339Nano), payload.Timestamp)
}
func TestHandleGitStatusUpdateUsesGitSubject(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.handleGitStatusUpdate(h.exec, &agentctl.GitStatusUpdate{
Timestamp: time.Now(),
Branch: "feature/cover",
Modified: []string{"main.go"},
Ahead: 2,
})
got := h.only(t)
require.Equal(t, events.BuildGitEventSubject("session-1"), got.Subject)
payload, ok := got.Event.Data.(*GitEventPayload)
require.True(t, ok)
require.Equal(t, GitEventTypeStatusUpdate, payload.Type)
require.NotNil(t, payload.Status)
require.Equal(t, "feature/cover", payload.Status.Branch)
require.Equal(t, []string{"main.go"}, payload.Status.Modified)
require.Equal(t, 2, payload.Status.Ahead)
}
func TestHandleFileChangeNotificationPublishesPathAndOperation(t *testing.T) {
h := newWorkspaceEventsHarness(t)
at := time.Date(2026, 8, 12, 13, 0, 0, 0, time.UTC)
h.mgr.handleFileChangeNotification(h.exec, &agentctl.FileChangeNotification{
Path: "src/main.go",
Operation: "modified",
Timestamp: at,
RepositoryName: "backend",
})
got := h.only(t)
require.Equal(t, events.BuildFileChangeSubject("session-1"), got.Subject)
require.Equal(t, events.FileChangeNotified, got.Event.Type)
payload, ok := got.Event.Data.(FileChangeEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Equal(t, "task-1", payload.TaskID)
require.Equal(t, "exec-1", payload.AgentID)
require.Equal(t, "src/main.go", payload.Path)
require.Equal(t, "modified", payload.Operation)
require.Equal(t, at.Format(time.RFC3339Nano), payload.Timestamp)
require.Equal(t, "backend", payload.RepositoryName)
}
func TestHandleShellOutputAndExitPublishTypedPayloads(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.handleShellOutput(h.exec, "build succeeded\n")
h.mgr.handleShellExit(h.exec, 137)
h.bus.mu.Lock()
published := append([]trackedEvent(nil), h.bus.PublishedEvents...)
h.bus.mu.Unlock()
require.Len(t, published, 2)
require.Equal(t, events.BuildShellOutputSubject("session-1"), published[0].Subject)
output, ok := published[0].Event.Data.(ShellOutputEventPayload)
require.True(t, ok, "payload type = %T", published[0].Event.Data)
require.Equal(t, "output", output.Type)
require.Equal(t, "build succeeded\n", output.Data)
require.Equal(t, "session-1", output.SessionID)
require.Equal(t, events.BuildShellExitSubject("session-1"), published[1].Subject)
exit, ok := published[1].Event.Data.(ShellExitEventPayload)
require.True(t, ok, "payload type = %T", published[1].Event.Data)
require.Equal(t, "exit", exit.Type)
require.Equal(t, 137, exit.Code, "the exit code is what the UI renders; it must not be dropped")
}
func TestHandleProcessOutputAndStatusIgnoreNilPayloads(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.handleProcessOutput(h.exec, nil)
h.mgr.handleProcessStatus(h.exec, nil)
require.Zero(t, h.count(), "a nil notification must be dropped, not published as an empty event")
}
func TestHandleProcessStatusPublishesScriptMetadata(t *testing.T) {
h := newWorkspaceEventsHarness(t)
exitCode := 2
at := time.Date(2026, 8, 12, 14, 0, 0, 0, time.UTC)
h.mgr.handleProcessStatus(h.exec, &agentctl.ProcessStatusUpdate{
SessionID: "session-1",
ProcessID: "proc-9",
Command: "make test",
ScriptName: "setup",
WorkingDir: "/workspace",
Status: "failed",
ExitCode: &exitCode,
Timestamp: at,
})
got := h.only(t)
require.Equal(t, events.ProcessStatus, got.Event.Type)
payload, ok := got.Event.Data.(ProcessStatusEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Equal(t, "proc-9", payload.ProcessID)
require.Equal(t, "make test", payload.Command)
require.Equal(t, "setup", payload.ScriptName)
require.Equal(t, "/workspace", payload.WorkingDir)
require.Equal(t, "failed", payload.Status)
require.NotNil(t, payload.ExitCode)
require.Equal(t, 2, *payload.ExitCode)
}
func TestPublishAvailableCommandsCarriesCommandList(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.eventPublisher.PublishAvailableCommands(h.exec, []streams.AvailableCommand{
{Name: "review", Description: "Review the diff"},
})
got := h.only(t)
require.Equal(t, events.BuildAvailableCommandsSubject("session-1"), got.Subject)
require.Equal(t, events.AvailableCommandsUpdated, got.Event.Type)
payload, ok := got.Event.Data.(AvailableCommandsEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Len(t, payload.AvailableCommands, 1)
require.Equal(t, "review", payload.AvailableCommands[0].Name)
require.Equal(t, "Review the diff", payload.AvailableCommands[0].Description)
}
func TestPublishPermissionRequestProjectsOptions(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.eventPublisher.PublishPermissionRequest(h.exec, agentctl.AgentEvent{
PendingID: "pending-1",
ToolCallID: "tool-1",
PermissionTitle: "Run `rm -rf build`",
ActionType: "execute",
ActionDetails: map[string]any{"command": "rm -rf build"},
PermissionOptions: []agentctl.PermissionOption{
{OptionID: "allow", Name: "Allow", Kind: "allow_once"},
{OptionID: "deny", Name: "Deny", Kind: "reject_once"},
},
})
got := h.only(t)
require.Equal(t, events.BuildPermissionRequestSubject("session-1"), got.Subject)
payload, ok := got.Event.Data.(PermissionRequestEventPayload)
require.True(t, ok, "payload type = %T", got.Event.Data)
require.Equal(t, "permission_request", payload.Type)
require.Equal(t, "pending-1", payload.PendingID)
require.Equal(t, "tool-1", payload.ToolCallID)
require.Equal(t, "Run `rm -rf build`", payload.Title)
require.Equal(t, "execute", payload.ActionType)
require.Equal(t, map[string]any{"command": "rm -rf build"}, payload.ActionDetails)
require.Equal(t, []PermissionOption{
{OptionID: "allow", Name: "Allow", Kind: "allow_once"},
{OptionID: "deny", Name: "Deny", Kind: "reject_once"},
}, payload.Options, "every option must reach the UI or the dialog renders unusable buttons")
}
// TestEventPublisherWithoutBusIsInert pins that a publisher wired without an
// event bus (embedded / test configurations) is a silent no-op rather than a
// nil-map panic on every workspace notification.
func TestEventPublisherWithoutBusIsInert(t *testing.T) {
publisher := NewEventPublisher(nil, newTestLogger())
execution := &AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"}
publisher.PublishGitEvent(&GitEventPayload{Type: GitEventTypeStatusUpdate, SessionID: "session-1"})
publisher.PublishFileChange(execution, &agentctl.FileChangeNotification{Path: "a.go"})
publisher.PublishShellOutput(execution, "hi")
publisher.PublishShellExit(execution, 0)
publisher.PublishPermissionRequest(execution, agentctl.AgentEvent{})
publisher.PublishAvailableCommands(execution, nil)
publisher.PublishProcessOutput(execution, &agentctl.ProcessOutput{})
publisher.PublishProcessStatus(execution, &agentctl.ProcessStatusUpdate{})
}
// TestPublishGitEventStampsTimestampWhenMissing pins the fallback: a payload
// built without a timestamp still reaches subscribers with one, so the frontend
// can order events.
func TestPublishGitEventStampsTimestampWhenMissing(t *testing.T) {
h := newWorkspaceEventsHarness(t)
h.mgr.eventPublisher.PublishGitEvent(&GitEventPayload{
Type: GitEventTypeStatusUpdate,
SessionID: "session-1",
})
payload, ok := h.only(t).Event.Data.(*GitEventPayload)
require.True(t, ok)
require.NotEmpty(t, payload.Timestamp)
_, err := time.Parse(time.RFC3339Nano, payload.Timestamp)
require.NoError(t, err)
}
@@ -0,0 +1,342 @@
package lifecycle
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/task/models"
)
func TestResolveSessionRuntimeFallbackOrder(t *testing.T) {
ctx := context.Background()
t.Run("empty session id", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.ResolveSessionRuntime(ctx, "")
require.ErrorContains(t, err, "session_id is required")
})
t.Run("in-memory execution wins", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", ExecutorType: string(models.ExecutorTypeSSH)},
}}
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", RuntimeName: agentruntime.RuntimeDocker,
}))
got, err := mgr.ResolveSessionRuntime(ctx, "session-1")
require.NoError(t, err)
require.Equal(t, agentruntime.RuntimeDocker, got,
"a live execution's runtime is authoritative over the persisted executor type")
})
t.Run("no provider configured", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.ResolveSessionRuntime(ctx, "session-1")
require.ErrorContains(t, err, "workspace info provider not configured")
})
t.Run("provider error", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{err: errors.New("db down")}
_, err := mgr.ResolveSessionRuntime(ctx, "session-1")
require.ErrorContains(t, err, "failed to resolve runtime for session session-1")
})
t.Run("session not found", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{}}
_, err := mgr.ResolveSessionRuntime(ctx, "session-1")
require.ErrorContains(t, err, "session session-1 not found")
})
t.Run("falls back to stored runtime name", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", RuntimeName: agentruntime.RuntimeSprites},
}}
got, err := mgr.ResolveSessionRuntime(ctx, "session-1")
require.NoError(t, err)
require.Equal(t, agentruntime.RuntimeSprites, got,
"legacy records carry only a runtime name")
})
t.Run("defaults to standalone", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1"},
}}
got, err := mgr.ResolveSessionRuntime(ctx, "session-1")
require.NoError(t, err)
require.Equal(t, agentruntime.RuntimeStandalone, got)
})
}
func TestVerifyPassthroughEnabledRejectsNonPassthroughProfile(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = &mockPassthroughProfileResolver{cliPassthrough: false}
_, err := mgr.verifyPassthroughEnabled(context.Background(), "session-1", "profile-1")
require.ErrorContains(t, err, "is not configured for CLI passthrough mode")
}
func TestVerifyPassthroughEnabledSurfacesResolveFailure(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = &mockPassthroughProfileResolver{err: errors.New("profile deleted")}
_, err := mgr.verifyPassthroughEnabled(context.Background(), "session-1", "profile-1")
require.ErrorContains(t, err, "failed to resolve profile profile-1")
require.ErrorContains(t, err, "profile deleted")
}
func TestVerifyPassthroughEnabledWithoutResolver(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = nil
_, err := mgr.verifyPassthroughEnabled(context.Background(), "session-1", "profile-1")
require.ErrorContains(t, err, "has no profile configured for passthrough mode")
}
// TestEnsurePassthroughExecutionChecksAccessBeforeCacheLookup pins that a
// non-owner cannot reach a cached execution: the scoping check runs before the
// execution store is consulted.
func TestEnsurePassthroughExecutionChecksAccessBeforeCacheLookup(t *testing.T) {
denied := errors.New("session access denied")
provider := &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {TaskID: "task-1", SessionID: "session-1", WorkspacePath: "/work"},
}}
mgr := newTestManager(t)
mgr.workspaceInfoProvider = provider
mgr.SetSessionAccessChecker(func(context.Context, string) error { return denied })
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-1", SessionID: "session-1"}))
_, err := mgr.EnsurePassthroughExecution(context.Background(), "session-1")
require.ErrorIs(t, err, denied)
require.Zero(t, provider.sessionCalls, "authorization must run before any lookup work")
}
// TestEnsurePassthroughExecutionResumesExistingExecution covers the recovery
// branch taken after a backend restart: an execution exists but its PTY does
// not, so the resume path runs (and here fails for lack of a runner).
func TestEnsurePassthroughExecutionResumesExistingExecution(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = &mockPassthroughProfileResolver{agentName: "claude-acp", cliPassthrough: true}
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1", AgentProfileID: "profile-1",
// A stale process ID: the runner no longer knows this process.
PassthroughProcessID: "pty-dead",
}))
_, err := mgr.EnsurePassthroughExecution(context.Background(), "session-1")
require.ErrorContains(t, err, "resume passthrough session session-1",
"a stale process ID must fall through to the resume path rather than being returned as live")
require.ErrorContains(t, err, "interactive runner not available")
}
func TestCreateExecutionFromSessionInfoRequiresProvider(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.EnsurePassthroughExecution(context.Background(), "session-1")
require.ErrorContains(t, err, "cannot restore session session-1: workspace info provider not configured")
}
func TestCreateExecutionFromSessionInfoSurfacesProviderError(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{err: errors.New("db down")}
_, err := mgr.EnsurePassthroughExecution(context.Background(), "session-1")
require.ErrorContains(t, err, "get workspace info for session session-1")
require.ErrorContains(t, err, "db down")
}
func TestCreateExecutionFromSessionInfoRequiresWorkspaceAndTask(t *testing.T) {
ctx := context.Background()
t.Run("no workspace path", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {TaskID: "task-1", SessionID: "session-1"},
}}
_, err := mgr.EnsurePassthroughExecution(ctx, "session-1")
require.ErrorIs(t, err, ErrSessionWorkspaceNotReady,
"the terminal handler retries on this sentinel instead of surfacing a hard failure")
})
t.Run("no task id", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", WorkspacePath: "/work"},
}}
_, err := mgr.EnsurePassthroughExecution(ctx, "session-1")
require.ErrorContains(t, err, "session session-1 has no associated task ID")
})
}
// TestCreateExecutionFromSessionInfoRejectsNonPassthroughProfile pins that the
// terminal recovery path refuses to spawn a PTY for an ACP session.
func TestCreateExecutionFromSessionInfoRejectsNonPassthroughProfile(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = &mockPassthroughProfileResolver{cliPassthrough: false}
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {
TaskID: "task-1", SessionID: "session-1", WorkspacePath: "/work",
ExecutionProfileID: "profile-1",
},
}}
_, err := mgr.EnsurePassthroughExecution(context.Background(), "session-1")
require.ErrorContains(t, err, "is not configured for CLI passthrough mode")
_, exists := mgr.GetExecutionBySessionID("session-1")
require.False(t, exists, "a rejected passthrough recovery must not leave an execution behind")
}
func TestApplyResumeIntentDerivesFromPreviousExecution(t *testing.T) {
fresh := &AgentExecution{ID: "exec-1"}
applyResumeIntent(fresh, &ExecutorCreateRequest{})
require.False(t, fresh.isResumedSession,
"a session that never ran has no CLI conversation for a resume flag to attach to")
recovered := &AgentExecution{ID: "exec-2"}
applyResumeIntent(recovered, &ExecutorCreateRequest{PreviousExecutionID: "exec-1"})
require.True(t, recovered.isResumedSession)
}
func TestWorkspaceProfileIDHelpers(t *testing.T) {
require.Empty(t, workspaceExecutionProfileID(nil))
require.Empty(t, workspaceExecutorProfileID(nil))
info := &WorkspaceInfo{AgentProfileID: "office-1"}
require.Equal(t, "office-1", workspaceExecutionProfileID(info),
"a legacy session uses its Office identity as the execution profile")
info.ExecutionProfileID = "exec-profile-1"
require.Equal(t, "exec-profile-1", workspaceExecutionProfileID(info))
info.ExecutorProfileID = "executor-profile-1"
require.Equal(t, "executor-profile-1", workspaceExecutorProfileID(info))
}
func TestGetOrEnsureExecutionRequiresSessionID(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.GetOrEnsureExecution(context.Background(), "")
require.ErrorContains(t, err, "session_id is required")
}
func TestGetOrEnsureExecutionForEnvironmentValidatesProviderResponse(t *testing.T) {
ctx := context.Background()
t.Run("empty environment id", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.GetOrEnsureExecutionForEnvironment(ctx, "")
require.ErrorContains(t, err, "task_environment_id is required")
})
t.Run("no provider", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.GetOrEnsureExecutionForEnvironment(ctx, "env-1")
require.ErrorContains(t, err, "workspace info provider not configured")
})
t.Run("provider returns a different environment", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{envInfos: map[string]*WorkspaceInfo{
"env-1": {TaskEnvironmentID: "env-other", SessionID: "session-1", WorkspacePath: "/work"},
}}
_, err := mgr.GetOrEnsureExecutionForEnvironment(ctx, "env-1")
require.ErrorContains(t, err, "workspace info resolved environment env-other, want env-1",
"a mismatched environment must fail loudly rather than silently binding the wrong workspace")
})
t.Run("environment without workspace path", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{envInfos: map[string]*WorkspaceInfo{
"env-1": {TaskEnvironmentID: "env-1", SessionID: "session-1"},
}}
_, err := mgr.GetOrEnsureExecutionForEnvironment(ctx, "env-1")
require.ErrorIs(t, err, ErrSessionWorkspaceNotReady)
})
t.Run("environment without a session", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{envInfos: map[string]*WorkspaceInfo{
"env-1": {TaskEnvironmentID: "env-1", WorkspacePath: "/work"},
}}
_, err := mgr.GetOrEnsureExecutionForEnvironment(ctx, "env-1")
require.ErrorContains(t, err, "task environment env-1 has no task session")
})
}
func TestEnsureWorkspaceExecutionForSessionRequiresSessionID(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.EnsureWorkspaceExecutionForSession(context.Background(), "task-1", "")
require.ErrorContains(t, err, "session_id is required")
}
func TestEnsureWorkspaceExecutionLockedReusesEnvironmentExecution(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-new": {
TaskID: "task-1", SessionID: "session-new", TaskEnvironmentID: "env-1", WorkspacePath: "/work",
},
}}
existing := &AgentExecution{
ID: "exec-existing", TaskID: "task-1", SessionID: "session-old", TaskEnvironmentID: "env-1",
}
require.NoError(t, mgr.executionStore.Add(existing))
got, err := mgr.EnsureWorkspaceExecutionForSession(context.Background(), "task-1", "session-new")
require.NoError(t, err)
require.Same(t, existing, got,
"sibling sessions in one task environment must share the existing execution, not create a second one")
}
func TestEnsureWorkspaceExecutionLockedRequiresProviderAndInfo(t *testing.T) {
ctx := context.Background()
t.Run("no provider", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.EnsureWorkspaceExecutionForSession(ctx, "task-1", "session-1")
require.ErrorContains(t, err, "workspace info provider not configured")
})
t.Run("provider error", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{err: errors.New("db down")}
_, err := mgr.EnsureWorkspaceExecutionForSession(ctx, "task-1", "session-1")
require.ErrorContains(t, err, "failed to get workspace info for session session-1")
})
t.Run("session not found", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{}}
_, err := mgr.EnsureWorkspaceExecutionForSession(ctx, "task-1", "session-1")
require.ErrorContains(t, err, "session session-1 not found")
})
t.Run("workspace not ready", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {TaskID: "task-1", SessionID: "session-1"},
}}
_, err := mgr.EnsureWorkspaceExecutionForSession(ctx, "task-1", "session-1")
require.ErrorIs(t, err, ErrSessionWorkspaceNotReady)
})
}
@@ -0,0 +1,402 @@
package lifecycle
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func TestWasSessionInitializedReflectsExecutionFlag(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-init", sessionInitialized: true}))
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-pending"}))
require.True(t, mgr.WasSessionInitialized("exec-init"))
require.False(t, mgr.WasSessionInitialized("exec-pending"))
require.False(t, mgr.WasSessionInitialized("exec-absent"), "an unknown execution is not initialized")
}
func TestGetAvailableCommandsForSessionReturnsCachedCommands(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
exec.SetAvailableCommands([]streams.AvailableCommand{
{Name: "review", Description: "Review the diff"},
{Name: "commit"},
})
require.NoError(t, mgr.executionStore.Add(exec))
got := mgr.GetAvailableCommandsForSession("session-1")
require.Len(t, got, 2)
require.Equal(t, "review", got[0].Name)
require.Equal(t, "Review the diff", got[0].Description)
require.Equal(t, "commit", got[1].Name)
require.Nil(t, mgr.GetAvailableCommandsForSession("session-absent"))
}
func TestGetModeStateForSessionReturnsCachedMode(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
exec.SetModeState(&CachedModeState{
CurrentModeID: "plan",
AvailableModes: []streams.SessionModeInfo{{ID: "plan"}, {ID: "default"}},
})
require.NoError(t, mgr.executionStore.Add(exec))
state := mgr.GetModeStateForSession("session-1")
require.NotNil(t, state)
require.Equal(t, "plan", state.CurrentModeID)
require.Len(t, state.AvailableModes, 2)
require.Nil(t, mgr.GetModeStateForSession("session-absent"))
}
func TestGetModelStateForSessionReturnsCachedModel(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
exec.SetModelState(&CachedModelState{CurrentModelID: "claude-opus-5"})
require.NoError(t, mgr.executionStore.Add(exec))
state := mgr.GetModelStateForSession("session-1")
require.NotNil(t, state)
require.Equal(t, "claude-opus-5", state.CurrentModelID)
require.Nil(t, mgr.GetModelStateForSession("session-absent"))
}
func TestGetPromptGenerationForSessionTracksBeginPrompt(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", Status: v1.AgentStatusReady,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}))
before, err := mgr.GetPromptGenerationForSession(context.Background(), "session-1")
require.NoError(t, err)
generation, err := mgr.BeginPrompt("exec-1")
require.NoError(t, err)
require.Greater(t, generation, before, "BeginPrompt must advance the prompt generation")
after, err := mgr.GetPromptGenerationForSession(context.Background(), "session-1")
require.NoError(t, err)
require.Equal(t, generation, after,
"the session-scoped read must observe the generation BeginPrompt handed out")
require.True(t, mgr.OwnsPromptGeneration("session-1", "exec-1", generation))
require.False(t, mgr.OwnsPromptGeneration("session-1", "exec-1", generation+1))
}
func TestGetPromptGenerationForSessionUnknownSession(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.GetPromptGenerationForSession(context.Background(), "session-absent")
require.ErrorIs(t, err, ErrNoExecutionForSession)
}
func TestBeginPromptUnknownExecution(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.BeginPrompt("missing")
require.ErrorContains(t, err, `execution "missing" not found`)
}
func TestGetExecutionIDForSession(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-42", SessionID: "session-42"}))
id, err := mgr.GetExecutionIDForSession(context.Background(), "session-42")
require.NoError(t, err)
require.Equal(t, "exec-42", id)
id, err = mgr.GetExecutionIDForSession(context.Background(), "session-absent")
require.ErrorIs(t, err, ErrNoExecutionForSession)
require.Empty(t, id)
}
func TestIsAgentCommandConfigured(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-agent", AgentCommand: "claude"}))
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-workspace"}))
require.True(t, mgr.IsAgentCommandConfigured("exec-agent"))
require.False(t, mgr.IsAgentCommandConfigured("exec-workspace"),
"a workspace-only execution has not been promoted to an agent execution")
require.False(t, mgr.IsAgentCommandConfigured("exec-absent"))
}
func TestResolveTaskEnvironmentIDPrefersInMemoryExecution(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", TaskEnvironmentID: "env-from-db"},
}}
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", TaskEnvironmentID: "env-in-memory",
}))
got, err := mgr.ResolveTaskEnvironmentID(context.Background(), "session-1")
require.NoError(t, err)
require.Equal(t, "env-in-memory", got)
}
func TestResolveTaskEnvironmentIDErrors(t *testing.T) {
ctx := context.Background()
t.Run("empty session id", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.ResolveTaskEnvironmentID(ctx, "")
require.ErrorContains(t, err, "session_id is required")
})
t.Run("execution without environment", func(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-1", SessionID: "session-1"}))
_, err := mgr.ResolveTaskEnvironmentID(ctx, "session-1")
require.ErrorContains(t, err, "has no task environment ID")
})
t.Run("no provider configured", func(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.ResolveTaskEnvironmentID(ctx, "session-1")
require.ErrorContains(t, err, "workspace info provider not configured")
})
t.Run("provider returns nil info", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{}}
_, err := mgr.ResolveTaskEnvironmentID(ctx, "session-1")
require.ErrorContains(t, err, "has no task environment ID")
})
t.Run("provider falls back for missing execution", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", TaskEnvironmentID: "env-from-db"},
}}
got, err := mgr.ResolveTaskEnvironmentID(ctx, "session-1")
require.NoError(t, err)
require.Equal(t, "env-from-db", got)
})
}
// TestIsAgentRunningForSessionStartingGrace pins the bounded boot window: a
// STARTING execution is reported live without probing agentctl, but only until
// the grace expires.
func TestIsAgentRunningForSessionStartingGrace(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-fresh", SessionID: "session-fresh",
Status: v1.AgentStatusStarting, StartedAt: time.Now(),
}))
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-stale", SessionID: "session-stale",
Status: v1.AgentStatusStarting, StartedAt: time.Now().Add(-2 * agentStartupLivenessGrace),
}))
require.True(t, mgr.IsAgentRunningForSession(context.Background(), "session-fresh"))
require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-stale"),
"a starting execution past the grace window must fall through to probing and be reaped")
require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-absent"))
}
func TestIsAgentRunningForSessionPassthroughWithoutRunner(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-pty", SessionID: "session-pty", Status: v1.AgentStatusReady,
PassthroughProcessID: "pty-1", StartedAt: time.Now(),
}))
require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-pty"),
"without an interactive runner a passthrough process cannot be confirmed alive")
}
func TestIsAgentReadyForPromptRequiresInitializedACPSession(t *testing.T) {
ctx := context.Background()
t.Run("no execution", func(t *testing.T) {
mgr := newTestManager(t)
require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-absent"))
})
t.Run("not ready status", func(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", Status: v1.AgentStatusRunning,
}))
require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-1"))
})
t.Run("ready but session not initialized", func(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", Status: v1.AgentStatusReady,
agentctl: newReadyAgentctlClient(t, newTestLogger()),
}))
require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-1"),
"an uninitialized ACP session cannot take a prompt even when the status is ready")
})
t.Run("passthrough delegates to running check", func(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-pty", SessionID: "session-pty", Status: v1.AgentStatusReady,
IsPassthrough: true, StartedAt: time.Now(),
}))
require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-pty"))
})
}
func TestStopBySessionIDStopsResolvedExecution(t *testing.T) {
log := newTestRegistryLogger()
execRegistry := NewExecutorRegistry(log)
stopTracker := &mockStopTracker{name: executor.NameStandalone}
execRegistry.Register(stopTracker)
mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, nil, nil, nil, ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-stop",
TaskID: "task-stop",
SessionID: "session-stop",
RuntimeName: executor.NameStandalone,
Status: v1.AgentStatusRunning,
}))
require.NoError(t, mgr.StopBySessionID(context.Background(), "session-stop", false))
require.True(t, stopTracker.stopCalled, "StopBySessionID must reach the executor backend")
require.Equal(t, "exec-stop", stopTracker.stoppedInstanceID)
_, exists := mgr.GetExecutionBySessionID("session-stop")
require.False(t, exists, "a stopped execution is removed from tracking")
bus, ok := mgr.eventBus.(*MockEventBus)
require.True(t, ok)
require.NotEmpty(t, bus.PublishedEvents)
require.Equal(t, events.AgentStopped, bus.PublishedEvents[len(bus.PublishedEvents)-1].Type)
}
func TestStopBySessionIDUnknownSession(t *testing.T) {
mgr := newTestManager(t)
err := mgr.StopBySessionID(context.Background(), "session-absent", false)
require.ErrorContains(t, err, `no agent running for session "session-absent"`)
}
func TestStopAgentUnknownExecution(t *testing.T) {
mgr := newTestManager(t)
err := mgr.StopAgent(context.Background(), "missing", false)
require.ErrorIs(t, err, ErrExecutionNotFound)
}
// TestStopAgentForcePassesForceToBackend pins that the force flag reaches the
// executor backend rather than being swallowed by the graceful-stop branch.
func TestStopAgentForcePassesForceToBackend(t *testing.T) {
log := newTestRegistryLogger()
execRegistry := NewExecutorRegistry(log)
stopTracker := &forceRecordingStopTracker{mockStopTracker: mockStopTracker{name: executor.NameStandalone}}
execRegistry.Register(stopTracker)
mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, nil, nil, nil, ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-force", SessionID: "session-force", RuntimeName: executor.NameStandalone,
}))
require.NoError(t, mgr.StopAgentWithReason(context.Background(), "exec-force", StopReasonBackendShutdown, true))
require.True(t, stopTracker.forced, "force must be forwarded to StopInstance")
require.Equal(t, StopReasonBackendShutdown, stopTracker.stopReason)
}
type forceRecordingStopTracker struct {
mockStopTracker
forced bool
}
func (m *forceRecordingStopTracker) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error {
m.forced = force
return m.mockStopTracker.StopInstance(ctx, instance, force)
}
func TestSteerAgentWithDispatchCallbackUnknownExecution(t *testing.T) {
mgr := newTestManager(t)
_, err := mgr.SteerAgentWithDispatchCallback(context.Background(), "missing", "hi", nil, false, nil)
require.ErrorIs(t, err, ErrExecutionNotFound)
}
func TestUpdateStatusUnknownExecution(t *testing.T) {
mgr := newTestManager(t)
err := mgr.UpdateStatus("missing", v1.AgentStatusReady)
require.ErrorContains(t, err, `execution "missing" not found`)
}
func TestUpdateStatusTransitionsExecution(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1", Status: v1.AgentStatusStarting}
require.NoError(t, mgr.executionStore.Add(exec))
require.NoError(t, mgr.UpdateStatus("exec-1", v1.AgentStatusRunning))
require.Equal(t, v1.AgentStatusRunning, exec.Status)
}
func TestMarkBootReadyFromFailedOnlyPromotesFailedExecutions(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-ready", SessionID: "session-ready", Status: v1.AgentStatusReady,
}))
failed := &AgentExecution{ID: "exec-failed", SessionID: "session-failed", Status: v1.AgentStatusFailed}
require.NoError(t, mgr.executionStore.Add(failed))
require.NoError(t, mgr.markBootReadyFromFailed(context.Background(), "exec-ready"),
"a non-failed execution is a no-op, not an error")
require.ErrorContains(t, mgr.markBootReadyFromFailed(context.Background(), "missing"),
`execution "missing" not found`)
require.NoError(t, mgr.markBootReadyFromFailed(context.Background(), "exec-failed"))
require.Equal(t, v1.AgentStatusReady, failed.Status)
bus, ok := mgr.eventBus.(*MockEventBus)
require.True(t, ok)
require.Len(t, bus.PublishedEvents, 1, "only the failed execution publishes a boot-ready event")
require.Equal(t, events.AgentBootReady, bus.PublishedEvents[0].Type)
}
func TestGetSessionAuthMethodsFallsBackByAgentID(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-claude", SessionID: "session-claude", AgentID: "claude-acp",
}))
cached := &AgentExecution{ID: "exec-cached", SessionID: "session-cached", AgentID: "claude-acp"}
cached.SetAuthMethods([]streams.AuthMethodInfo{{ID: "cached-method"}})
require.NoError(t, mgr.executionStore.Add(cached))
fallback := mgr.GetSessionAuthMethods("session-claude")
require.Len(t, fallback, 1)
require.Equal(t, "claude-auth-login", fallback[0].ID)
require.NotNil(t, fallback[0].TerminalAuth)
require.Equal(t, "claude", fallback[0].TerminalAuth.Command)
live := mgr.GetSessionAuthMethods("session-cached")
require.Len(t, live, 1)
require.Equal(t, "cached-method", live[0].ID,
"reported capabilities win over the static fallback")
require.Nil(t, mgr.GetSessionAuthMethods("session-absent"))
}
@@ -0,0 +1,463 @@
package lifecycle
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
)
// sessionOpsHarness wires a Manager to a live agentctl agent stream backed by
// the mock WS server, recording every request the manager makes so tests can
// assert the exact action and payload that reached agentctl rather than merely
// that the call returned nil.
type sessionOpsHarness struct {
mgr *Manager
exec *AgentExecution
mu sync.Mutex
requests []recordedStreamRequest
}
type recordedStreamRequest struct {
action string
payload map[string]interface{}
raw []byte
}
// decode unmarshals the captured wire payload into v. Needed for payloads whose
// fields carry `omitempty` (permission responses), where the typed shape — not
// the raw map — is what agentctl actually observes.
func (r recordedStreamRequest) decode(t *testing.T, v interface{}) {
t.Helper()
require.NoError(t, json.Unmarshal(r.raw, v))
}
func (h *sessionOpsHarness) record(msg ws.Message) {
payload := map[string]interface{}{}
raw := append([]byte(nil), msg.Payload...)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
h.mu.Lock()
h.requests = append(h.requests, recordedStreamRequest{action: msg.Action, payload: payload, raw: raw})
h.mu.Unlock()
}
// only returns the single recorded request, failing when the manager made a
// different number of agentctl calls than expected.
func (h *sessionOpsHarness) only(t *testing.T) recordedStreamRequest {
t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
require.Len(t, h.requests, 1, "expected exactly one agentctl stream request, got %+v", h.requests)
return h.requests[0]
}
func (h *sessionOpsHarness) count() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.requests)
}
// newSessionOpsHarness builds a manager holding one execution whose agentctl
// client has a connected agent stream. The execution is registered in the
// store under both its execution ID and session ID.
func newSessionOpsHarness(t *testing.T) *sessionOpsHarness {
t.Helper()
h := &sessionOpsHarness{}
mock := newMockAgentServer(t)
t.Cleanup(mock.Close)
mock.handler = func(msg ws.Message) *ws.Message {
h.record(msg)
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
return resp
}
client := createTestClient(t, mock.server.URL)
t.Cleanup(client.Close)
streamCtx, streamCancel := context.WithCancel(context.Background())
t.Cleanup(streamCancel)
require.NoError(t, client.StreamUpdates(streamCtx, func(agentctlclient.AgentEvent) {}, nil, nil))
select {
case <-mock.wsConnected:
case <-time.After(2 * time.Second):
t.Fatal("mock agentctl server never saw the agent stream connect")
}
h.mgr = newTestManager(t)
h.exec = &AgentExecution{
ID: "exec-ops",
TaskID: "task-ops",
SessionID: "session-ops",
ACPSessionID: "acp-live",
Status: v1.AgentStatusReady,
sessionInitialized: true,
agentctl: client,
}
require.NoError(t, h.mgr.executionStore.Add(h.exec))
return h
}
// TestSetSessionModeUsesLiveACPSessionID pins the documented invariant on
// SetSessionMode: the ACP session ID sent to agentctl always comes from the
// execution, never from the caller's (possibly stale) copy.
func TestSetSessionModeUsesLiveACPSessionID(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.SetSessionMode(context.Background(), "exec-ops", "acp-stale-from-caller", "plan"))
req := h.only(t)
require.Equal(t, "agent.session.set_mode", req.action)
require.Equal(t, "acp-live", req.payload["session_id"],
"SetSessionMode must send the execution's current ACP session ID, not the caller's argument")
require.Equal(t, "plan", req.payload["mode_id"])
}
func TestSetSessionModeBySessionIDResolvesExecution(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.SetSessionModeBySessionID(context.Background(), "session-ops", "accept-edits"))
req := h.only(t)
require.Equal(t, "agent.session.set_mode", req.action)
require.Equal(t, "acp-live", req.payload["session_id"])
require.Equal(t, "accept-edits", req.payload["mode_id"])
}
func TestSetSessionModeGuardsRejectBeforeReachingAgentctl(t *testing.T) {
ctx := context.Background()
t.Run("unknown execution", func(t *testing.T) {
h := newSessionOpsHarness(t)
err := h.mgr.SetSessionMode(ctx, "missing", "", "plan")
require.ErrorContains(t, err, `execution "missing" not found`)
require.Zero(t, h.count(), "no agentctl call may be made for an unknown execution")
})
t.Run("no agentctl client", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.executionStore.Add(&AgentExecution{
ID: "exec-bare", SessionID: "session-bare", ACPSessionID: "acp", sessionInitialized: true,
}))
err := h.mgr.SetSessionMode(ctx, "exec-bare", "", "plan")
require.ErrorContains(t, err, "has no agentctl client")
require.Zero(t, h.count())
})
t.Run("acp session not initialized", func(t *testing.T) {
h := newSessionOpsHarness(t)
h.exec.sessionInitialized = false
err := h.mgr.SetSessionMode(ctx, "exec-ops", "", "plan")
require.ErrorContains(t, err, "ACP session is not ready")
require.Zero(t, h.count())
})
t.Run("acp session id empty", func(t *testing.T) {
h := newSessionOpsHarness(t)
h.exec.ACPSessionID = ""
err := h.mgr.SetSessionMode(ctx, "exec-ops", "", "plan")
require.ErrorContains(t, err, "ACP session is not ready")
require.Zero(t, h.count())
})
t.Run("unknown session id", func(t *testing.T) {
h := newSessionOpsHarness(t)
err := h.mgr.SetSessionModeBySessionID(ctx, "nope", "plan")
require.ErrorContains(t, err, `no agent running for session "nope"`)
require.Zero(t, h.count())
})
}
func TestSetSessionModelSendsModelToAgentctl(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.SetSessionModelBySessionID(context.Background(), "session-ops", "claude-opus-5"))
req := h.only(t)
require.Equal(t, "agent.session.set_model", req.action)
require.Equal(t, "claude-opus-5", req.payload["model_id"])
require.Empty(t, h.exec.metadataString(MetadataKeyModelOverride),
"an ACP session swaps the model in-place and must not persist a relaunch override")
}
// TestSetSessionModelPassthroughPersistsOverrideAndRestarts pins the
// passthrough branch: a TUI agent has no live channel for the model, so the
// override is persisted and the process is relaunched instead. The restart is
// expected to fail here (no interactive runner is wired), which is what proves
// RestartAgentProcess was reached — but the override must already be stored.
func TestSetSessionModelPassthroughPersistsOverrideAndRestarts(t *testing.T) {
h := newSessionOpsHarness(t)
h.exec.PassthroughProcessID = "pty-1"
err := h.mgr.SetSessionModel(context.Background(), "exec-ops", "gpt-5-codex")
require.Error(t, err, "restart must surface the missing interactive runner")
require.ErrorContains(t, err, "interactive runner not available")
require.Equal(t, "gpt-5-codex", h.exec.metadataString(MetadataKeyModelOverride),
"passthrough model change must persist the override for the next launch")
require.Zero(t, h.count(), "passthrough agents have no ACP channel for set_model")
}
func TestSetSessionModelGuards(t *testing.T) {
ctx := context.Background()
t.Run("unknown execution", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.SetSessionModel(ctx, "missing", "m"), `execution "missing" not found`)
require.Zero(t, h.count())
})
t.Run("no agentctl client", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.executionStore.Add(&AgentExecution{ID: "exec-bare", SessionID: "session-bare"}))
require.ErrorContains(t, h.mgr.SetSessionModel(ctx, "exec-bare", "m"), "has no agentctl client")
require.Zero(t, h.count())
})
t.Run("unknown session id", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.SetSessionModelBySessionID(ctx, "nope", "m"), `no agent running for session "nope"`)
require.Zero(t, h.count())
})
}
func TestSetSessionConfigOptionSendsIDAndValue(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.SetSessionConfigOptionBySessionID(context.Background(), "session-ops", "reasoning", "high"))
req := h.only(t)
require.Equal(t, "agent.session.set_config_option", req.action)
require.Equal(t, "reasoning", req.payload["config_id"])
require.Equal(t, "high", req.payload["value"])
}
func TestSetSessionConfigOptionGuards(t *testing.T) {
ctx := context.Background()
t.Run("unknown execution", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.SetSessionConfigOption(ctx, "missing", "c", "v"), `execution "missing" not found`)
require.Zero(t, h.count())
})
t.Run("acp session not ready", func(t *testing.T) {
h := newSessionOpsHarness(t)
h.exec.ACPSessionID = ""
require.ErrorContains(t, h.mgr.SetSessionConfigOption(ctx, "exec-ops", "c", "v"), "ACP session is not ready")
require.Zero(t, h.count())
})
t.Run("unknown session id", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.SetSessionConfigOptionBySessionID(ctx, "nope", "c", "v"),
`no agent running for session "nope"`)
require.Zero(t, h.count())
})
}
func TestAuthenticateBySessionIDSendsMethodID(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.AuthenticateBySessionID(context.Background(), "session-ops", "claude-auth-login"))
req := h.only(t)
require.Equal(t, "agent.session.authenticate", req.action)
require.Equal(t, "claude-auth-login", req.payload["method_id"])
}
func TestAuthenticateBySessionIDGuards(t *testing.T) {
ctx := context.Background()
t.Run("unknown session", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.AuthenticateBySessionID(ctx, "nope", "m"), `no agent running for session "nope"`)
require.Zero(t, h.count())
})
t.Run("no agentctl client", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.executionStore.Add(&AgentExecution{ID: "exec-bare", SessionID: "session-bare"}))
require.ErrorContains(t, h.mgr.AuthenticateBySessionID(ctx, "session-bare", "m"), "has no agentctl client")
require.Zero(t, h.count())
})
}
func TestRespondToPermissionForwardsDecision(t *testing.T) {
for _, tc := range []struct {
name string
optionID string
cancelled bool
}{
{name: "approve", optionID: "allow", cancelled: false},
{name: "deny", optionID: "deny", cancelled: false},
{name: "dialog cancelled", optionID: "", cancelled: true},
} {
t.Run(tc.name, func(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.RespondToPermissionBySessionID("session-ops", "pending-7", tc.optionID, tc.cancelled))
req := h.only(t)
require.Equal(t, "agent.permissions.respond", req.action)
var got struct {
PendingID string `json:"pending_id"`
OptionID string `json:"option_id"`
Cancelled bool `json:"cancelled"`
}
req.decode(t, &got)
require.Equal(t, "pending-7", got.PendingID)
require.Equal(t, tc.optionID, got.OptionID)
require.Equal(t, tc.cancelled, got.Cancelled)
})
}
}
func TestRespondToPermissionGuards(t *testing.T) {
t.Run("unknown execution", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.RespondToPermission("missing", "p", "allow", false),
"agent execution not found: missing")
require.Zero(t, h.count())
})
t.Run("no agentctl client", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.executionStore.Add(&AgentExecution{ID: "exec-bare", SessionID: "session-bare"}))
require.ErrorContains(t, h.mgr.RespondToPermission("exec-bare", "p", "allow", false),
"agent execution has no agentctl client")
require.Zero(t, h.count())
})
t.Run("unknown session", func(t *testing.T) {
h := newSessionOpsHarness(t)
require.ErrorContains(t, h.mgr.RespondToPermissionBySessionID("nope", "p", "allow", false),
"no agent execution found for session: nope")
require.Zero(t, h.count())
})
}
// TestCancelAgentBySessionIDPassthroughWritesCtrlC pins that a passthrough
// session is cancelled with a PTY Ctrl-C rather than an ACP agent.cancel, and
// that a failed write stays non-fatal so the caller still reconciles DB state.
func TestCancelAgentBySessionIDPassthroughSendsCtrlCNotACPCancel(t *testing.T) {
h := newSessionOpsHarness(t)
h.exec.PassthroughProcessID = "pty-1"
// No interactive runner is wired, so WritePassthroughStdin fails — the
// documented non-fatal path.
require.NoError(t, h.mgr.CancelAgentBySessionID(context.Background(), "session-ops"),
"a failed PTY write must not surface as a cancel error")
require.Zero(t, h.count(), "passthrough cancel must not send agent.cancel over ACP")
}
func TestCancelAgentBySessionIDACPPathSendsCancel(t *testing.T) {
h := newSessionOpsHarness(t)
require.NoError(t, h.mgr.CancelAgentBySessionID(context.Background(), "session-ops"))
req := h.only(t)
require.Equal(t, "agent.cancel", req.action)
}
func TestCancelAgentBySessionIDUnknownSession(t *testing.T) {
h := newSessionOpsHarness(t)
err := h.mgr.CancelAgentBySessionID(context.Background(), "nope")
require.ErrorIs(t, err, ErrNoExecutionForSession)
require.Zero(t, h.count())
}
func TestCancelAgentRejectsExecutionWithoutAgentctl(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-bare", SessionID: "session-bare"}))
err := mgr.CancelAgent(context.Background(), "exec-bare")
require.ErrorContains(t, err, "has no agentctl client")
}
func TestCancelAgentUnknownExecution(t *testing.T) {
mgr := newTestManager(t)
err := mgr.CancelAgent(context.Background(), "missing")
require.ErrorContains(t, err, `execution "missing" not found`)
}
// TestSetMcpProvidersForSessionSendsProviders exercises the HTTP-backed MCP
// provider refresh and its documented no-op-on-missing-session contract.
func TestSetMcpProvidersForSessionRequiresSessionID(t *testing.T) {
mgr := newTestManager(t)
require.ErrorContains(t, mgr.SetMcpProvidersForSession(context.Background(), "", nil), "session_id is required")
}
func TestSetMcpProvidersForSessionMissingExecutionIsNoOp(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.SetMcpProvidersForSession(context.Background(), "session-absent", []string{"github"}),
"an absent execution is a successful no-op: the next launch re-derives providers")
}
func TestSetMcpProvidersForSessionRequiresAgentctl(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-bare", SessionID: "session-bare"}))
err := mgr.SetMcpProvidersForSession(context.Background(), "session-bare", []string{"github"})
require.ErrorContains(t, err, "has no agentctl client")
}
func TestSetMcpModeGuards(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-bare", SessionID: "session-bare"}))
require.ErrorContains(t, mgr.SetMcpMode(context.Background(), "missing", "config"), `execution "missing" not found`)
require.ErrorContains(t, mgr.SetMcpMode(context.Background(), "exec-bare", "config"), "has no agentctl client")
}
// TestCancelAgentEscalatesWhenStreamDisconnected covers the branch where the
// stream is torn down before the cancel lands: the manager escalates locally
// rather than surfacing the transport error.
func TestCancelAgentEscalatesWhenStreamDisconnected(t *testing.T) {
prevEsc := cancelEscalationTimeout
cancelEscalationTimeout = 50 * time.Millisecond
t.Cleanup(func() { cancelEscalationTimeout = prevEsc })
mock := newMockAgentServer(t)
t.Cleanup(mock.Close)
client := createTestClient(t, mock.server.URL)
t.Cleanup(client.Close)
mgr := newTestManager(t)
promptFinished := make(chan struct{})
exec := &AgentExecution{
ID: "exec-disc",
SessionID: "session-disc",
Status: v1.AgentStatusRunning,
agentctl: client,
promptFinished: promptFinished,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
require.NoError(t, mgr.executionStore.Add(exec))
// The agent stream was never connected, so Cancel returns
// ErrAgentStreamNotConnected — the teardown-race path.
err := mgr.CancelAgent(context.Background(), "exec-disc")
require.True(t, errors.Is(err, ErrCancelEscalated),
"a disconnected stream must escalate locally, got %v", err)
require.Equal(t, v1.AgentStatusReady, exec.Status,
"escalation must leave the execution ready so the workflow can advance")
}
@@ -0,0 +1,421 @@
package lifecycle
import (
"context"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/task/models"
)
func validTestRemoteContribution(number int, sourcePath string) models.RemoteContribution {
return models.RemoteContribution{
Version: models.RemoteContributionVersion,
Provider: models.RemoteContributionProviderGitHub,
Kind: models.RemoteContributionKindPullRequest,
CanonicalURL: "https://github.com/acme/widget/pull/" + strconv.Itoa(number),
Number: number,
State: models.RemoteContributionStateOpen,
BaseBranch: "main",
HeadBranch: "feature/remote",
HeadSHA: strings.Repeat("a", 40),
SourceRepository: models.RemoteContributionRepository{
Host: "github.com", Path: sourcePath, RemoteURL: "https://github.com/" + sourcePath + ".git",
},
CollaborationAllowed: true,
}
}
// TestBuildLaunchMetadataExecutorConfigWinsForTrustedKeys pins the security
// boundary: connection-routing keys always come from the configured executor
// record, while ordinary per-task keys keep the caller's value.
func TestBuildLaunchMetadataExecutorConfigWinsForTrustedKeys(t *testing.T) {
req := &LaunchRequest{
Metadata: map[string]interface{}{
MetadataKeySSHHost: "attacker.example.com",
MetadataKeySSHHostFingerprint: "SHA256:forged",
MetadataKeySSHUser: "root",
MetadataKeyCleanupScript: "task-cleanup.sh",
},
ExecutorConfig: map[string]string{
MetadataKeySSHHost: "trusted.example.com",
MetadataKeySSHHostFingerprint: "SHA256:pinned",
MetadataKeySSHUser: "deploy",
MetadataKeyCleanupScript: "executor-cleanup.sh",
MetadataKeySSHWorkdirRoot: "/srv/kandev",
},
}
metadata := buildLaunchMetadata(req, "", "", "")
require.Equal(t, "trusted.example.com", metadata[MetadataKeySSHHost],
"a task metadata payload must never be able to pivot the SSH host")
require.Equal(t, "SHA256:pinned", metadata[MetadataKeySSHHostFingerprint],
"the pinned host key must not be overridable by request metadata")
require.Equal(t, "deploy", metadata[MetadataKeySSHUser])
require.Equal(t, "task-cleanup.sh", metadata[MetadataKeyCleanupScript],
"untrusted keys keep the caller's value when present")
require.Equal(t, "/srv/kandev", metadata[MetadataKeySSHWorkdirRoot],
"untrusted executor-config keys still fill in when the caller supplied nothing")
}
func TestIsTrustedExecutorConfigKey(t *testing.T) {
for _, key := range []string{
MetadataKeySSHHost, MetadataKeySSHHostAlias, MetadataKeySSHPort, MetadataKeySSHUser,
MetadataKeySSHHostFingerprint, MetadataKeySSHIdentitySource, MetadataKeySSHIdentityFile,
MetadataKeySSHProxyJump,
} {
require.True(t, isTrustedExecutorConfigKey(key), "%s steers the connection and must be trusted-only", key)
}
for _, key := range []string{
MetadataKeySSHWorkdirRoot, MetadataKeySSHShell, MetadataKeyCleanupScript, MetadataKeySetupScript,
} {
require.False(t, isTrustedExecutorConfigKey(key), "%s is per-task config, not connection routing", key)
}
}
func TestBuildLaunchMetadataProjectsWorktreeAndRepoFields(t *testing.T) {
req := &LaunchRequest{
RepositoryPath: "/repos/widget",
SetupScript: "make setup",
BaseBranch: "main",
}
metadata := buildLaunchMetadata(req, "/repos/widget/.git", "wt-1", "kandev/feature")
require.Equal(t, "/repos/widget/.git", metadata[MetadataKeyMainRepoGitDir])
require.Equal(t, "wt-1", metadata[MetadataKeyWorktreeID])
require.Equal(t, "kandev/feature", metadata[MetadataKeyWorktreeBranch])
require.Equal(t, "/repos/widget", metadata[MetadataKeyRepositoryPath])
require.Equal(t, "make setup", metadata[MetadataKeySetupScript])
require.Equal(t, "main", metadata[MetadataKeyBaseBranch])
require.Equal(t, map[string]string{"": "main"}, metadata[MetadataKeyBaseBranches],
"the single-repo base branch is recorded under the empty key for single-repo trackers")
}
func TestBuildLaunchMetadataOmitsEmptyOptionalKeys(t *testing.T) {
metadata := buildLaunchMetadata(&LaunchRequest{}, "", "", "")
for _, key := range []string{
MetadataKeyMainRepoGitDir, MetadataKeyWorktreeID, MetadataKeyWorktreeBranch,
MetadataKeyRepositoryPath, MetadataKeySetupScript, MetadataKeyBaseBranch, MetadataKeyBaseBranches,
} {
require.NotContains(t, metadata, key, "%s must be absent when the request carries no value", key)
}
}
func TestCollectRemoteContributionsSingleRepoUsesRootKey(t *testing.T) {
binding := validTestRemoteContribution(7, "contributor/widget")
req := &LaunchRequest{RemoteContribution: &binding}
bindings, err := collectRemoteContributions(req)
require.NoError(t, err)
require.Len(t, bindings, 1)
require.Contains(t, bindings, "", "a repo-less launch binds the workspace root")
require.Equal(t, binding.CanonicalURL, bindings[""].CanonicalURL)
}
func TestCollectRemoteContributionsMultiRepoKeysSiblings(t *testing.T) {
first := validTestRemoteContribution(7, "contributor/widget")
second := validTestRemoteContribution(9, "contributor/gadget")
req := &LaunchRequest{
Repositories: []RepoLaunchSpec{
{RepositoryID: "r1", RepoName: "widget", RemoteContribution: &first},
{RepositoryID: "r2", RepoName: "gadget", BranchSlug: "hotfix", RemoteContribution: &second},
},
}
bindings, err := collectRemoteContributions(req)
require.NoError(t, err)
require.Len(t, bindings, 2)
require.Equal(t, first.CanonicalURL, bindings[""].CanonicalURL,
"the first repository owns the workspace root")
require.Equal(t, second.CanonicalURL, bindings["gadget-hotfix"].CanonicalURL,
"siblings use the same deterministic key as base-branch projection")
}
func TestCollectRemoteContributionsRejectsConflictingBindings(t *testing.T) {
first := validTestRemoteContribution(7, "contributor/widget")
second := validTestRemoteContribution(9, "contributor/widget")
req := &LaunchRequest{
Repositories: []RepoLaunchSpec{
{RepositoryID: "r1", RepoName: "widget", RemoteContribution: &first},
// Same key (index > 0 but identical name/slug) with a different URL.
{RepositoryID: "r2", RepoName: "widget", RemoteContribution: &second},
{RepositoryID: "r3", RepoName: "widget", RemoteContribution: &second},
},
}
_, err := collectRemoteContributions(req)
require.NoError(t, err, "identical sibling URLs are not a conflict")
third := validTestRemoteContribution(11, "contributor/widget")
req.Repositories[2].RemoteContribution = &third
_, err = collectRemoteContributions(req)
require.ErrorContains(t, err, "multiple remote contributions target workspace repository")
}
func TestCollectRemoteContributionsPropagatesValidationFailure(t *testing.T) {
invalid := validTestRemoteContribution(7, "contributor/widget")
invalid.State = "closed"
t.Run("single repo", func(t *testing.T) {
_, err := collectRemoteContributions(&LaunchRequest{RemoteContribution: &invalid})
require.ErrorContains(t, err, "validate remote contribution")
})
t.Run("multi repo names the repository", func(t *testing.T) {
_, err := collectRemoteContributions(&LaunchRequest{
Repositories: []RepoLaunchSpec{{RepositoryID: "r1", RepoName: "widget", RemoteContribution: &invalid}},
})
require.ErrorContains(t, err, `validate remote contribution for repository "widget"`)
})
}
func TestCollectRemoteContributionsEmptyInputs(t *testing.T) {
bindings, err := collectRemoteContributions(nil)
require.NoError(t, err)
require.Nil(t, bindings)
bindings, err = collectRemoteContributions(&LaunchRequest{})
require.NoError(t, err)
require.Nil(t, bindings)
bindings, err = collectRemoteContributions(&LaunchRequest{
Repositories: []RepoLaunchSpec{{RepositoryID: "r1", RepoName: "widget"}},
})
require.NoError(t, err)
require.Nil(t, bindings, "repositories without bindings produce no map at all")
}
// TestBuildEnvPrepareRequestForwardsMultiRepoSpecs pins the per-repo
// RepoSetupScript inheritance: an entry without its own script inherits the
// request-level one, and an entry with one keeps it.
func TestBuildEnvPrepareRequestForwardsMultiRepoSpecs(t *testing.T) {
req := &LaunchRequest{
TaskID: "task-1",
WorkspaceID: "ws-1",
SessionID: "session-1",
TaskTitle: "Fix the widget",
Metadata: map[string]interface{}{
MetadataKeyRepoSetupScript: "shared-setup.sh",
MetadataKeyWorktreeBranch: "kandev/fix-widget",
},
Repositories: []RepoLaunchSpec{
{RepositoryID: "r1", RepoName: "widget", BaseBranch: "main"},
{RepositoryID: "r2", RepoName: "gadget", RepoSetupScript: "gadget-setup.sh", PRNumber: 12},
},
}
prep := buildEnvPrepareRequest(req, "/work/task-1", executor.NameStandalone)
require.Equal(t, "task-1", prep.TaskID)
require.Equal(t, "ws-1", prep.WorkspaceID)
require.Equal(t, "Fix the widget", prep.TaskTitle)
require.Equal(t, executor.NameStandalone, prep.ExecutorType)
require.Equal(t, "/work/task-1", prep.WorkspacePath)
require.Equal(t, "shared-setup.sh", prep.RepoSetupScript)
require.Equal(t, "kandev/fix-widget", prep.WorktreeBranch,
"the worktree branch is read out of launch metadata, not a request field")
require.Len(t, prep.Repositories, 2)
require.Equal(t, "shared-setup.sh", prep.Repositories[0].RepoSetupScript,
"a repo without its own setup script inherits the request-level one")
require.Equal(t, "main", prep.Repositories[0].BaseBranch)
require.Equal(t, "gadget-setup.sh", prep.Repositories[1].RepoSetupScript,
"a repo with its own setup script keeps it")
require.Equal(t, 12, prep.Repositories[1].PRNumber)
}
func TestBuildEnvPrepareRequestSingleRepoLeavesRepositoriesEmpty(t *testing.T) {
req := &LaunchRequest{
TaskID: "task-1",
RepositoryPath: "/repos/widget",
UseWorktree: true,
BaseBranch: "main",
Env: map[string]string{"FOO": "bar"},
}
prep := buildEnvPrepareRequest(req, "/work/task-1", executor.NameDocker)
require.Empty(t, prep.Repositories, "a legacy single-repo launch carries no per-repo spec list")
require.Equal(t, "/repos/widget", prep.RepositoryPath)
require.True(t, prep.UseWorktree)
require.Equal(t, map[string]string{"FOO": "bar"}, prep.Env)
require.Empty(t, prep.RepoSetupScript)
}
func TestExecutionProfileIDPrefersExplicitExecutionProfile(t *testing.T) {
require.Empty(t, executionProfileID(nil))
require.Equal(t, "agent-profile", executionProfileID(&LaunchRequest{AgentProfileID: "agent-profile"}))
require.Equal(t, "exec-profile", executionProfileID(&LaunchRequest{
AgentProfileID: "agent-profile", ExecutionProfileID: "exec-profile",
}), "a concrete execution profile wins over the Office identity")
}
func TestApplyRouteOverrideToProfileReplacesOnlyExplicitValues(t *testing.T) {
profile := &AgentProfileInfo{AgentName: "claude-acp", Model: "sonnet", Mode: "default"}
applyRouteOverrideToProfile(profile, &LaunchRequest{RouteOverride: &RouteOverride{Model: "opus"}})
require.Equal(t, "claude-acp", profile.AgentName, "an empty ProviderID must not clear the profile agent")
require.Equal(t, "opus", profile.Model)
require.Empty(t, profile.Mode, "routing owns the mode, so it is applied unconditionally")
}
func TestApplyRouteOverrideToProfileNilInputs(t *testing.T) {
profile := &AgentProfileInfo{AgentName: "claude-acp", Mode: "plan"}
applyRouteOverrideToProfile(nil, &LaunchRequest{RouteOverride: &RouteOverride{ProviderID: "codex"}})
applyRouteOverrideToProfile(profile, nil)
applyRouteOverrideToProfile(profile, &LaunchRequest{})
require.Equal(t, "claude-acp", profile.AgentName)
require.Equal(t, "plan", profile.Mode, "no override means no mutation at all")
}
func TestRuntimeEnvFromMetadataAcceptsBothShapes(t *testing.T) {
require.Empty(t, runtimeEnvFromMetadata(nil))
require.Empty(t, runtimeEnvFromMetadata(map[string]interface{}{}))
typed := runtimeEnvFromMetadata(map[string]interface{}{
"runtime_env": map[string]string{"ANTHROPIC_API_KEY": "sk-live", "PATH": "/usr/bin"},
})
require.Equal(t, map[string]string{"ANTHROPIC_API_KEY": "sk-live", "PATH": "/usr/bin"}, typed,
"the in-memory shape must reach the agent subprocess verbatim")
decoded := runtimeEnvFromMetadata(map[string]interface{}{
"runtime_env": map[string]interface{}{"ANTHROPIC_API_KEY": "sk-live", "PORT": 8080},
})
require.Equal(t, map[string]string{"ANTHROPIC_API_KEY": "sk-live"}, decoded,
"a JSON round-tripped map keeps string values and drops non-strings")
}
func TestGetAttachmentsFromMetadata(t *testing.T) {
exec := &AgentExecution{ID: "exec-1"}
require.Nil(t, getAttachmentsFromMetadata(exec))
exec.setMetadataValue("attachments", "not-a-slice")
require.Nil(t, getAttachmentsFromMetadata(exec), "a wrong-typed value must not panic or leak through")
want := []MessageAttachment{{AttachmentID: "att-1", Type: "image", MimeType: "image/png"}}
exec.setMetadataValue("attachments", want)
require.Equal(t, want, getAttachmentsFromMetadata(exec))
}
// TestSetExecutionEnvStoresDefensiveCopy pins the Runtime().Env contract at the
// execution boundary: what SetExecutionEnv stores is exactly what
// configureAndStartAgent later hands to the agentctl child process, and the
// caller's map cannot mutate it after the fact.
func TestSetExecutionEnvStoresDefensiveCopy(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
require.NoError(t, mgr.executionStore.Add(exec))
env := map[string]string{"ANTHROPIC_BASE_URL": "https://proxy.internal", "KANDEV_TASK_ID": "task-1"}
require.NoError(t, mgr.SetExecutionEnv(context.Background(), "exec-1", env))
env["ANTHROPIC_BASE_URL"] = "https://attacker.example"
require.Equal(t,
map[string]string{"ANTHROPIC_BASE_URL": "https://proxy.internal", "KANDEV_TASK_ID": "task-1"},
runtimeEnvFromMetadata(exec.MetadataSnapshot()),
"the stored runtime env must be a copy taken at call time")
}
func TestSetExecutionEnvAndDescriptionRejectUnknownExecution(t *testing.T) {
mgr := newTestManager(t)
require.ErrorContains(t, mgr.SetExecutionEnv(context.Background(), "missing", nil), `execution "missing" not found`)
require.ErrorContains(t, mgr.SetExecutionDescription(context.Background(), "missing", "x"),
`execution "missing" not found`)
}
func TestSetExecutionDescriptionFeedsPassthroughPromptInjection(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
require.NoError(t, mgr.executionStore.Add(exec))
require.NoError(t, mgr.SetExecutionDescription(context.Background(), "exec-1", "Ship the release"))
require.Equal(t, "Ship the release", getTaskDescriptionFromMetadata(exec),
"the description set on a workspace-only execution is what the prompt path reads back")
}
func TestPrepareProgressRecorderMergeFillsOnlyBlankSlots(t *testing.T) {
recorder := newPrepareProgressRecorder(nil)
recorder.Callback(0)(PrepareStep{Name: "Validate Docker"}, 0, 2)
recorder.Merge([]PrepareStep{
{Name: "Overwrite attempt"},
{Name: "Create worktree"},
{Name: "Run setup"},
})
steps := recorder.Steps()
require.Equal(t, 3, recorder.Len())
require.Equal(t, "Validate Docker", steps[0].Name, "a recorded step must not be overwritten by a merge")
require.Equal(t, "Create worktree", steps[1].Name, "a blank slot is filled from the merged list")
require.Equal(t, "Run setup", steps[2].Name, "steps past the recorded length are appended")
}
func TestPrepareProgressRecorderCallbackOffsetsIndexes(t *testing.T) {
var gotIndex, gotTotal int
recorder := newPrepareProgressRecorder(func(_ PrepareStep, index, total int) {
gotIndex, gotTotal = index, total
})
recorder.Callback(2)(PrepareStep{Name: "Clone repository"}, 1, 3)
require.Equal(t, 3, gotIndex, "the callback index is offset by the preceding phase's step count")
require.Equal(t, 5, gotTotal)
require.Equal(t, "Clone repository", recorder.Steps()[3].Name)
}
func TestScratchWorkspacePathRejectsTraversalIDs(t *testing.T) {
mgr := newTestManager(t)
mgr.dataDir = t.TempDir()
require.Equal(t, filepath.Join(mgr.dataDir, "tasks", "ws-1", "task-1"),
mgr.scratchWorkspacePath(&LaunchRequest{TaskID: "task-1", WorkspaceID: "ws-1"}))
require.Equal(t, filepath.Join(mgr.dataDir, "quick-chat", "session-1"),
mgr.scratchWorkspacePath(&LaunchRequest{SessionID: "session-1", IsEphemeral: true}))
require.Empty(t, mgr.scratchWorkspacePath(&LaunchRequest{SessionID: "a/../b", IsEphemeral: true}))
require.Empty(t, mgr.scratchWorkspacePath(&LaunchRequest{TaskID: "task-1"}),
"a non-ephemeral scratch workspace needs both a task and a workspace ID")
require.Empty(t, mgr.scratchWorkspacePath(&LaunchRequest{TaskID: "..", WorkspaceID: "ws-1"}))
require.Empty(t, mgr.scratchWorkspacePath(&LaunchRequest{TaskID: "task-1", WorkspaceID: "a/b"}))
}
func TestResolveWorkspaceFromProvider(t *testing.T) {
ctx := context.Background()
req := &LaunchRequest{TaskID: "task-1", SessionID: "session-1"}
t.Run("no provider", func(t *testing.T) {
mgr := newTestManager(t)
require.Empty(t, mgr.resolveWorkspaceFromProvider(ctx, req))
})
t.Run("provider returns path", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", WorkspacePath: "/work/task-1"},
}}
require.Equal(t, "/work/task-1", mgr.resolveWorkspaceFromProvider(ctx, req))
})
t.Run("provider returns empty path", func(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1"},
}}
require.Empty(t, mgr.resolveWorkspaceFromProvider(ctx, req))
})
}
@@ -0,0 +1,142 @@
package lifecycle
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// TestResolveResumeWorktreePathUsesProviderPath pins the worktree-resume
// branch: the workspace comes from the persisted session, and the main repo's
// git dir is derived from the request so the executor can find the parent repo.
func TestResolveResumeWorktreePathUsesProviderPath(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", WorkspacePath: "/work/task-1/backend"},
}}
req := &LaunchRequest{
TaskID: "task-1", SessionID: "session-1",
RepositoryPath: "/repos/backend", WorktreeID: "wt-1",
}
ws, gitDir, worktreeID, branch := mgr.resolveResumeWorktreePath(context.Background(), req)
require.Equal(t, "/work/task-1/backend", ws)
require.Equal(t, filepath.Join("/repos/backend", ".git"), gitDir)
require.Equal(t, "wt-1", worktreeID)
require.Empty(t, branch, "a resume reuses the existing branch rather than naming a new one")
}
func TestResolveResumeWorktreePathWithoutRepositoryPath(t *testing.T) {
mgr := newTestManager(t)
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{
"session-1": {SessionID: "session-1", WorkspacePath: "/work/task-1"},
}}
ws, gitDir, _, _ := mgr.resolveResumeWorktreePath(context.Background(),
&LaunchRequest{TaskID: "task-1", SessionID: "session-1"})
require.Equal(t, "/work/task-1", ws)
require.Empty(t, gitDir)
}
func TestResolveResumeWorktreePathUnresolvableWorkspace(t *testing.T) {
mgr := newTestManager(t)
ws, gitDir, worktreeID, branch := mgr.resolveResumeWorktreePath(context.Background(),
&LaunchRequest{TaskID: "task-1", SessionID: "session-1", WorktreeID: "wt-1"})
require.Empty(t, ws)
require.Empty(t, gitDir)
require.Empty(t, worktreeID, "an unresolvable workspace must not leak a worktree ID")
require.Empty(t, branch)
}
// TestLaunchResolveWorkspacePathSkipsCloneBasedExecutors pins the containment
// rule: a host checkout must never be handed to a clone-based executor.
func TestLaunchResolveWorkspacePathSkipsCloneBasedExecutors(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&capabilityExecutor{
MockExecutor: MockExecutor{name: "sprites"},
requiresClone: true,
})
mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil,
ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
ws, gitDir, worktreeID, branch := mgr.launchResolveWorkspacePath(context.Background(), &LaunchRequest{
ExecutorType: string(models.ExecutorTypeSprites),
WorkspacePath: "/host/checkout",
RepositoryPath: "/repos/backend",
SessionID: "session-1",
})
require.Empty(t, ws, "a clone-based executor owns its own filesystem")
require.Empty(t, gitDir)
require.Empty(t, worktreeID)
require.Empty(t, branch)
}
func TestLaunchResolveWorkspacePathFallsBackToRepositoryPath(t *testing.T) {
mgr := newTestManager(t)
ws, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), &LaunchRequest{
RepositoryPath: "/repos/backend",
SessionID: "session-1",
})
require.Equal(t, "/repos/backend", ws,
"a launch with no explicit workspace works directly in the repository")
}
// TestLaunchResolveWorkspacePathDefersWorktreeCreation pins that a fresh
// worktree launch returns nothing — the WorktreePreparer owns creation.
func TestLaunchResolveWorkspacePathDefersWorktreeCreation(t *testing.T) {
mgr := newTestManager(t)
ws, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), &LaunchRequest{
UseWorktree: true,
RepositoryPath: "/repos/backend",
SessionID: "session-1",
})
require.Empty(t, ws, "a first worktree launch has no ACP session and defers to the preparer")
}
func TestTruncateID(t *testing.T) {
require.Equal(t, "abc", truncateID("abc", 8))
require.Equal(t, "abcdefgh", truncateID("abcdefgh", 8))
require.Equal(t, "abcdefgh", truncateID("abcdefghijkl", 8))
}
func TestExecutorRunningStatusFromExecution(t *testing.T) {
require.Equal(t, models.ExecutorRunningStatusStarting, executorRunningStatusFromExecution(nil))
for status, want := range map[v1.AgentStatus]string{
v1.AgentStatusRunning: models.ExecutorRunningStatusRunning,
v1.AgentStatusReady: models.ExecutorRunningStatusReady,
v1.AgentStatusFailed: models.ExecutorRunningStatusFailed,
v1.AgentStatusStopped: models.ExecutorRunningStatusStopped,
v1.AgentStatusCompleted: models.ExecutorRunningStatusComplete,
v1.AgentStatusStarting: models.ExecutorRunningStatusStarting,
"": models.ExecutorRunningStatusStarting,
} {
require.Equal(t, want, executorRunningStatusFromExecution(&AgentExecution{Status: status}),
"status %q", status)
}
}
func TestIsTerminalExecutorRunningStatus(t *testing.T) {
require.True(t, isTerminalExecutorRunningStatus(models.ExecutorRunningStatusFailed))
require.True(t, isTerminalExecutorRunningStatus(models.ExecutorRunningStatusStopped))
require.True(t, isTerminalExecutorRunningStatus(models.ExecutorRunningStatusComplete))
require.False(t, isTerminalExecutorRunningStatus(models.ExecutorRunningStatusRunning),
"a live row must keep its local liveness handle")
require.False(t, isTerminalExecutorRunningStatus(models.ExecutorRunningStatusStarting))
}
@@ -0,0 +1,422 @@
package lifecycle
import (
"bytes"
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agentctl/server/process"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// runnerBackend is a minimal ExecutorBackend that exposes a real
// InteractiveRunner. The runner is the designed injection seam for the
// passthrough launch paths; a runner with no started processes lets every
// failure and guard branch run without spawning a PTY.
type runnerBackend struct {
MockExecutor
runner *process.InteractiveRunner
}
func (b *runnerBackend) GetInteractiveRunner() *process.InteractiveRunner { return b.runner }
// terminalRecorder stands in for the terminal WebSocket writer so the banner
// text the manager emits can be asserted verbatim.
type terminalRecorder struct {
mu sync.Mutex
buf bytes.Buffer
}
func (w *terminalRecorder) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Write(p)
}
func (w *terminalRecorder) Close() error { return nil }
func (w *terminalRecorder) text() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
// newPassthroughRunnerManager returns a manager whose standalone backend
// exposes a real InteractiveRunner, plus a passthrough-capable execution whose
// workspace points at a directory that exists.
func newPassthroughRunnerManager(t *testing.T) (*Manager, *process.InteractiveRunner, *AgentExecution) {
t.Helper()
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
log := newTestRegistryLogger()
runner := process.NewInteractiveRunner(nil, log, 1024)
registry := NewExecutorRegistry(log)
registry.Register(&runnerBackend{
MockExecutor: MockExecutor{name: executor.NameStandalone},
runner: runner,
})
mgr.executorRegistry = registry
require.NoError(t, mgr.executionStore.Add(execution))
return mgr, runner, execution
}
// missingWorkspace returns a path that does not exist. Pointing an execution at
// it makes the PTY spawn fail deterministically (the child cannot chdir),
// regardless of which agent binaries happen to be installed on the host, so no
// real agent process is ever started by these tests.
func missingWorkspace(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "does-not-exist")
}
func TestGetInteractiveRunnerResolvesStandaloneBackend(t *testing.T) {
mgr, runner, _ := newPassthroughRunnerManager(t)
require.Same(t, runner, mgr.GetInteractiveRunner())
}
// TestPassthroughRunnerErrorsSurfaceFromRunner pins that a live runner which
// does not know the recorded process ID produces the runner's own error rather
// than a generic "runner unavailable".
func TestPassthroughRunnerErrorsSurfaceFromRunner(t *testing.T) {
ctx := context.Background()
mgr, _, execution := newPassthroughRunnerManager(t)
execution.PassthroughProcessID = "pty-gone"
require.ErrorContains(t, mgr.WritePassthroughStdin(ctx, execution.SessionID, "\x03"),
"process not found: pty-gone")
require.ErrorContains(t, mgr.ResizePassthroughPTY(ctx, execution.SessionID, 80, 24),
"no process found for session")
_, err := mgr.GetPassthroughBuffer(ctx, execution.SessionID)
require.ErrorContains(t, err, "passthrough process not found")
}
func TestResolvePassthroughConfigReturnsAgentConfig(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
pt, err := mgr.ResolvePassthroughConfig(context.Background(), execution.SessionID)
require.NoError(t, err)
require.NotEmpty(t, pt.SubmitSequence,
"the resolved config must carry the submit sequence callers write to PTY stdin")
}
func TestResolvePassthroughConfigErrors(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
_, err := mgr.ResolvePassthroughConfig(context.Background(), "session-absent")
require.ErrorContains(t, err, `no execution for session "session-absent"`)
execution.AgentProfileID = ""
_, err = mgr.ResolvePassthroughConfig(context.Background(), execution.SessionID)
require.ErrorContains(t, err, "failed to get agent config")
}
// TestStartPassthroughSessionSurfacesLaunchFailure pins that a PTY that cannot
// be spawned is recorded on the execution as an error rather than leaving the
// session looking started.
func TestStartPassthroughSessionSurfacesLaunchFailure(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
err := mgr.startPassthroughSession(context.Background(), execution, nil)
require.ErrorContains(t, err, "failed to start passthrough session")
require.Empty(t, execution.PassthroughProcessID,
"a failed launch must not record a process ID the WS handler would then treat as live")
require.Equal(t, v1.AgentStatusFailed, execution.Status)
require.NotEmpty(t, execution.ErrorMessage)
}
func TestResumePassthroughSessionSurfacesLaunchFailure(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
err := mgr.ResumePassthroughSession(context.Background(), execution.SessionID)
require.ErrorContains(t, err, "failed to start passthrough session")
require.Empty(t, execution.PassthroughProcessID)
}
func TestResumePassthroughSessionUnknownSession(t *testing.T) {
mgr, _, _ := newPassthroughRunnerManager(t)
err := mgr.ResumePassthroughSession(context.Background(), "session-absent")
require.ErrorIs(t, err, ErrNoExecutionForSession)
}
// TestRestartPassthroughProcessClearsProcessIDBeforeStopping pins the ordering
// documented on restartPassthroughProcess: the ID is cleared first so the
// deliberate kill does not trip handlePassthroughStatus's auto-restart.
func TestRestartPassthroughProcessClearsProcessIDBeforeStopping(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
execution.PassthroughProcessID = "pty-old"
execution.PassthroughStartedAt = time.Now()
err := mgr.restartPassthroughProcess(context.Background(), execution)
require.ErrorContains(t, err, "failed to restart passthrough session")
require.Empty(t, execution.PassthroughProcessID,
"the old process ID is cleared so the deliberate kill is not auto-restarted")
require.True(t, execution.PassthroughStartedAt.IsZero())
require.Equal(t, v1.AgentStatusFailed, execution.Status)
}
func TestRestartPassthroughProcessWithoutRunner(t *testing.T) {
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
mgr.executorRegistry = nil
require.NoError(t, mgr.executionStore.Add(execution))
execution.PassthroughProcessID = "pty-old"
err := mgr.restartPassthroughProcess(context.Background(), execution)
require.ErrorContains(t, err, "interactive runner not available")
require.Equal(t, "pty-old", execution.PassthroughProcessID,
"without a runner nothing was stopped, so the process ID must be left intact")
}
// TestNotifyFastFailExitWritesBannerToTerminal pins the exact user-facing copy
// and the values interpolated into it: without them the user has no way to tell
// a bad CLI flag from a crash loop.
func TestNotifyFastFailExitWritesBannerToTerminal(t *testing.T) {
mgr, runner, _ := newPassthroughRunnerManager(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket("session-1", recorder)
mgr.notifyFastFailExit(runner, "session-1", 350*time.Millisecond, 127, 2*time.Second)
banner := recorder.text()
require.Contains(t, banner, "Agent exited (code 127) within 2s")
require.Contains(t, banner, "bad CLI flag, missing binary, or auth failure")
}
func TestNotifyFastFailExitToleratesMissingTerminal(t *testing.T) {
mgr, runner, _ := newPassthroughRunnerManager(t)
// No session WebSocket registered: the write fails and must be swallowed.
mgr.notifyFastFailExit(runner, "session-gone", time.Second, 1, 2*time.Second)
}
// TestNotifyFallbackInfrastructureFailureNamesTheStage pins that a kandev-side
// failure gets its own banner naming the stage, rather than the fast-fail copy
// that blames the user's profile.
func TestNotifyFallbackInfrastructureFailureNamesTheStage(t *testing.T) {
mgr, runner, _ := newPassthroughRunnerManager(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket("session-1", recorder)
mgr.notifyFallbackInfrastructureFailure(runner, "session-1", "start fresh process",
context.DeadlineExceeded)
banner := recorder.text()
require.Contains(t, banner, "Resume fallback failed: could not start fresh process")
require.Contains(t, banner, context.DeadlineExceeded.Error())
require.NotContains(t, banner, "bad CLI flag",
"an infrastructure failure must not blame the user's agent profile")
}
// TestAttemptResumeFallbackAnnouncesAndRelaunchesFresh pins the recovery a user
// sees after `claude -c` fails on a stale conversation: a fresh-start banner
// followed by a relaunch attempt with no resume flag.
func TestAttemptResumeFallbackAnnouncesAndRelaunchesFresh(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
mgr.attemptResumeFallback(execution, runner, execution.SessionID, 1, 400*time.Millisecond)
banner := recorder.text()
require.Contains(t, banner, "No prior conversation to resume")
require.Contains(t, banner, "Resume fallback failed: could not start fresh process",
"a fallback that cannot start must say so rather than leaving a silent dead terminal")
require.False(t, execution.passthroughLaunchUsedResume)
}
func TestAttemptResumeFallbackSkipsDuringShutdown(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
mgr.shuttingDown.Store(true)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
mgr.attemptResumeFallback(execution, runner, execution.SessionID, 1, time.Second)
require.Empty(t, recorder.text(), "a clean shutdown must not print a recovery banner")
}
func TestAttemptResumeFallbackSkipsWithoutTerminal(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
// No session WebSocket: nobody is watching, so no relaunch is attempted.
mgr.attemptResumeFallback(execution, runner, execution.SessionID, 1, time.Second)
require.Empty(t, execution.PassthroughProcessID)
}
// TestAttemptPassthroughRestartAnnouncesAndReportsFailure pins the restart
// banner pair the user sees when the agent exits and the relaunch also fails.
func TestAttemptPassthroughRestartAnnouncesAndReportsFailure(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
mgr.attemptPassthroughRestart(execution, runner, execution.SessionID, 1, time.Millisecond)
banner := recorder.text()
require.Contains(t, banner, "[Agent exited. Restarting...]")
require.Contains(t, banner, "[Restart failed:",
"a failed restart must be reported on the terminal, not only in backend logs")
}
func TestAttemptPassthroughRestartAbortsDuringShutdown(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
mgr.shuttingDown.Store(true)
mgr.attemptPassthroughRestart(execution, runner, execution.SessionID, 1, time.Millisecond)
require.Contains(t, recorder.text(), "[Agent exited. Restarting...]")
require.NotContains(t, recorder.text(), "[Restart failed:",
"shutdown aborts before the relaunch, so no failure banner is emitted")
}
func TestAttemptPassthroughRestartAbortsWhenTerminalDisconnects(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
// No session WebSocket at all: the announce write fails and the
// post-delay check aborts the restart.
mgr.attemptPassthroughRestart(execution, runner, execution.SessionID, 1, time.Millisecond)
require.Empty(t, execution.PassthroughProcessID)
}
// TestHandlePassthroughExitFastFailsResumeLaunch drives the exit handler end to
// end for the issue-#2330 shape: a resume launch that exits non-zero quickly
// must take the fallback path, not the plain restart path.
func TestHandlePassthroughExitFastFailsResumeLaunch(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
execution.WorkspacePath = missingWorkspace(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
startedAt := time.Now()
exitCode := 1
mgr.handlePassthroughExit(execution, &agentctltypes.ProcessStatusUpdate{
SessionID: execution.SessionID,
ProcessID: "pty-1",
Status: agentctltypes.ProcessStatusExited,
ExitCode: &exitCode,
Timestamp: startedAt.Add(200 * time.Millisecond),
}, startedAt, true)
require.Contains(t, recorder.text(), "No prior conversation to resume",
"a fast-failed resume must retry once without the resume flag")
require.NotContains(t, recorder.text(), "[Agent exited. Restarting...]",
"the fast-fail short-circuit must skip the plain restart path")
}
func TestHandlePassthroughExitFastFailsFreshLaunch(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
startedAt := time.Now()
exitCode := 127
mgr.handlePassthroughExit(execution, &agentctltypes.ProcessStatusUpdate{
SessionID: execution.SessionID,
ProcessID: "pty-1",
ExitCode: &exitCode,
Timestamp: startedAt.Add(100 * time.Millisecond),
}, startedAt, false)
require.Contains(t, recorder.text(), "Agent exited (code 127)")
require.NotContains(t, recorder.text(), "No prior conversation to resume",
"a launch that never used a resume flag has nothing to fall back from")
}
// TestHandlePassthroughExitSkipsWithoutTerminal pins the no-subscriber guard.
// The observable is that none of the relaunch machinery ran: building a fresh
// command materializes an MCP config file and records it on the execution, so
// an empty file list proves the handler returned before that work.
func TestHandlePassthroughExitSkipsWithoutTerminal(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
exitCode := 1
mgr.handlePassthroughExit(execution, &agentctltypes.ProcessStatusUpdate{
SessionID: execution.SessionID,
ProcessID: "pty-1",
ExitCode: &exitCode,
Timestamp: time.Now(),
}, time.Now(), true)
require.Empty(t, getPassthroughMCPFiles(execution),
"with nobody watching the terminal, no relaunch command may be built")
require.Empty(t, execution.PassthroughProcessID)
}
// TestHandlePassthroughExitUsesStatusTimestampNotWallClock pins that the
// measured uptime comes from the exit event, so the handler's own 100ms cleanup
// delay cannot push a genuine fast fail outside the window.
func TestHandlePassthroughExitUsesStatusTimestampNotWallClock(t *testing.T) {
mgr, runner, execution := newPassthroughRunnerManager(t)
recorder := &terminalRecorder{}
runner.TrackSessionWebSocket(execution.SessionID, recorder)
startedAt := time.Now().Add(-time.Hour)
exitCode := 3
mgr.handlePassthroughExit(execution, &agentctltypes.ProcessStatusUpdate{
SessionID: execution.SessionID,
ProcessID: "pty-1",
ExitCode: &exitCode,
// The child really lived 50ms, an hour ago.
Timestamp: startedAt.Add(50 * time.Millisecond),
}, startedAt, false)
require.True(t, strings.Contains(recorder.text(), "Agent exited (code 3)"),
"uptime must be measured from the exit event, not from time.Now()")
}
// TestStopPassthroughProcessLeavesExecutionStateToItsCaller pins that a failed
// runner stop is swallowed AND that this helper never edits the execution:
// StopAgentWithReason still needs the process ID to build the ExecutorInstance
// it hands to the backend teardown immediately afterwards.
func TestStopPassthroughProcessLeavesExecutionStateToItsCaller(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
backend, err := mgr.executorRegistry.GetBackend(executor.NameStandalone)
require.NoError(t, err)
execution.PassthroughProcessID = "pty-gone"
// The runner does not know this process; the failure is logged and
// teardown continues rather than aborting the stop.
mgr.stopPassthroughProcess(context.Background(), execution.ID, execution, backend)
require.Equal(t, "pty-gone", execution.PassthroughProcessID,
"clearing the process ID is the caller's job, not this helper's")
}
func TestStopPassthroughProcessSkipsNonPassthroughExecutions(t *testing.T) {
mgr, _, execution := newPassthroughRunnerManager(t)
backend, err := mgr.executorRegistry.GetBackend(executor.NameStandalone)
require.NoError(t, err)
// No passthrough process ID: nothing to stop.
mgr.stopPassthroughProcess(context.Background(), execution.ID, execution, backend)
// A backend without an interactive runner is also a no-op.
execution.PassthroughProcessID = "pty-1"
mgr.stopPassthroughProcess(context.Background(), execution.ID, execution,
&MockExecutor{name: executor.NameDocker})
}
@@ -0,0 +1,285 @@
package lifecycle
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/mcpconfig"
)
func TestSafePassthroughMCPConfigName(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{in: "", want: "session"},
{in: "abcXYZ019", want: "abcXYZ019"},
{in: "sess-1_2.json", want: "sess-1_2.json"},
// Dots are legal in a filename; it is the separator that is neutralised.
{in: "../../etc/passwd", want: ".._.._etc_passwd"},
{in: "a b\tc", want: "a_b_c"},
{in: "sess/../id", want: "sess_.._id"},
} {
require.Equal(t, tc.want, safePassthroughMCPConfigName(tc.in),
"safePassthroughMCPConfigName(%q)", tc.in)
}
}
func TestPassthroughMCPPathsFallBackToTempWhenNoDataDir(t *testing.T) {
mgr := newTestManager(t)
workspace := t.TempDir()
paths := mgr.passthroughMCPPaths(&AgentExecution{
ID: "exec-1", SessionID: "session-1", WorkspacePath: workspace,
})
require.Equal(t, filepath.Join(os.TempDir(), "kandev", "passthrough-mcp", "session-1.json"),
paths.TempConfigPath, "an unset data dir falls back to the OS temp dir")
require.Equal(t, workspace, paths.WorkspaceDir)
}
func TestPassthroughMCPPathsUsesExecutionIDWhenSessionMissing(t *testing.T) {
mgr := newTestManager(t)
mgr.dataDir = t.TempDir()
paths := mgr.passthroughMCPPaths(&AgentExecution{ID: "exec-1"})
require.Equal(t, filepath.Join(mgr.dataDir, "passthrough-mcp", "exec-1.json"), paths.TempConfigPath,
"a session-less execution names its config after the execution")
}
func TestPassthroughMCPConfigPortPrefersLiveStandalonePort(t *testing.T) {
require.Zero(t, passthroughMCPConfigPort(nil))
exec := &AgentExecution{}
exec.setMetadataValue("standalone_port", 45678)
require.Equal(t, 45678, passthroughMCPConfigPort(exec),
"a recovered execution reads the port back out of metadata")
exec.standalonePort = 45679
require.Equal(t, 45679, passthroughMCPConfigPort(exec),
"the live port wins over the persisted metadata copy")
}
// TestMaterializePassthroughFileOverwritesKandevOwnedTempFile pins the
// relaunch path: an existing kandev-owned config (no MergeKey) is overwritten
// and stays tracked for cleanup.
func TestMaterializePassthroughFileOverwritesKandevOwnedTempFile(t *testing.T) {
mgr := newTestManager(t)
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
require.NoError(t, os.WriteFile(path, []byte(`{"stale":true}`), 0o600))
owned, err := mgr.materializePassthroughFile(&AgentExecution{ID: "exec-1"}, mcpconfig.PassthroughConfigFile{
Path: path,
Content: []byte(`{"mcpServers":{"kandev":{}}}`),
})
require.NoError(t, err)
require.True(t, owned, "a kandev-owned temp config stays tracked so teardown removes it")
content, err := os.ReadFile(path)
require.NoError(t, err)
require.JSONEq(t, `{"mcpServers":{"kandev":{}}}`, string(content))
}
// TestMaterializePassthroughFileSkipsPathEscapingWorkspace pins the symlink
// containment guard: a config path that resolves outside the worktree is
// skipped, not written.
func TestMaterializePassthroughFileSkipsPathEscapingWorkspace(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is not reliably available on Windows CI")
}
mgr := newTestManager(t)
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
outside := filepath.Join(root, "outside")
require.NoError(t, os.MkdirAll(workspace, 0o700))
require.NoError(t, os.MkdirAll(outside, 0o700))
require.NoError(t, os.Symlink(outside, filepath.Join(workspace, ".cursor")))
target := filepath.Join(workspace, ".cursor", "mcp.json")
owned, err := mgr.materializePassthroughFile(
&AgentExecution{ID: "exec-1", WorkspacePath: workspace},
mcpconfig.PassthroughConfigFile{Path: target, Content: []byte(`{"mcpServers":{}}`), MergeKey: "mcpServers"},
)
require.NoError(t, err)
require.False(t, owned)
_, statErr := os.Stat(filepath.Join(outside, "mcp.json"))
require.True(t, os.IsNotExist(statErr),
"a symlinked parent escaping the worktree must not receive kandev's config")
}
func TestMaterializePassthroughFileSurfacesUnreadableParent(t *testing.T) {
mgr := newTestManager(t)
dir := t.TempDir()
// A regular file where a directory is expected: Lstat on the child fails
// with ENOTDIR, which is neither "exists" nor "not exist".
blocker := filepath.Join(dir, "blocker")
require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o600))
_, err := mgr.materializePassthroughFile(&AgentExecution{ID: "exec-1"}, mcpconfig.PassthroughConfigFile{
Path: filepath.Join(blocker, "config.json"),
Content: []byte(`{}`),
})
require.Error(t, err, "an unexpected lstat failure must be surfaced, not silently skipped")
}
func TestWritePassthroughMCPFilesSkipsEmptyPaths(t *testing.T) {
mgr := newTestManager(t)
execution := &AgentExecution{ID: "exec-1"}
require.NoError(t, mgr.writePassthroughMCPFiles(execution, []mcpconfig.PassthroughConfigFile{
{Path: "", Content: []byte(`{}`)},
}))
require.Empty(t, getPassthroughMCPFiles(execution),
"a strategy entry with no path is skipped and never tracked")
}
// TestWriteFileNoFollowLeavesConcurrentlyCreatedFileAlone pins the O_EXCL
// contract: a file that appeared between the lstat and the create is left
// untouched and NOT tracked for cleanup.
func TestWriteFileNoFollowLeavesConcurrentlyCreatedFileAlone(t *testing.T) {
mgr := newTestManager(t)
path := filepath.Join(t.TempDir(), "config.json")
require.NoError(t, os.WriteFile(path, []byte("user content"), 0o600))
owned, err := mgr.writeFileNoFollow(path, []byte("kandev content"))
require.NoError(t, err)
require.False(t, owned, "kandev does not own a file it did not create")
content, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "user content", string(content), "the pre-existing file must not be clobbered")
}
func TestWriteFileNoFollowSurfacesCreateFailure(t *testing.T) {
mgr := newTestManager(t)
dir := t.TempDir()
blocker := filepath.Join(dir, "blocker")
require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o600))
owned, err := mgr.writeFileNoFollow(filepath.Join(blocker, "config.json"), []byte("{}"))
require.ErrorContains(t, err, "create passthrough MCP config")
require.False(t, owned)
}
func TestMergePassthroughConfigLeavesUnreadableFileUntouched(t *testing.T) {
mgr := newTestManager(t)
// A directory cannot be read as a file: os.ReadFile fails, and the merge
// must degrade to a warning rather than clobbering anything.
dir := t.TempDir()
require.NoError(t, mgr.mergePassthroughConfig(mcpconfig.PassthroughConfigFile{
Path: dir,
Content: []byte(`{"mcpServers":{"kandev":{}}}`),
MergeKey: "mcpServers",
}))
}
func TestMergePassthroughConfigLeavesMalformedFileUntouched(t *testing.T) {
mgr := newTestManager(t)
path := filepath.Join(t.TempDir(), "mcp.json")
require.NoError(t, os.WriteFile(path, []byte("not json at all"), 0o600))
require.NoError(t, mgr.mergePassthroughConfig(mcpconfig.PassthroughConfigFile{
Path: path,
Content: []byte(`{"mcpServers":{"kandev":{}}}`),
MergeKey: "mcpServers",
}), "a malformed user config is a warning, not a launch failure")
content, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "not json at all", string(content),
"a config kandev cannot parse must never be overwritten")
}
func TestMergePassthroughConfigPreservesUserEntries(t *testing.T) {
mgr := newTestManager(t)
path := filepath.Join(t.TempDir(), "mcp.json")
require.NoError(t, os.WriteFile(path,
[]byte(`{"otherKey":"keep me","mcpServers":{"user-server":{"url":"http://user"}}}`), 0o600))
require.NoError(t, mgr.mergePassthroughConfig(mcpconfig.PassthroughConfigFile{
Path: path,
Content: []byte(`{"mcpServers":{"kandev":{"url":"http://localhost:1/mcp"}}}`),
MergeKey: "mcpServers",
}))
content, err := os.ReadFile(path)
require.NoError(t, err)
require.JSONEq(t,
`{"otherKey":"keep me","mcpServers":{"user-server":{"url":"http://user"},"kandev":{"url":"http://localhost:1/mcp"}}}`,
string(content))
}
func TestWorkspacePathEscapesIgnoresPathsOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
escapes, err := workspacePathEscapes("", filepath.Join(workspace, "anything.json"))
require.NoError(t, err)
require.False(t, escapes, "an empty workspace dir disables the check")
escapes, err = workspacePathEscapes(workspace, filepath.Join(t.TempDir(), "kandev-temp.json"))
require.NoError(t, err)
require.False(t, escapes, "kandev's own temp configs are not workspace-relative and are exempt")
escapes, err = workspacePathEscapes(workspace, filepath.Join(workspace, "nested", "deep", "mcp.json"))
require.NoError(t, err)
require.False(t, escapes, "not-yet-created parents resolve to the workspace itself")
}
func TestWorkspacePathEscapesSurfacesUnresolvableWorkspace(t *testing.T) {
missing := filepath.Join(t.TempDir(), "never-created")
_, err := workspacePathEscapes(missing, filepath.Join(missing, "mcp.json"))
require.ErrorContains(t, err, "resolve workspace dir")
}
func TestCleanupPassthroughMCPConfigToleratesRemovalFailure(t *testing.T) {
mgr := newTestManager(t)
dir := t.TempDir()
nonEmpty := filepath.Join(dir, "not-a-file")
require.NoError(t, os.MkdirAll(filepath.Join(nonEmpty, "child"), 0o700))
removable := filepath.Join(dir, "config.json")
require.NoError(t, os.WriteFile(removable, []byte(`{}`), 0o600))
execution := &AgentExecution{ID: "exec-1"}
setPassthroughMCPFiles(execution, []string{nonEmpty, removable, filepath.Join(dir, "already-gone.json")})
setPassthroughMCPEnv(execution, map[string]string{"OPENCODE_CONFIG": removable})
mgr.cleanupPassthroughMCPConfig(execution)
_, err := os.Stat(removable)
require.True(t, os.IsNotExist(err), "a removable tracked file must still be deleted")
require.Empty(t, getPassthroughMCPFiles(execution), "the tracking keys are always cleared")
require.Empty(t, getPassthroughMCPEnv(execution))
}
func TestAppendUniqueKeepsFirstOccurrence(t *testing.T) {
list := appendUnique(nil, "a")
list = appendUnique(list, "b")
list = appendUnique(list, "a")
require.Equal(t, []string{"a", "b"}, list)
}
func TestGetPassthroughMCPHelpersHandleNilExecution(t *testing.T) {
require.Nil(t, getPassthroughMCPFiles(nil))
require.Nil(t, getPassthroughMCPEnv(nil))
}
func TestSetPassthroughMCPEnvIgnoresEmptyMap(t *testing.T) {
execution := &AgentExecution{ID: "exec-1"}
setPassthroughMCPEnv(execution, nil)
setPassthroughMCPEnv(execution, map[string]string{})
_, exists := execution.metadataValue(metadataKeyPassthroughMCPEnv)
require.False(t, exists, "an empty strategy env must not write a metadata key at all")
}
@@ -0,0 +1,419 @@
package lifecycle
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/agents"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/events"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// stubCredentialsManager returns canned credential values so buildPassthroughEnv's
// required-env loop can be observed at the boundary it actually writes.
type stubCredentialsManager struct {
values map[string]string
requested []string
mu sync.Mutex
}
func (s *stubCredentialsManager) GetCredentialValue(_ context.Context, key string) (string, error) {
s.mu.Lock()
s.requested = append(s.requested, key)
s.mu.Unlock()
if value, ok := s.values[key]; ok {
return value, nil
}
return "", errors.New("no such credential")
}
// TestBuildPassthroughEnvInjectsRequiredCredentialsAndMCPEnv pins the two
// layers buildPassthroughEnv adds on top of the runtime environment: the
// agent's declared required credentials, and the env vars the passthrough MCP
// strategy contributed during command building (e.g. opencode's
// OPENCODE_CONFIG). Both must reach the PTY child process.
func TestBuildPassthroughEnvInjectsRequiredCredentialsAndMCPEnv(t *testing.T) {
mgr := newTestManager(t)
creds := &stubCredentialsManager{values: map[string]string{
"ANTHROPIC_API_KEY": "sk-live",
}}
mgr.credsMgr = creds
mgr.profileResolver = &mockPassthroughProfileResolver{
agentName: "claude-acp",
cliPassthrough: true,
envVars: []settingsmodels.ProfileEnvVar{{Key: "PROFILE_ONLY", Value: "profile-value"}},
}
execution := &AgentExecution{
ID: "exec-1",
TaskID: "task-1",
SessionID: "session-1",
AgentProfileID: "profile-1",
OfficeAgentProfileID: "office-1",
}
execution.setRuntimeEnvironment(map[string]string{
"ANTHROPIC_BASE_URL": "https://proxy.internal",
"HTTPS_PROXY": "http://proxy:3128",
})
setPassthroughMCPEnv(execution, map[string]string{"OPENCODE_CONFIG": "/tmp/opencode.json"})
env := mgr.buildPassthroughEnv(context.Background(), execution, []string{"ANTHROPIC_API_KEY", "MISSING_KEY"})
require.Equal(t, "https://proxy.internal", env["ANTHROPIC_BASE_URL"],
"Agent.Runtime().Env must reach the passthrough subprocess")
require.Equal(t, "http://proxy:3128", env["HTTPS_PROXY"])
require.Equal(t, "task-1", env["KANDEV_TASK_ID"])
require.Equal(t, "session-1", env["KANDEV_SESSION_ID"])
require.Equal(t, "office-1", env["KANDEV_AGENT_PROFILE_ID"],
"the Office identity is the agent profile ID exposed to the agent")
require.Equal(t, "profile-1", env["KANDEV_EXECUTION_PROFILE_ID"])
require.Equal(t, "profile-value", env["PROFILE_ONLY"], "profile env vars are merged in")
require.Equal(t, "sk-live", env["ANTHROPIC_API_KEY"], "required credentials are resolved and injected")
require.NotContains(t, env, "MISSING_KEY", "an unresolvable credential must not be written as an empty value")
require.Equal(t, "/tmp/opencode.json", env["OPENCODE_CONFIG"],
"env contributed by the passthrough MCP strategy must be merged")
require.Contains(t, creds.requested, "ANTHROPIC_API_KEY")
require.Contains(t, creds.requested, "MISSING_KEY")
}
func TestMarkPassthroughRunningPublishesOnceAndGuards(t *testing.T) {
mgr := newTestManager(t)
bus, ok := mgr.eventBus.(*MockEventBus)
require.True(t, ok)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-acp", SessionID: "session-acp", Status: v1.AgentStatusReady,
}))
exec := &AgentExecution{
ID: "exec-pty", SessionID: "session-pty", PassthroughProcessID: "pty-1", Status: v1.AgentStatusReady,
}
require.NoError(t, mgr.executionStore.Add(exec))
require.ErrorContains(t, mgr.MarkPassthroughRunning("session-absent"),
"no agent execution found for session: session-absent")
require.ErrorContains(t, mgr.MarkPassthroughRunning("session-acp"),
"is not in passthrough mode")
require.Empty(t, bus.PublishedEvents, "guard failures must publish nothing")
require.NoError(t, mgr.MarkPassthroughRunning("session-pty"))
require.Equal(t, v1.AgentStatusRunning, exec.Status)
require.Len(t, bus.PublishedEvents, 1)
require.Equal(t, events.AgentRunning, bus.PublishedEvents[0].Type)
require.NoError(t, mgr.MarkPassthroughRunning("session-pty"))
require.Len(t, bus.PublishedEvents, 1,
"an already-running execution must not publish a duplicate AgentRunning event")
}
func TestPassthroughAccessorsRequirePassthroughSessionAndRunner(t *testing.T) {
ctx := context.Background()
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-acp", SessionID: "session-acp"}))
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-pty", SessionID: "session-pty", PassthroughProcessID: "pty-1",
}))
require.False(t, mgr.IsPassthroughSession(ctx, "session-absent"))
require.False(t, mgr.IsPassthroughSession(ctx, "session-acp"))
require.True(t, mgr.IsPassthroughSession(ctx, "session-pty"))
t.Run("write stdin", func(t *testing.T) {
require.ErrorContains(t, mgr.WritePassthroughStdin(ctx, "session-absent", "x"),
"no agent execution found for session")
require.ErrorContains(t, mgr.WritePassthroughStdin(ctx, "session-acp", "x"),
"is not in passthrough mode")
require.ErrorContains(t, mgr.WritePassthroughStdin(ctx, "session-pty", "x"),
"interactive runner not available")
})
t.Run("resize pty", func(t *testing.T) {
require.ErrorContains(t, mgr.ResizePassthroughPTY(ctx, "session-absent", 80, 24),
"no agent execution found for session")
require.ErrorContains(t, mgr.ResizePassthroughPTY(ctx, "session-acp", 80, 24),
"is not in passthrough mode")
require.ErrorContains(t, mgr.ResizePassthroughPTY(ctx, "session-pty", 80, 24),
"interactive runner not available")
})
t.Run("get buffer", func(t *testing.T) {
_, err := mgr.GetPassthroughBuffer(ctx, "session-absent")
require.ErrorContains(t, err, "no agent execution found for session")
_, err = mgr.GetPassthroughBuffer(ctx, "session-acp")
require.ErrorContains(t, err, "is not in passthrough mode")
_, err = mgr.GetPassthroughBuffer(ctx, "session-pty")
require.ErrorContains(t, err, "interactive runner not available")
})
}
func TestGetInteractiveRunnerWithoutStandaloneBackend(t *testing.T) {
mgr := newTestManager(t)
require.Nil(t, mgr.GetInteractiveRunner(), "a manager without an executor registry has no runner")
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{name: "docker"})
mgr.executorRegistry = registry
require.Nil(t, mgr.GetInteractiveRunner(), "passthrough requires the standalone backend specifically")
}
func TestStartPassthroughShellIsNoOpWithoutAgentctl(t *testing.T) {
mgr := newTestManager(t)
// No agentctl client: the shell start must be skipped silently, not panic.
mgr.startPassthroughShell(context.Background(), &AgentExecution{ID: "exec-1"}, "warn")
}
func TestStartPassthroughShellCallsAgentctlShellStart(t *testing.T) {
var shellStarts atomic.Int32
var status atomic.Int32
status.Store(http.StatusOK)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/shell/start" {
shellStarts.Add(1)
w.WriteHeader(int(status.Load()))
_, _ = w.Write([]byte(`{"success":true}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(server.Close)
mgr := newTestManager(t)
execution := &AgentExecution{ID: "exec-1", agentctl: newTestAgentctlClient(t, server.URL, newTestLogger())}
mgr.startPassthroughShell(context.Background(), execution, "failed to start shell")
require.Equal(t, int32(1), shellStarts.Load())
// A failing shell start is logged, not propagated — the PTY session stays up.
status.Store(http.StatusInternalServerError)
mgr.startPassthroughShell(context.Background(), execution, "failed to start shell")
require.Equal(t, int32(2), shellStarts.Load())
}
func TestHandlePassthroughOutputPublishesToEventBus(t *testing.T) {
mgr := newTestManager(t)
bus, ok := mgr.eventBus.(*MockEventBus)
require.True(t, ok)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1",
}))
mgr.handlePassthroughOutput(nil)
mgr.handlePassthroughOutput(&agentctltypes.ProcessOutput{SessionID: "session-absent"})
require.Empty(t, bus.PublishedEvents, "nil output and unknown sessions publish nothing")
mgr.handlePassthroughOutput(&agentctltypes.ProcessOutput{
SessionID: "session-1",
ProcessID: "pty-1",
Stream: "stdout",
Data: "hello",
Timestamp: time.Now(),
})
require.Len(t, bus.PublishedEvents, 1)
require.Equal(t, events.ProcessOutput, bus.PublishedEvents[0].Type)
payload, ok := bus.PublishedEvents[0].Data.(ProcessOutputEventPayload)
require.True(t, ok, "payload type = %T", bus.PublishedEvents[0].Data)
require.Equal(t, "task-1", payload.TaskID)
require.Equal(t, "session-1", payload.SessionID)
require.Equal(t, "pty-1", payload.ProcessID)
require.Equal(t, "stdout", payload.Stream)
require.Equal(t, "hello", payload.Data)
}
// TestHandlePassthroughStatusSkipsRestartForShellTerminals pins the guard that
// keeps a user shell terminal's exit from restarting the agent PTY.
func TestHandlePassthroughStatusSkipsRestartForShellTerminals(t *testing.T) {
mgr := newTestManager(t)
bus, ok := mgr.eventBus.(*MockEventBus)
require.True(t, ok)
exec := &AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1",
PassthroughProcessID: "pty-agent",
PassthroughStartedAt: time.Now(),
}
exec.passthroughLaunchUsedResume = true
require.NoError(t, mgr.executionStore.Add(exec))
mgr.handlePassthroughStatus(nil)
mgr.handlePassthroughStatus(&agentctltypes.ProcessStatusUpdate{SessionID: "session-absent"})
require.Empty(t, bus.PublishedEvents)
exitCode := 1
mgr.handlePassthroughStatus(&agentctltypes.ProcessStatusUpdate{
SessionID: "session-1",
ProcessID: "shell-terminal-7",
Status: agentctltypes.ProcessStatusExited,
ExitCode: &exitCode,
Timestamp: time.Now(),
})
require.Len(t, bus.PublishedEvents, 1, "the status is still published for the shell terminal")
require.Equal(t, events.ProcessStatus, bus.PublishedEvents[0].Type)
require.False(t, exec.passthroughResumeFailed,
"a user shell terminal's exit must not mark the agent's resume as failed")
require.True(t, exec.passthroughLaunchUsedResume,
"the agent PTY's resume intent must survive an unrelated process exit")
}
// TestHandlePassthroughStatusFlipsResumeFailedSynchronously pins the
// synchronous flag flip that beats a racing WebSocket reconnect.
func TestHandlePassthroughStatusFlipsResumeFailedSynchronously(t *testing.T) {
mgr := newTestManager(t)
mgr.shuttingDown.Store(true) // Short-circuits the detached restart goroutine.
exec := &AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1",
PassthroughProcessID: "pty-agent",
PassthroughStartedAt: time.Now(),
isResumedSession: true,
}
exec.passthroughLaunchUsedResume = true
require.NoError(t, mgr.executionStore.Add(exec))
exitCode := 1
mgr.handlePassthroughStatus(&agentctltypes.ProcessStatusUpdate{
SessionID: "session-1",
ProcessID: "pty-agent",
Status: agentctltypes.ProcessStatusFailed,
ExitCode: &exitCode,
Timestamp: time.Now(),
})
require.True(t, exec.passthroughResumeFailed,
"a non-zero exit of a resume launch must set the sticky resume-failed flag before returning")
require.False(t, exec.isResumedSession)
require.False(t, exec.passthroughLaunchUsedResume)
}
func TestHandlePassthroughStatusKeepsResumeIntentOnCleanExit(t *testing.T) {
mgr := newTestManager(t)
mgr.shuttingDown.Store(true)
exec := &AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1",
PassthroughProcessID: "pty-agent",
PassthroughStartedAt: time.Now(),
}
exec.passthroughLaunchUsedResume = true
require.NoError(t, mgr.executionStore.Add(exec))
zero := 0
mgr.handlePassthroughStatus(&agentctltypes.ProcessStatusUpdate{
SessionID: "session-1",
ProcessID: "pty-agent",
Status: agentctltypes.ProcessStatusExited,
ExitCode: &zero,
Timestamp: time.Now(),
})
require.False(t, exec.passthroughResumeFailed,
"a clean exit keeps the resume intent so a quit session is auto-restarted as a resume")
require.True(t, exec.passthroughLaunchUsedResume)
}
func TestPassthroughExitCodeTreatsAbsentCodeAsClean(t *testing.T) {
require.Equal(t, 0, passthroughExitCode(&agentctltypes.ProcessStatusUpdate{}))
code := 127
require.Equal(t, 127, passthroughExitCode(&agentctltypes.ProcessStatusUpdate{ExitCode: &code}))
}
func TestHandlePassthroughTurnCompleteUnknownSession(t *testing.T) {
mgr := newTestManager(t)
bus, ok := mgr.eventBus.(*MockEventBus)
require.True(t, ok)
mgr.handlePassthroughTurnComplete("session-absent", "pty-1")
require.Empty(t, bus.PublishedEvents)
}
// TestAutoInjectMarksRunningBeforeWriting pins the ordering that blocks a
// racing composer submission: the execution must be RUNNING before the first
// chunk lands on the PTY, so checkSessionPromptable rejects a concurrent
// message.add instead of letting it race into the same PTY mid-submit.
func TestAutoInjectMarksRunningBeforeWriting(t *testing.T) {
mgr := newTestManager(t)
execution := newAutoInjectExecution("Ship the release")
execution.Status = v1.AgentStatusReady
require.NoError(t, mgr.executionStore.Add(execution))
var statusAtFirstWrite v1.AgentStatus
runner := &statusObservingRunner{onWrite: func() { statusAtFirstWrite = execution.Status }}
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{SubmitSequence: "\r"})
require.Equal(t, []string{"Ship the release\r"}, runner.writes)
require.Equal(t, v1.AgentStatusRunning, statusAtFirstWrite,
"the execution must already be RUNNING when the first PTY chunk is written")
}
func TestAutoInjectStopsAfterWriteFailure(t *testing.T) {
mgr := newTestManager(t)
execution := newAutoInjectExecution("Ship the release")
execution.Status = v1.AgentStatusReady
require.NoError(t, mgr.executionStore.Add(execution))
runner := &fakePassthroughRunner{writeErr: errors.New("process not found")}
mgr.autoInjectInitialPromptWith(runner, execution, agents.PassthroughConfig{
SubmitSequence: "\r",
DisableBracketedPaste: true,
SubmitDelay: time.Millisecond,
})
require.Len(t, runner.writes, 1,
"a failed write must abort the chunk loop instead of continuing to the submit byte")
}
func TestAutoInjectSkipsDuringShutdown(t *testing.T) {
mgr := newTestManager(t)
mgr.shuttingDown.Store(true)
runner := &fakePassthroughRunner{}
mgr.autoInjectInitialPromptWith(runner, newAutoInjectExecution("Ship it"), agents.PassthroughConfig{
SubmitSequence: "\r",
})
require.False(t, runner.writeCalled, "teardown must not race a stdin write into a dying PTY")
}
// TestAutoInjectInitialPromptWithoutRunnerIsNoOp covers the outer wrapper: with
// no standalone backend there is no interactive runner, and the goroutine
// startPassthroughSession spawns must exit without touching the execution.
func TestAutoInjectInitialPromptWithoutRunnerIsNoOp(t *testing.T) {
mgr := newTestManager(t)
execution := newAutoInjectExecution("Ship it")
execution.Status = v1.AgentStatusReady
require.NoError(t, mgr.executionStore.Add(execution))
mgr.autoInjectInitialPrompt(execution, agents.PassthroughConfig{SubmitSequence: "\r"})
require.Equal(t, v1.AgentStatusReady, execution.Status,
"without a runner the execution must not be flipped to RUNNING")
}
// statusObservingRunner records the execution status observed at the moment of
// the first stdin write, which is what the mark-running-first ordering is about.
type statusObservingRunner struct {
onWrite func()
writes []string
}
func (r *statusObservingRunner) WaitForFirstIdle(context.Context, string) error { return nil }
func (r *statusObservingRunner) WriteStdin(_ string, data string) error {
if r.onWrite != nil {
r.onWrite()
}
r.writes = append(r.writes, data)
return nil
}
@@ -0,0 +1,369 @@
package lifecycle
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/task/models"
)
// capabilityExecutor reports fixed executor capabilities so the manager's
// pass-through accessors can be checked against a known backend.
type capabilityExecutor struct {
MockExecutor
requiresClone bool
preferShell bool
}
func (e *capabilityExecutor) RequiresCloneURL() bool { return e.requiresClone }
func (e *capabilityExecutor) ShouldApplyPreferredShell() bool { return e.preferShell }
func TestManagerCapabilitiesDelegateToBackend(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&capabilityExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
requiresClone: true,
preferShell: false,
})
registry.Register(&capabilityExecutor{
MockExecutor: MockExecutor{name: executor.NameStandalone},
requiresClone: false,
preferShell: true,
})
mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
require.True(t, mgr.RequiresCloneURL(string(models.ExecutorTypeSprites)),
"a clone-based executor owns its own filesystem and needs a clone URL")
require.False(t, mgr.ShouldApplyPreferredShell(string(models.ExecutorTypeSprites)))
require.False(t, mgr.RequiresCloneURL(string(models.ExecutorTypeLocal)))
require.True(t, mgr.ShouldApplyPreferredShell(string(models.ExecutorTypeLocal)),
"a host-local executor inherits the user's preferred shell")
}
func TestManagerCapabilitiesFailClosedForUnknownExecutor(t *testing.T) {
mgr := newTestManager(t)
require.False(t, mgr.RequiresCloneURL("not-an-executor"))
require.False(t, mgr.ShouldApplyPreferredShell("not-an-executor"))
}
func TestResolveAgentProfileRequiresResolver(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = nil
_, err := mgr.ResolveAgentProfile(context.Background(), "profile-1")
require.ErrorContains(t, err, "profile resolver not configured")
}
func TestResolveAgentProfileDelegatesToResolver(t *testing.T) {
mgr := newTestManager(t)
info, err := mgr.ResolveAgentProfile(context.Background(), "profile-1")
require.NoError(t, err)
require.Equal(t, "profile-1", info.ProfileID)
require.Equal(t, "auggie", info.AgentName)
}
func TestRuntimeNameOfNilBackend(t *testing.T) {
require.Equal(t, executor.NameUnknown, runtimeName(nil))
require.Equal(t, executor.NameDocker, runtimeName(&MockExecutor{name: executor.NameDocker}))
}
func TestResolveProfileSessionConfigAndPolicyReadsProfileOnce(t *testing.T) {
resolver := &countingProfileResolver{info: &AgentProfileInfo{
ProfileID: "profile-1",
Model: "claude-opus-5",
Mode: "plan",
ConfigOptions: map[string]string{"reasoning": "high"},
FallbackModel: "claude-sonnet-5",
AutoFallback: true,
}}
mgr := newTestManager(t)
mgr.profileResolver = resolver
model, mode, options, policy := mgr.resolveProfileSessionConfigAndPolicy(context.Background(), "profile-1")
require.Equal(t, "claude-opus-5", model)
require.Equal(t, "plan", mode)
require.Equal(t, map[string]string{"reasoning": "high"}, options)
require.Equal(t, "claude-opus-5", policy.Model)
require.Equal(t, "claude-sonnet-5", policy.FallbackModel)
require.True(t, policy.AutoFallback)
require.Equal(t, int32(1), resolver.calls.Load(),
"session start needs config and policy together; a second read would be a wasted DB hit")
}
func TestResolveProfileSessionConfigAndPolicyDegradesGracefully(t *testing.T) {
ctx := context.Background()
t.Run("no profile id", func(t *testing.T) {
mgr := newTestManager(t)
model, mode, options, policy := mgr.resolveProfileSessionConfigAndPolicy(ctx, "")
require.Empty(t, model)
require.Empty(t, mode)
require.Nil(t, options)
require.Equal(t, StartModelPolicy{}, policy)
})
t.Run("resolver error", func(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = &countingProfileResolver{err: errors.New("profile deleted")}
model, _, _, policy := mgr.resolveProfileSessionConfigAndPolicy(ctx, "profile-1")
require.Empty(t, model)
require.Equal(t, StartModelPolicy{}, policy,
"a missing profile means no start model is configured, not a hard failure")
})
t.Run("no resolver wired", func(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = nil
_, _, _, policy := mgr.resolveProfileSessionConfigAndPolicy(ctx, "profile-1")
require.Equal(t, StartModelPolicy{}, policy)
})
}
func TestApplyExecutorMcpPolicyOverridesDefault(t *testing.T) {
mgr := newTestManager(t)
base := mcpconfig.DefaultPolicyForRuntime(executor.NameUnknown)
require.False(t, base.AllowStdio, "the unknown-runtime default denies stdio")
policy, executorID, err := mgr.applyExecutorMcpPolicy("profile-1", "",
map[string]interface{}{
"executor_id": "executor-7",
"executor_mcp_policy": `{"allow_stdio":true}`,
}, base)
require.NoError(t, err)
require.Equal(t, "executor-7", executorID,
"the executor identity is read from metadata so warnings name the right executor")
require.True(t, policy.AllowStdio)
}
func TestApplyExecutorMcpPolicyPassesThroughWithoutOverride(t *testing.T) {
mgr := newTestManager(t)
base := mcpconfig.DefaultPolicyForRuntime(executor.NameDocker)
policy, executorID, err := mgr.applyExecutorMcpPolicy("profile-1", "executor-1", nil, base)
require.NoError(t, err)
require.Equal(t, base, policy)
require.Equal(t, "executor-1", executorID)
policy, executorID, err = mgr.applyExecutorMcpPolicy("profile-1", "executor-1",
map[string]interface{}{"unrelated": "value"}, base)
require.NoError(t, err)
require.Equal(t, base, policy)
require.Equal(t, "executor-1", executorID)
}
func TestApplyExecutorMcpPolicyRejectsMalformedOverride(t *testing.T) {
mgr := newTestManager(t)
base := mcpconfig.DefaultPolicyForRuntime(executor.NameDocker)
_, _, err := mgr.applyExecutorMcpPolicy("profile-1", "", map[string]interface{}{
"executor_mcp_policy": "{not json",
}, base)
require.ErrorContains(t, err, "invalid executor MCP policy")
}
func TestResolveMcpServersWithParamsShortCircuits(t *testing.T) {
ctx := context.Background()
agentConfig, ok := newTestRegistry().Get("claude-acp")
require.True(t, ok)
t.Run("no provider", func(t *testing.T) {
mgr := newTestManager(t)
servers, err := mgr.resolveMcpServersWithParams(ctx, "profile-1", nil, agentConfig)
require.NoError(t, err)
require.Nil(t, servers)
})
t.Run("no agent config", func(t *testing.T) {
mgr := newTestManager(t)
mgr.mcpProvider = &fakeMcpConfigProvider{}
servers, err := mgr.resolveMcpServersWithParams(ctx, "profile-1", nil, nil)
require.NoError(t, err)
require.Nil(t, servers)
})
t.Run("blank profile id", func(t *testing.T) {
mgr := newTestManager(t)
mgr.mcpProvider = &fakeMcpConfigProvider{}
servers, err := mgr.resolveMcpServersWithParams(ctx, " ", nil, agentConfig)
require.NoError(t, err)
require.Nil(t, servers)
})
t.Run("disabled config", func(t *testing.T) {
mgr := newTestManager(t)
mgr.mcpProvider = &fakeMcpConfigProvider{config: &mcpconfig.ProfileConfig{ProfileID: "profile-1"}}
servers, err := mgr.resolveMcpServersWithParams(ctx, "profile-1", nil, agentConfig)
require.NoError(t, err)
require.Nil(t, servers, "a profile with MCP disabled contributes no servers")
})
}
func TestResolveMcpServersFromExecutionUsesExecutionMetadata(t *testing.T) {
mgr := newTestManager(t)
mgr.mcpProvider = &fakeMcpConfigProvider{config: &mcpconfig.ProfileConfig{
ProfileID: "profile-1",
Enabled: true,
Servers: map[string]mcpconfig.ServerDef{
"github": {Type: mcpconfig.ServerTypeStdio, Command: "npx", Args: []string{"-y", "gh"}},
},
}}
agentConfig, ok := newTestRegistry().Get("claude-acp")
require.True(t, ok)
execution := &AgentExecution{ID: "exec-1", AgentProfileID: "profile-1"}
execution.setMetadataValue("executor_mcp_policy", `{"allow_stdio":true}`)
servers, err := mgr.resolveMcpServers(context.Background(), execution, agentConfig)
require.NoError(t, err)
require.Len(t, servers, 1)
require.Equal(t, "github", servers[0].Name)
require.Equal(t, "npx", servers[0].Command,
"the executor policy on the execution's own metadata is what unlocks stdio transports")
nilServers, err := mgr.resolveMcpServers(context.Background(), nil, agentConfig)
require.NoError(t, err)
require.Nil(t, nilServers)
}
// TestPushBaseBranchesForTaskTargetsOnlyThatTask pins the fan-out: a base
// branch change must reach every live execution of the task and no other.
func TestPushBaseBranchesForTaskTargetsOnlyThatTask(t *testing.T) {
var mu sync.Mutex
var pushes []map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/workspace/base-branches" {
w.WriteHeader(http.StatusNotFound)
return
}
var body struct {
BaseBranches map[string]string `json:"base_branches"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
// t.Errorf, not require: this runs on the server's goroutine and
// require's FailNow/Goexit is only valid on the test goroutine.
t.Errorf("decode base-branches request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
mu.Lock()
pushes = append(pushes, body.BaseBranches)
mu.Unlock()
_, _ = w.Write([]byte(`{"success":true}`))
}))
t.Cleanup(server.Close)
client := newTestAgentctlClient(t, server.URL, newTestLogger())
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1", agentctl: client,
}))
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-2", TaskID: "task-1", SessionID: "session-2", agentctl: client,
}))
// Same-workspace execution on another task, plus one with no client.
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-3", TaskID: "task-other", SessionID: "session-3", agentctl: client,
}))
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-4", TaskID: "task-1", SessionID: "session-4",
}))
branches := map[string]string{"": "main", "frontend": "develop"}
mgr.PushBaseBranchesForTask(context.Background(), "task-1", branches)
mu.Lock()
defer mu.Unlock()
require.Len(t, pushes, 2, "only the two client-bearing executions of task-1 are pushed to")
for _, push := range pushes {
require.Equal(t, branches, push)
}
}
func TestPushBaseBranchesForTaskIgnoresEmptyTaskID(t *testing.T) {
mgr := newTestManager(t)
// No panic, no work: an empty task ID cannot identify executions.
mgr.PushBaseBranchesForTask(context.Background(), "", map[string]string{"": "main"})
}
// TestPushBaseBranchesForTaskIsBestEffort pins that one failing agentctl does
// not stop the push to the task's other executions.
func TestPushBaseBranchesForTaskIsBestEffort(t *testing.T) {
var mu sync.Mutex
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
calls++
mu.Unlock()
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
client := newTestAgentctlClient(t, server.URL, newTestLogger())
mgr := newTestManager(t)
for _, id := range []string{"session-1", "session-2"} {
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-" + id, TaskID: "task-1", SessionID: id, agentctl: client,
}))
}
mgr.PushBaseBranchesForTask(context.Background(), "task-1", map[string]string{"": "main"})
mu.Lock()
defer mu.Unlock()
require.Equal(t, 2, calls,
"a failed push must be logged and the loop continued; the DB remains the source of truth")
}
func TestGetRecoveredExecutionsProjectsOfficeIdentity(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1", ContainerID: "container-1",
AgentProfileID: "execution-profile", OfficeAgentProfileID: "office-profile",
}))
recovered := mgr.GetRecoveredExecutions()
require.Len(t, recovered, 1)
require.Equal(t, "exec-1", recovered[0].ExecutionID)
require.Equal(t, "task-1", recovered[0].TaskID)
require.Equal(t, "session-1", recovered[0].SessionID)
require.Equal(t, "container-1", recovered[0].ContainerID)
require.Equal(t, "office-profile", recovered[0].AgentProfileID,
"the orchestrator syncs on the stable Office identity")
require.Equal(t, "execution-profile", recovered[0].ExecutionProfileID)
}
func TestExecutionStoreGetByContainerID(t *testing.T) {
store := NewExecutionStore()
require.NoError(t, store.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", ContainerID: "container-1",
}))
got, ok := store.GetByContainerID("container-1")
require.True(t, ok)
require.Equal(t, "exec-1", got.ID)
_, ok = store.GetByContainerID("container-absent")
require.False(t, ok)
}
@@ -0,0 +1,338 @@
package lifecycle
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agentruntime"
)
// statusProviderExecutor is an ExecutorBackend that also implements
// RemoteStatusProvider, recording the instance the manager projected from the
// execution so the projection itself can be asserted.
type statusProviderExecutor struct {
MockExecutor
status *RemoteStatus
err error
observed []*ExecutorInstance
}
func (e *statusProviderExecutor) GetRemoteStatus(_ context.Context, instance *ExecutorInstance) (*RemoteStatus, error) {
e.observed = append(e.observed, instance)
return e.status, e.err
}
func newRemoteStatusManager(t *testing.T, provider ExecutorBackend) *Manager {
t.Helper()
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(provider)
mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
return mgr
}
// TestPollOneRemoteStatusProjectsExecutionIntoInstance pins the projection the
// provider is handed: a status poll must carry the runtime identifiers the
// backend needs to find the remote (container, sprite name in metadata), not
// just the session ID.
func TestPollOneRemoteStatusProjectsExecutionIntoInstance(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
status: &RemoteStatus{State: "started"},
}
mgr := newRemoteStatusManager(t, provider)
execution := &AgentExecution{
ID: "exec-1",
TaskID: "task-1",
SessionID: "session-1",
RuntimeName: agentruntime.RuntimeSprites,
ContainerID: "container-1",
ContainerIP: "10.0.0.2",
WorkspacePath: "/workspace",
}
execution.setMetadataValue(MetadataKeySpriteName, "kandev-abc")
mgr.pollOneRemoteStatus(context.Background(), execution)
require.Len(t, provider.observed, 1)
instance := provider.observed[0]
require.Equal(t, "exec-1", instance.InstanceID)
require.Equal(t, "task-1", instance.TaskID)
require.Equal(t, "session-1", instance.SessionID)
require.Equal(t, agentruntime.RuntimeSprites, instance.RuntimeName)
require.Equal(t, "container-1", instance.ContainerID)
require.Equal(t, "10.0.0.2", instance.ContainerIP)
require.Equal(t, "/workspace", instance.WorkspacePath)
require.Equal(t, "kandev-abc", instance.Metadata[MetadataKeySpriteName],
"the remote's own name lives in metadata; without it the provider cannot find the sandbox")
status, ok := mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok)
require.Equal(t, "started", status.State)
require.Equal(t, agentruntime.RuntimeSprites, status.RuntimeName,
"a provider that omits the runtime name gets the execution's filled in")
require.False(t, status.LastCheckedAt.IsZero(),
"an unstamped status is stamped so the UI can show staleness")
}
func TestPollOneRemoteStatusStoresProviderError(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
err: errors.New("sprite unreachable"),
}
mgr := newRemoteStatusManager(t, provider)
mgr.pollOneRemoteStatus(context.Background(), &AgentExecution{
ID: "exec-1", SessionID: "session-1", RuntimeName: agentruntime.RuntimeSprites,
})
status, ok := mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok, "a failed poll must still record a status so the UI can show degraded")
require.Equal(t, "sprite unreachable", status.ErrorMessage)
require.Equal(t, agentruntime.RuntimeSprites, status.RuntimeName)
}
func TestPollOneRemoteStatusSkipsUnsupportedExecutions(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
status: &RemoteStatus{State: "started"},
}
mgr := newRemoteStatusManager(t, provider)
ctx := context.Background()
mgr.pollOneRemoteStatus(ctx, nil)
mgr.pollOneRemoteStatus(ctx, &AgentExecution{ID: "exec-1"}) // no session
mgr.pollOneRemoteStatus(ctx, &AgentExecution{
ID: "exec-2", SessionID: "session-2", RuntimeName: agentruntime.RuntimeDocker,
}) // runtime not registered
require.Empty(t, provider.observed)
_, ok := mgr.GetRemoteStatusBySession("session-2")
require.False(t, ok)
}
func TestPollOneRemoteStatusSkipsBackendWithoutStatusSupport(t *testing.T) {
mgr := newRemoteStatusManager(t, &MockExecutor{name: executor.NameStandalone})
mgr.pollOneRemoteStatus(context.Background(), &AgentExecution{
ID: "exec-1", SessionID: "session-1", RuntimeName: agentruntime.RuntimeStandalone,
})
_, ok := mgr.GetRemoteStatusBySession("session-1")
require.False(t, ok, "a local executor has no remote status to report")
}
func TestPollOneRemoteStatusIgnoresNilProviderResult(t *testing.T) {
provider := &statusProviderExecutor{MockExecutor: MockExecutor{name: executor.NameSprites}}
mgr := newRemoteStatusManager(t, provider)
mgr.pollOneRemoteStatus(context.Background(), &AgentExecution{
ID: "exec-1", SessionID: "session-1", RuntimeName: agentruntime.RuntimeSprites,
})
_, ok := mgr.GetRemoteStatusBySession("session-1")
require.False(t, ok, "a provider returning (nil, nil) must not create an empty cache entry")
}
func TestPollRemoteStatusesCoversEveryTrackedExecution(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
status: &RemoteStatus{State: "started"},
}
mgr := newRemoteStatusManager(t, provider)
for _, id := range []string{"session-1", "session-2"} {
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-" + id, SessionID: id, RuntimeName: agentruntime.RuntimeSprites,
}))
}
mgr.pollRemoteStatuses(context.Background())
require.Len(t, provider.observed, 2)
for _, id := range []string{"session-1", "session-2"} {
_, ok := mgr.GetRemoteStatusBySession(id)
require.True(t, ok, "session %s was not polled", id)
}
}
func TestGetRemoteStatusBySessionIDRefreshesTrackedExecution(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
status: &RemoteStatus{State: "started", LastCheckedAt: time.Now().UTC()},
}
mgr := newRemoteStatusManager(t, provider)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", RuntimeName: agentruntime.RuntimeSprites,
}))
status, ok := mgr.GetRemoteStatusBySessionID(context.Background(), "session-1")
require.True(t, ok)
require.Equal(t, "started", status.State)
require.Len(t, provider.observed, 1, "a tracked session is refreshed opportunistically on read")
_, ok = mgr.GetRemoteStatusBySessionID(context.Background(), "session-absent")
require.False(t, ok)
require.Len(t, provider.observed, 1, "an untracked session must not trigger a poll")
}
func TestGetRemoteStatusBySessionReturnsDefensiveCopy(t *testing.T) {
mgr := newRemoteStatusManager(t, &MockExecutor{name: executor.NameStandalone})
mgr.storeRemoteStatus("session-1", &RemoteStatus{State: "started"})
first, ok := mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok)
first.State = "mutated-by-caller"
second, ok := mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok)
require.Equal(t, "started", second.State, "the cache must hand out copies, not its own entry")
}
func TestStoreAndClearRemoteStatusIgnoreEmptyInputs(t *testing.T) {
mgr := newRemoteStatusManager(t, &MockExecutor{name: executor.NameStandalone})
mgr.storeRemoteStatus("", &RemoteStatus{State: "started"})
mgr.storeRemoteStatus("session-1", nil)
_, ok := mgr.GetRemoteStatusBySession("session-1")
require.False(t, ok)
mgr.storeRemoteStatus("session-1", &RemoteStatus{State: "started"})
mgr.clearRemoteStatus("")
_, ok = mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok, "clearing an empty session ID must not wipe unrelated entries")
mgr.clearRemoteStatus("session-1")
_, ok = mgr.GetRemoteStatusBySession("session-1")
require.False(t, ok)
}
// TestPollRemoteStatusForRecordsPopulatesCacheBeforeResume pins the startup
// path: sessions that have no in-memory execution yet still get their remote
// status cached from the persisted executor records.
func TestPollRemoteStatusForRecordsPopulatesCacheBeforeResume(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
status: &RemoteStatus{State: "suspended"},
}
mgr := newRemoteStatusManager(t, provider)
mgr.PollRemoteStatusForRecords(context.Background(), []RemoteStatusPollRecord{
{SessionID: "", Runtime: agentruntime.RuntimeSprites}, // skipped
{SessionID: "session-no-runtime"}, // skipped
{SessionID: "session-unregistered", Runtime: agentruntime.RuntimeSSH}, // skipped
{
SessionID: "session-1",
Runtime: agentruntime.RuntimeSprites,
AgentExecutionID: "exec-1",
ContainerID: "container-1",
Metadata: map[string]interface{}{MetadataKeySpriteName: "kandev-abc"},
},
})
require.Len(t, provider.observed, 1, "only the complete, registered record is polled")
require.Equal(t, "exec-1", provider.observed[0].InstanceID)
require.Equal(t, "container-1", provider.observed[0].ContainerID)
require.Equal(t, "kandev-abc", provider.observed[0].Metadata[MetadataKeySpriteName])
status, ok := mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok)
require.Equal(t, "suspended", status.State)
require.Equal(t, agentruntime.RuntimeSprites, status.RuntimeName)
require.False(t, status.LastCheckedAt.IsZero())
}
func TestPollRemoteStatusForRecordsStoresProviderError(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
err: errors.New("api token expired"),
}
mgr := newRemoteStatusManager(t, provider)
mgr.PollRemoteStatusForRecords(context.Background(), []RemoteStatusPollRecord{
{SessionID: "session-1", Runtime: agentruntime.RuntimeSprites},
})
status, ok := mgr.GetRemoteStatusBySession("session-1")
require.True(t, ok)
require.Equal(t, "api token expired", status.ErrorMessage)
}
func TestPollRemoteStatusForRecordsSkipsNilProviderResultAndNoRegistry(t *testing.T) {
provider := &statusProviderExecutor{MockExecutor: MockExecutor{name: executor.NameSprites}}
mgr := newRemoteStatusManager(t, provider)
mgr.PollRemoteStatusForRecords(context.Background(), []RemoteStatusPollRecord{
{SessionID: "session-1", Runtime: agentruntime.RuntimeSprites},
})
_, ok := mgr.GetRemoteStatusBySession("session-1")
require.False(t, ok)
bare := newTestManager(t)
bare.PollRemoteStatusForRecords(context.Background(), []RemoteStatusPollRecord{
{SessionID: "session-1", Runtime: agentruntime.RuntimeSprites},
})
_, ok = bare.GetRemoteStatusBySession("session-1")
require.False(t, ok, "without an executor registry there is nothing to poll")
}
// TestStopAgentClearsRemoteStatus pins that a stopped session does not keep
// reporting a stale cloud status in the UI.
func TestStopAgentClearsRemoteStatus(t *testing.T) {
provider := &statusProviderExecutor{
MockExecutor: MockExecutor{name: executor.NameSprites},
status: &RemoteStatus{State: "started"},
}
mgr := newRemoteStatusManager(t, provider)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{
ID: "exec-1", SessionID: "session-1", RuntimeName: agentruntime.RuntimeSprites,
}))
mgr.storeRemoteStatus("session-1", &RemoteStatus{State: "started"})
require.NoError(t, mgr.StopAgent(context.Background(), "exec-1", false))
_, ok := mgr.GetRemoteStatusBySession("session-1")
require.False(t, ok)
}
func TestManagerEnvironmentRequiresTypedDockerBackend(t *testing.T) {
ctx := context.Background()
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{name: executor.NameDocker})
registry.Register(&MockExecutor{name: executor.NameSprites})
mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil, ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
require.NoError(t, mgr.DestroyContainer(ctx, ""), "an empty container ID is a no-op")
require.ErrorContains(t, mgr.DestroyContainer(ctx, "container-1"), "docker backend has unexpected type")
status, err := mgr.GetContainerLiveStatus(ctx, "")
require.NoError(t, err)
require.Nil(t, status)
_, err = mgr.GetContainerLiveStatus(ctx, "container-1")
require.ErrorContains(t, err, "docker backend has unexpected type")
require.NoError(t, mgr.DestroySandbox(ctx, "", "exec-1"), "an empty sandbox ID is a no-op")
require.ErrorContains(t, mgr.DestroySandbox(ctx, "sandbox-1", "exec-1"),
"sprites backend has unexpected type")
}
func TestManagerEnvironmentReportsMissingBackends(t *testing.T) {
ctx := context.Background()
log := newTestRegistryLogger()
mgr := NewManager(newTestRegistry(), &MockEventBus{}, NewExecutorRegistry(log), nil, nil, nil,
ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
require.ErrorContains(t, mgr.DestroyContainer(ctx, "container-1"), "docker backend unavailable")
_, err := mgr.GetContainerLiveStatus(ctx, "container-1")
require.ErrorContains(t, err, "docker backend unavailable")
require.ErrorContains(t, mgr.DestroySandbox(ctx, "sandbox-1", "exec-1"), "sprites backend unavailable")
}
@@ -0,0 +1,156 @@
package lifecycle
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/executor"
)
type recordedRescan struct {
WorkDir string `json:"work_dir"`
SourceRoots []string `json:"workspace_source_roots"`
}
// newRescanServer serves a fixed status for the lifetime of the server. The
// status is passed by value rather than by pointer so the handler goroutine
// cannot race a mid-run write: these tests each want one status throughout.
// (fakeAgentctlProcessServer uses atomic.Int32 because it genuinely does flip
// its status mid-test; matching the shape to the requirement keeps the
// difference between the two helpers visible.)
func newRescanServer(t *testing.T, status int) (*[]recordedRescan, string) {
t.Helper()
var mu sync.Mutex
recorded := &[]recordedRescan{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body recordedRescan
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
*recorded = append(*recorded, body)
mu.Unlock()
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"success":true}`))
}))
t.Cleanup(server.Close)
return recorded, server.URL
}
// TestRescanWorkspaceForSessionForwardsWorkDirAndRoots pins what reaches
// agentctl and, critically, that the execution's recorded source roots are
// updated to match — a rescan that succeeded but left the old roots on the
// execution would send stale roots on the next call.
func TestRescanWorkspaceForSessionForwardsWorkDirAndRoots(t *testing.T) {
recorded, url := newRescanServer(t, http.StatusOK)
mgr := newTestManager(t)
execution := &AgentExecution{
ID: "exec-1", SessionID: "session-1",
WorkspaceSourceRoots: []string{"/work/task-1/backend"},
agentctl: newTestAgentctlClient(t, url, newTestLogger()),
}
require.NoError(t, mgr.executionStore.Add(execution))
newRoots := []string{"/work/task-1/backend", "/work/task-1/frontend"}
require.NoError(t, mgr.RescanWorkspaceForSession(
context.Background(), "session-1", "/work/task-1", newRoots))
require.Len(t, *recorded, 1)
require.Equal(t, "/work/task-1", (*recorded)[0].WorkDir)
require.Equal(t, newRoots, (*recorded)[0].SourceRoots)
require.Equal(t, newRoots, execution.WorkspaceSourceRoots,
"a successful rescan commits the new roots to the execution")
}
// TestRescanWorkspaceForSessionRollsBackRootsOnFailure pins the rollback: a
// failed rescan must not leave the execution claiming roots agentctl never
// accepted.
func TestRescanWorkspaceForSessionRollsBackRootsOnFailure(t *testing.T) {
_, url := newRescanServer(t, http.StatusInternalServerError)
mgr := newTestManager(t)
original := []string{"/work/task-1/backend"}
execution := &AgentExecution{
ID: "exec-1", SessionID: "session-1",
WorkspaceSourceRoots: original,
agentctl: newTestAgentctlClient(t, url, newTestLogger()),
}
require.NoError(t, mgr.executionStore.Add(execution))
err := mgr.RescanWorkspaceForSession(context.Background(), "session-1", "/work/task-1",
[]string{"/work/task-1/backend", "/work/task-1/frontend"})
require.ErrorContains(t, err, "rescan workspace via agentctl")
require.Equal(t, original, execution.WorkspaceSourceRoots,
"a failed rescan must restore the roots agentctl is actually tracking")
}
func TestRescanWorkspaceForSessionSkipsWithoutExecutionOrClient(t *testing.T) {
ctx := context.Background()
mgr := newTestManager(t)
require.ErrorContains(t, mgr.RescanWorkspaceForSession(ctx, "", ""), "session_id is required")
require.NoError(t, mgr.RescanWorkspaceForSession(ctx, "session-absent", ""),
"no execution means agentctl is not running; the next launch rebuilds trackers")
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-1", SessionID: "session-1"}))
require.NoError(t, mgr.RescanWorkspaceForSession(ctx, "session-1", ""),
"an execution without an agentctl client has nothing to rescan")
}
func TestOptionalWorkspaceSourceRoots(t *testing.T) {
current := []string{"/work/backend"}
unchanged := optionalWorkspaceSourceRoots(current, nil)
require.Equal(t, current, unchanged)
require.NotSame(t, &current[0], &unchanged[0],
"the caller must get a copy so a later mutation cannot reach the execution")
replaced := optionalWorkspaceSourceRoots(current, [][]string{{"/work/frontend"}})
require.Equal(t, []string{"/work/frontend"}, replaced)
cleared := optionalWorkspaceSourceRoots(current, [][]string{nil})
require.Empty(t, cleared, "an explicit empty root list clears the tracked roots")
}
// TestManagerStartRecoversInstancesIntoStore pins the restart path: instances
// a backend reports as still running are re-tracked so the frontend reattaches
// instead of creating a duplicate execution.
func TestManagerStartRecoversInstancesIntoStore(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{
name: executor.NameStandalone,
recoverInstances: []*ExecutorInstance{{
InstanceID: "exec-recovered",
TaskID: "task-1",
SessionID: "session-1",
ContainerID: "container-1",
ContainerIP: "10.0.0.2",
WorkspacePath: "/workspace",
RuntimeName: executor.NameStandalone,
}},
})
mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, nil, nil, nil,
ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, mgr)
t.Cleanup(func() { _ = mgr.Stop() })
require.NoError(t, mgr.Start(context.Background()))
execution, ok := mgr.GetExecutionBySessionID("session-1")
require.True(t, ok, "a recovered instance must be re-tracked under its session")
require.Equal(t, "exec-recovered", execution.ID)
require.Equal(t, "container-1", execution.ContainerID)
require.Equal(t, "/workspace", execution.WorkspacePath)
}
func TestManagerStartWithoutRegistryIsNoOp(t *testing.T) {
mgr := newTestManager(t)
require.NoError(t, mgr.Start(context.Background()))
require.Empty(t, mgr.ListExecutions())
}
@@ -0,0 +1,317 @@
package lifecycle
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
)
// fakeAgentctlProcessServer serves the agentctl process endpoints the lifecycle
// manager drives, recording every request so the arguments the manager sent can
// be asserted rather than merely that the call returned nil.
type fakeAgentctlProcessServer struct {
mu sync.Mutex
startRequests []agentctl.StartProcessRequest
stopIDs []string
listSessions []string
fetchedIDs []string
startStatus agentctl.ProcessStatus
knownProcess string
sessionProcs []agentctl.ProcessInfo
stopShouldErr bool
healthy bool
}
func newFakeAgentctlProcessServer(t *testing.T) (*fakeAgentctlProcessServer, *agentctl.Client) {
t.Helper()
f := &fakeAgentctlProcessServer{startStatus: "running", healthy: true}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.URL.Path == "/health":
if !f.healthy {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/api/v1/processes/start":
var req agentctl.StartProcessRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// t.Errorf, not require: this runs on the server's goroutine and
// require's FailNow/Goexit is only valid on the test goroutine.
t.Errorf("decode start process request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
f.startRequests = append(f.startRequests, req)
_ = json.NewEncoder(w).Encode(map[string]any{
"process": agentctl.ProcessInfo{
ID: "proc-1", SessionID: req.SessionID, Command: req.Command, Status: f.startStatus,
},
})
case r.URL.Path == "/api/v1/processes/stop":
var req struct {
ProcessID string `json:"process_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode stop process request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
f.stopIDs = append(f.stopIDs, req.ProcessID)
if f.stopShouldErr {
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "already gone"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
case r.URL.Path == "/api/v1/processes":
f.listSessions = append(f.listSessions, r.URL.Query().Get("session_id"))
_ = json.NewEncoder(w).Encode(f.sessionProcs)
case strings.HasPrefix(r.URL.Path, "/api/v1/processes/"):
id := strings.TrimPrefix(r.URL.Path, "/api/v1/processes/")
f.fetchedIDs = append(f.fetchedIDs, id)
if f.knownProcess == "" || id != f.knownProcess {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(agentctl.ProcessInfo{ID: id, Status: "running"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
return f, newTestAgentctlClient(t, server.URL, newTestLogger())
}
func (f *fakeAgentctlProcessServer) snapshotStarts() []agentctl.StartProcessRequest {
f.mu.Lock()
defer f.mu.Unlock()
return append([]agentctl.StartProcessRequest(nil), f.startRequests...)
}
func (f *fakeAgentctlProcessServer) snapshotStops() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.stopIDs...)
}
func newProcessRunnerManager(t *testing.T, client *agentctl.Client) (*Manager, *AgentExecution) {
t.Helper()
mgr := newTestManager(t)
execution := &AgentExecution{
ID: "exec-1", TaskID: "task-1", SessionID: "session-1", agentctl: client,
}
require.NoError(t, mgr.executionStore.Add(execution))
return mgr, execution
}
// TestStartProcessForwardsRequestAndManagedGoCache pins what actually reaches
// agentctl, including the managed GOCACHE overlay that the execution carries in
// metadata rather than in the caller's request.
func TestStartProcessForwardsRequestAndManagedGoCache(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
mgr, execution := newProcessRunnerManager(t, client)
execution.setMetadataValue(managedGoCacheMetadataKey, "/cache/go-build")
info, err := mgr.StartProcess(context.Background(), StartProcessRequest{
SessionID: "session-1",
Kind: "script",
ScriptName: "setup",
Command: "make setup",
WorkingDir: "/workspace",
Env: map[string]string{"CI": "1"},
})
require.NoError(t, err)
require.Equal(t, "proc-1", info.ID)
starts := fake.snapshotStarts()
require.Len(t, starts, 1)
require.Equal(t, "session-1", starts[0].SessionID)
require.Equal(t, agentctl.ProcessKind("script"), starts[0].Kind)
require.Equal(t, "setup", starts[0].ScriptName)
require.Equal(t, "make setup", starts[0].Command)
require.Equal(t, "/workspace", starts[0].WorkingDir)
require.Equal(t, "1", starts[0].Env["CI"], "the caller's env must not be dropped")
require.Equal(t, "/cache/go-build", starts[0].Env["GOCACHE"],
"the managed cache is an install-wide setting the caller never sends")
}
func TestProcessEnvironmentLeavesRequestUntouchedWithoutManagedCache(t *testing.T) {
execution := &AgentExecution{ID: "exec-1"}
requested := map[string]string{"CI": "1"}
require.Equal(t, requested, processEnvironment(execution, requested))
execution.setMetadataValue(managedGoCacheMetadataKey, "/cache/go-build")
merged := processEnvironment(execution, requested)
require.Equal(t, "/cache/go-build", merged["GOCACHE"])
require.NotContains(t, requested, "GOCACHE",
"the caller's map must not be mutated in place")
}
func TestProcessRunnerEntryPointsValidateArguments(t *testing.T) {
ctx := context.Background()
mgr := newTestManager(t)
_, err := mgr.StartProcess(ctx, StartProcessRequest{})
require.ErrorContains(t, err, "session_id is required")
_, err = mgr.StartProcess(ctx, StartProcessRequest{SessionID: "session-absent"})
require.ErrorIs(t, err, ErrNoExecutionForSession)
require.ErrorContains(t, mgr.WaitForAgentctlReadyForSession(ctx, ""), "session_id is required")
require.ErrorIs(t, mgr.WaitForAgentctlReadyForSession(ctx, "session-absent"), ErrNoExecutionForSession)
require.ErrorContains(t, mgr.StopProcessForSession(ctx, "", "proc-1"), "session_id is required")
require.ErrorContains(t, mgr.StopProcessForSession(ctx, "session-1", ""), "process_id is required")
require.ErrorIs(t, mgr.StopProcessForSession(ctx, "session-absent", "proc-1"), ErrNoExecutionForSession)
_, err = mgr.ListProcesses(ctx, "")
require.ErrorContains(t, err, "session_id is required")
_, err = mgr.ListProcesses(ctx, "session-absent")
require.ErrorIs(t, err, ErrNoExecutionForSession)
_, err = mgr.GetProcess(ctx, "", false)
require.ErrorContains(t, err, "process_id is required")
require.ErrorContains(t, mgr.StopProcess(ctx, ""), "process_id is required")
}
func TestProcessRunnerEntryPointsRequireAgentctlClient(t *testing.T) {
ctx := context.Background()
mgr := newTestManager(t)
require.NoError(t, mgr.executionStore.Add(&AgentExecution{ID: "exec-1", SessionID: "session-1"}))
_, err := mgr.StartProcess(ctx, StartProcessRequest{SessionID: "session-1"})
require.ErrorContains(t, err, "agentctl client not available for session session-1")
require.ErrorContains(t, mgr.WaitForAgentctlReadyForSession(ctx, "session-1"),
"agentctl client not available")
require.ErrorContains(t, mgr.StopProcessForSession(ctx, "session-1", "proc-1"),
"agentctl client not available")
_, err = mgr.ListProcesses(ctx, "session-1")
require.ErrorContains(t, err, "agentctl client not available")
// Executions without a client are skipped by the cross-execution scans.
_, err = mgr.GetProcess(ctx, "proc-1", false)
require.ErrorContains(t, err, "process not found: proc-1")
require.ErrorContains(t, mgr.StopProcess(ctx, "proc-1"), "process not found: proc-1")
require.NoError(t, mgr.StopAllProcesses(ctx))
}
func TestWaitForAgentctlReadyForSessionProbesHealth(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
mgr, _ := newProcessRunnerManager(t, client)
require.NoError(t, mgr.WaitForAgentctlReadyForSession(context.Background(), "session-1"))
fake.mu.Lock()
fake.healthy = false
fake.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.Error(t, mgr.WaitForAgentctlReadyForSession(ctx, "session-1"))
}
func TestStopProcessForSessionForwardsProcessID(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
mgr, _ := newProcessRunnerManager(t, client)
require.NoError(t, mgr.StopProcessForSession(context.Background(), "session-1", "proc-7"))
require.Equal(t, []string{"proc-7"}, fake.snapshotStops())
}
func TestStopProcessForSessionSurfacesAgentctlFailure(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
fake.stopShouldErr = true
mgr, _ := newProcessRunnerManager(t, client)
err := mgr.StopProcessForSession(context.Background(), "session-1", "proc-7")
require.ErrorContains(t, err, "already gone")
}
// TestStopProcessScansExecutionsForOwner pins the cross-execution scan: the
// caller supplies only a process ID, so the manager must locate the owning
// execution before issuing the stop.
func TestStopProcessScansExecutionsForOwner(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
fake.knownProcess = "proc-7"
mgr, _ := newProcessRunnerManager(t, client)
require.NoError(t, mgr.StopProcess(context.Background(), "proc-7"))
require.Equal(t, []string{"proc-7"}, fake.snapshotStops())
require.ErrorContains(t, mgr.StopProcess(context.Background(), "proc-unknown"),
"process not found: proc-unknown")
require.Equal(t, []string{"proc-7"}, fake.snapshotStops(),
"an unowned process ID must not produce a stop call")
}
func TestGetProcessScansExecutions(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
fake.knownProcess = "proc-7"
mgr, _ := newProcessRunnerManager(t, client)
got, err := mgr.GetProcess(context.Background(), "proc-7", true)
require.NoError(t, err)
require.Equal(t, "proc-7", got.ID)
_, err = mgr.GetProcess(context.Background(), "proc-unknown", false)
require.ErrorContains(t, err, "process not found: proc-unknown")
}
func TestListProcessesScopesToSession(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
fake.sessionProcs = []agentctl.ProcessInfo{{ID: "proc-1"}, {ID: "proc-2"}}
mgr, _ := newProcessRunnerManager(t, client)
got, err := mgr.ListProcesses(context.Background(), "session-1")
require.NoError(t, err)
require.Len(t, got, 2)
fake.mu.Lock()
defer fake.mu.Unlock()
require.Equal(t, []string{"session-1"}, fake.listSessions,
"the list must be scoped to the session, not the whole agentctl instance")
}
// TestStopAllProcessesStopsEveryListedProcess pins the shutdown sweep: every
// process agentctl reports for the session is stopped, and per-process failures
// are aggregated rather than aborting the sweep.
func TestStopAllProcessesStopsEveryListedProcess(t *testing.T) {
fake, client := newFakeAgentctlProcessServer(t)
fake.sessionProcs = []agentctl.ProcessInfo{{ID: "proc-1"}, {ID: "proc-2"}}
mgr, _ := newProcessRunnerManager(t, client)
require.NoError(t, mgr.StopAllProcesses(context.Background()))
require.Equal(t, []string{"proc-1", "proc-2"}, fake.snapshotStops())
fake.mu.Lock()
fake.stopShouldErr = true
fake.mu.Unlock()
err := mgr.StopAllProcesses(context.Background())
require.ErrorContains(t, err, "already gone")
require.Len(t, fake.snapshotStops(), 4,
"a failing stop must not abort the sweep over the remaining processes")
}
func TestIsTerminalProcessStatus(t *testing.T) {
require.True(t, isTerminalProcessStatus("exited"))
require.True(t, isTerminalProcessStatus("failed"))
require.True(t, isTerminalProcessStatus("stopped"))
require.False(t, isTerminalProcessStatus("running"))
require.False(t, isTerminalProcessStatus(""))
}
@@ -0,0 +1,153 @@
package lifecycle
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newSessionHistoryManagerForTest(t *testing.T) (*SessionHistoryManager, string) {
t.Helper()
dir := t.TempDir()
mgr, err := NewSessionHistoryManager(dir, "", newTestLogger())
require.NoError(t, err)
return mgr, dir
}
// TestGenerateResumeContextRendersEveryEntryKind pins the fork-session prompt:
// each recorded entry kind gets its own labelled section, and the user's new
// request is appended after the history so the agent knows what to do next.
func TestGenerateResumeContextRendersEveryEntryKind(t *testing.T) {
mgr, _ := newSessionHistoryManagerForTest(t)
now := time.Now()
for _, entry := range []HistoryEntry{
{Timestamp: now, Type: "user_message", Content: "add retries"},
{Timestamp: now, Type: "agent_message", Content: "added them"},
{Timestamp: now, Type: historyEntryTypeToolCall, ToolName: "edit_file"},
{Timestamp: now, Type: "tool_result", ToolName: "edit_file", Content: "3 lines changed"},
{Timestamp: now, Type: "unrecognised_kind", Content: "should be ignored"},
} {
require.NoError(t, mgr.AppendEntry("session-1", entry))
}
prompt, err := mgr.GenerateResumeContext("session-1", "now add tests")
require.NoError(t, err)
require.Contains(t, prompt, "[USER]: add retries")
require.Contains(t, prompt, "[ASSISTANT]: added them")
require.Contains(t, prompt, "[TOOL CALL: edit_file]")
require.Contains(t, prompt, "[TOOL RESULT: edit_file] 3 lines changed")
require.NotContains(t, prompt, "should be ignored",
"an unknown entry kind must be skipped rather than rendered raw")
require.Contains(t, prompt, "=== CURRENT REQUEST ===\nnow add tests")
require.Contains(t, prompt, "Do not repeat work that was already completed.")
require.Less(t, strings.Index(prompt, "add retries"), strings.Index(prompt, "now add tests"),
"history must precede the new request so the agent reads it as prior context")
}
func TestGenerateResumeContextReturnsPromptUnchangedWithoutHistory(t *testing.T) {
mgr, _ := newSessionHistoryManagerForTest(t)
prompt, err := mgr.GenerateResumeContext("session-fresh", "do the thing")
require.NoError(t, err)
require.Equal(t, "do the thing", prompt,
"a session with no history must not be given an empty RESUME CONTEXT wrapper")
}
// TestGenerateResumeContextIgnoresContentlessEntries pins the second early
// return: entries exist, but none render, so the raw prompt is used.
func TestGenerateResumeContextIgnoresContentlessEntries(t *testing.T) {
mgr, _ := newSessionHistoryManagerForTest(t)
require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{
Timestamp: time.Now(), Type: "session_started",
}))
prompt, err := mgr.GenerateResumeContext("session-1", "do the thing")
require.NoError(t, err)
require.Equal(t, "do the thing", prompt)
}
func TestGenerateResumeContextSurfacesReadFailure(t *testing.T) {
mgr, _ := newSessionHistoryManagerForTest(t)
prompt, err := mgr.GenerateResumeContext("", "do the thing")
require.ErrorContains(t, err, "session ID is required")
require.Equal(t, "do the thing", prompt,
"a failed read must still return the caller's prompt so the launch can proceed")
}
// TestGenerateResumeContextTruncatesLongEntries pins the size guard: an
// unbounded transcript would blow past the agent's context window.
func TestGenerateResumeContextTruncatesLongEntries(t *testing.T) {
mgr, _ := newSessionHistoryManagerForTest(t)
require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{
Timestamp: time.Now(), Type: "user_message", Content: strings.Repeat("a", 5000),
}))
require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{
Timestamp: time.Now(), Type: "tool_result", ToolName: "read_file",
Content: strings.Repeat("b", 5000),
}))
prompt, err := mgr.GenerateResumeContext("session-1", "continue")
require.NoError(t, err)
require.Equal(t, 2, strings.Count(prompt, "... [truncated]"))
require.NotContains(t, prompt, strings.Repeat("a", 2001),
"user messages are capped at 2000 characters")
require.NotContains(t, prompt, strings.Repeat("b", 501),
"tool results are capped at 500 characters — they are the bulkiest entries")
}
func TestTruncateForContext(t *testing.T) {
require.Equal(t, "short", truncateForContext("short", 10))
require.Equal(t, "exactly10c", truncateForContext("exactly10c", 10))
require.Equal(t, "0123456789... [truncated]", truncateForContext("0123456789abc", 10))
}
func TestHasHistoryReflectsStoredEntries(t *testing.T) {
mgr, dir := newSessionHistoryManagerForTest(t)
require.False(t, mgr.HasHistory(""), "an empty session ID has no history")
require.False(t, mgr.HasHistory("session-1"))
require.NoError(t, os.WriteFile(filepath.Join(dir, "session-empty.jsonl"), nil, 0o600))
require.False(t, mgr.HasHistory("session-empty"), "a zero-byte file is not history")
require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{
Timestamp: time.Now(), Type: "user_message", Content: "hi",
}))
require.True(t, mgr.HasHistory("session-1"))
}
func TestDeleteHistoryRemovesFileAndIsIdempotent(t *testing.T) {
mgr, dir := newSessionHistoryManagerForTest(t)
require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{
Timestamp: time.Now(), Type: "user_message", Content: "hi",
}))
path := filepath.Join(dir, "session-1.jsonl")
require.FileExists(t, path)
require.NoError(t, mgr.DeleteHistory("session-1"))
require.NoFileExists(t, path)
require.NoError(t, mgr.DeleteHistory("session-1"),
"deleting an already-absent history must not error — task cleanup runs more than once")
require.ErrorContains(t, mgr.DeleteHistory(""), "session ID is required")
}
// TestHistoryFilePathSanitizesSessionID pins the traversal guard: a session ID
// carrying separators must not write outside the history directory.
func TestHistoryFilePathSanitizesSessionID(t *testing.T) {
mgr, dir := newSessionHistoryManagerForTest(t)
require.Equal(t, filepath.Join(dir, ".._.._etc_passwd.jsonl"),
mgr.historyFilePath("../../etc/passwd"))
require.Equal(t, filepath.Join(dir, "a_b.jsonl"), mgr.historyFilePath(`a\b`))
}
@@ -0,0 +1,184 @@
package lifecycle
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
)
// recordedWorkspaceCallback captures which handler fired, for which execution,
// and with what payload — the three things a dropped wiring hop silently loses.
type recordedWorkspaceCallback struct {
name string
execution *AgentExecution
payload any
}
func newRecordingStreamManager(t *testing.T, recorded *[]recordedWorkspaceCallback) *StreamManager {
t.Helper()
record := func(name string) func(*AgentExecution, any) {
return func(execution *AgentExecution, payload any) {
*recorded = append(*recorded, recordedWorkspaceCallback{name: name, execution: execution, payload: payload})
}
}
stopCh := newTestStopCh(t)
sm := NewStreamManager(newTestLogger(), StreamCallbacks{
OnShellOutput: func(e *AgentExecution, data string) { record("shell_output")(e, data) },
OnShellExit: func(e *AgentExecution, code int) { record("shell_exit")(e, code) },
OnGitStatus: func(e *AgentExecution, u *agentctl.GitStatusUpdate) {
record("git_status")(e, u)
},
OnGitCommit: func(e *AgentExecution, c *agentctl.GitCommitNotification) {
record("git_commit")(e, c)
},
OnGitReset: func(e *AgentExecution, r *agentctl.GitResetNotification) {
record("git_reset")(e, r)
},
OnBranchSwitch: func(e *AgentExecution, b *agentctl.GitBranchSwitchNotification) {
record("branch_switch")(e, b)
},
OnFileChange: func(e *AgentExecution, n *agentctl.FileChangeNotification) {
record("file_change")(e, n)
},
OnProcessOutput: func(e *AgentExecution, o *agentctl.ProcessOutput) {
record("process_output")(e, o)
},
OnProcessStatus: func(e *AgentExecution, s *agentctl.ProcessStatusUpdate) {
record("process_status")(e, s)
},
}, nil, stopCh)
cleanupStreamManager(t, stopCh, sm)
return sm
}
// TestBuildWorkspaceCallbacksForwardsEveryHopWithItsExecution pins the full
// wiring chain from the agentctl workspace stream to the manager's handlers.
// A dropped hop here is invisible to any test that calls the manager handler
// directly: the stream would simply stop delivering that notification.
func TestBuildWorkspaceCallbacksForwardsEveryHopWithItsExecution(t *testing.T) {
var recorded []recordedWorkspaceCallback
sm := newRecordingStreamManager(t, &recorded)
execution := &AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"}
callbacks := sm.buildWorkspaceCallbacks(execution)
status := &agentctl.GitStatusUpdate{Branch: "feature/cover", Timestamp: time.Now()}
commit := &agentctl.GitCommitNotification{CommitSHA: "abc123"}
reset := &agentctl.GitResetNotification{PreviousHead: "head-after"}
branchSwitch := &agentctl.GitBranchSwitchNotification{CurrentBranch: "feature/cover"}
fileChange := &agentctl.FileChangeNotification{Path: "src/main.go"}
output := &agentctl.ProcessOutput{ProcessID: "proc-1", Data: "hello"}
procStatus := &agentctl.ProcessStatusUpdate{ProcessID: "proc-1", Status: "failed"}
callbacks.OnShellOutput("build ok\n")
callbacks.OnShellExit(137)
callbacks.OnGitStatus(status)
callbacks.OnGitCommit(commit)
callbacks.OnGitReset(reset)
callbacks.OnBranchSwitch(branchSwitch)
callbacks.OnFileChange(fileChange)
callbacks.OnProcessOutput(output)
callbacks.OnProcessStatus(procStatus)
// These two only log; they must not panic or reach a handler.
callbacks.OnConnected()
callbacks.OnError("stream reset by peer")
require.Len(t, recorded, 9)
wantOrder := []string{
"shell_output", "shell_exit", "git_status", "git_commit", "git_reset",
"branch_switch", "file_change", "process_output", "process_status",
}
for i, want := range wantOrder {
require.Equal(t, want, recorded[i].name, "hop %d", i)
require.Same(t, execution, recorded[i].execution,
"%s must be attributed to the execution the stream belongs to", want)
}
require.Equal(t, "build ok\n", recorded[0].payload)
require.Equal(t, 137, recorded[1].payload)
require.Same(t, status, recorded[2].payload)
require.Same(t, commit, recorded[3].payload)
require.Same(t, reset, recorded[4].payload)
require.Same(t, branchSwitch, recorded[5].payload)
require.Same(t, fileChange, recorded[6].payload)
require.Same(t, output, recorded[7].payload)
require.Same(t, procStatus, recorded[8].payload)
}
// TestBuildWorkspaceCallbacksToleratesUnwiredHandlers pins the nil guards: a
// StreamManager built without a given handler must drop the notification, not
// panic and tear down the workspace stream goroutine.
func TestBuildWorkspaceCallbacksToleratesUnwiredHandlers(t *testing.T) {
stopCh := newTestStopCh(t)
sm := NewStreamManager(newTestLogger(), StreamCallbacks{}, nil, stopCh)
cleanupStreamManager(t, stopCh, sm)
callbacks := sm.buildWorkspaceCallbacks(&AgentExecution{ID: "exec-1"})
callbacks.OnShellOutput("data")
callbacks.OnShellExit(0)
callbacks.OnGitStatus(&agentctl.GitStatusUpdate{})
callbacks.OnGitCommit(&agentctl.GitCommitNotification{})
callbacks.OnGitReset(&agentctl.GitResetNotification{})
callbacks.OnBranchSwitch(&agentctl.GitBranchSwitchNotification{})
callbacks.OnFileChange(&agentctl.FileChangeNotification{})
callbacks.OnProcessOutput(&agentctl.ProcessOutput{})
callbacks.OnProcessStatus(&agentctl.ProcessStatusUpdate{})
callbacks.OnConnected()
callbacks.OnError("boom")
}
// TestStreamManagerStartRejectsWorkAfterWait pins the drain barrier: once Wait
// has run, no new stream goroutine may be spawned, and a caller waiting on
// `ready` is still released rather than blocked forever.
func TestStreamManagerStartRejectsWorkAfterWait(t *testing.T) {
stopCh := newTestStopCh(t)
sm := NewStreamManager(newTestLogger(), StreamCallbacks{}, nil, stopCh)
// Registered before the Wait below and before any t.Fatal path, so the
// drain still runs if this test fails early.
cleanupStreamManager(t, stopCh, sm)
sm.Wait()
execution := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
ready := make(chan struct{})
sm.ConnectWorkspaceStream(execution, ready)
select {
case <-ready:
case <-time.After(100 * time.Millisecond):
t.Fatal("ready was not closed after a rejected start; callers would block forever")
}
// ConnectMCPStream has no ready channel — it simply must not spawn.
// The registered cleanup drains; no trailing Wait needed here.
sm.ConnectMCPStream(execution)
}
func TestStopChannelContextErrReportsFirstClosedSignal(t *testing.T) {
primary := make(chan struct{})
secondary := make(chan struct{})
ctx := &stopChannelContext{Context: context.Background(), primary: primary, secondary: secondary}
require.NoError(t, ctx.Err(), "no signal has fired yet")
close(secondary)
require.ErrorIs(t, ctx.Err(), context.Canceled,
"the StreamManager's internal drain signal must cancel the stream context")
close(primary)
require.ErrorIs(t, ctx.Err(), context.Canceled)
}
func TestStopChannelContextErrFallsBackToParent(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
ctx := &stopChannelContext{Context: parent}
require.NoError(t, ctx.Err())
cancel()
require.ErrorIs(t, ctx.Err(), context.Canceled,
"with no stop channels the parent context still governs")
}