Files
kandev/apps/backend/internal/agentctl/server/shell/session_test.go
T
João Salavisa 2e108b040f refactor: session resumption cleanup and AgentInstance → AgentExecution rename (#23)
* feat: session resumption with ACP session ID support

- Add useSessionResumption hook for automatic session recovery on page reload
- Fix ACPSessionID not being set on AgentInstance in Launch() and createInstance()
- Add task.session.status and task.session.resume WebSocket actions
- Fix shell terminal retry logic for resumed sessions
- Fix taskSessionState reading from Zustand store instead of local state
- Remove dead code: unused stream handling functions in lifecycle/manager.go
- Fix test mock interfaces (CleanupStaleInstanceByTaskID, NewManager signature)
- Add enhanced logging for session resumption debugging
- Simplify page-client.tsx state merging logic

* refactor: remove unused AgentController (dead code)

- Delete agent_controller.go - entirely unused duplicate of Manager methods
- Remove agentController field from Manager struct
- Remove AgentController initialization in NewManager

The AgentController duplicated StartAgentProcess, initializeACPSession, and
PromptAgent from Manager but was never called. All agent control operations
go through Manager's own methods.

* refactor: remove unused MemoryRepository (dead code)

- Delete memory.go - entire MemoryRepository implementation unused
  (replaced by SQLite repository)
- Delete memory_test.go - tests for removed MemoryRepository

Found via 'deadcode' tool analysis.

* refactor: consolidate agentctl packages into server/client structure

Reorganize the confusing dual agentctl packages:
- internal/agentctl/* → internal/agentctl/server/
- internal/agent/agentctl/* → internal/agentctl/client/

Structure is now:
  internal/agentctl/
  ├── client/      - HTTP client for talking to agentctl servers
  │   ├── client.go
  │   ├── standalone.go
  │   ├── acp.go
  │   └── launcher/
  ├── server/      - agentctl server implementation (runs in containers)
  │   ├── api/
  │   ├── adapter/
  │   ├── acp/
  │   ├── config/
  │   ├── instance/
  │   ├── process/
  │   └── shell/
  └── types/       - Shared types used by both client and server

Also consolidated shared types (GitStatusUpdate, FileInfo, etc.) from
process/types.go into the shared types/ package.

* refactor: rename AgentInstance to AgentExecution for clarity

Rename AgentInstance -> AgentExecution across the entire codebase to better
reflect what it represents: an agent actively working on a task, not just
a generic 'instance'.

Changes:
- AgentInstance struct -> AgentExecution
- InstanceStore -> ExecutionStore (new file: execution_store.go)
- RecoveredInstance -> RecoveredExecution
- All method names: GetInstance* -> GetExecution*, ListInstances -> ListExecutions, etc.
- All parameters: instanceID -> executionID
- All local variables: instance -> execution
- DTOs and JSON tags: agent_instance_id -> agent_execution_id
- Database columns: agent_instance_id -> agent_execution_id

Also consolidates duplicate prompt logic:
- Merge sendInitialPrompt and SendPrompt into single SendPrompt method
- Add validateStatus parameter to differentiate initial vs follow-up prompts

Also extracts ACP session management to session.go:
- Move initializeACPSession logic to SessionManager.InitializeAndPrompt
- Move PromptAgent logic to SessionManager.SendPrompt
- Manager now delegates to SessionManager for all prompt operations

Also removes duplicate event publishing:
- Remove 9 duplicate m.publish*() methods from manager.go
- Use existing EventPublisher methods via m.eventPublisher.Publish*()

Also extracts types to dedicated file:
- Move AgentExecution, LaunchRequest, PromptResult, etc. to types.go

Net reduction: ~600 lines removed from manager.go through deduplication
and better separation of concerns.

* fix(backend): Docker runtime now creates instance via ControlServer

The Docker runtime was incorrectly creating an agentctl.Client pointing
directly to port 9999 (the ControlServer port), then trying to call
instance-specific APIs like /api/v1/agent/configure which only exist
on per-instance servers.

Fixed by making Docker runtime follow the same flow as standalone mode:
- LaunchContainer now uses StandaloneCtl to create an instance via the
  control API, then creates Client pointing to the instance port
- RecoverInstances queries the ControlServer to get the instance port
- Added waitForHealth helper to wait for agentctl to be ready

Also improved HTTP error handling in agentctl client:
- All client methods now check HTTP status codes before JSON decoding
- Better error messages include status code and response body

* fix: restore session resumption imports and variables

* fix: add sessionId prop back to TaskLayout
2026-01-16 22:14:19 +00:00

547 lines
12 KiB
Go

package shell
import (
"os"
"runtime"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
)
func newTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error", // Suppress logs during tests
Format: "console",
})
return log
}
// TestDefaultConfig tests that DefaultConfig returns expected values
func TestDefaultConfig(t *testing.T) {
workDir := "/test/dir"
cfg := DefaultConfig(workDir)
if cfg.WorkDir != workDir {
t.Errorf("expected WorkDir %q, got %q", workDir, cfg.WorkDir)
}
if cfg.Cols != 80 {
t.Errorf("expected Cols 80, got %d", cfg.Cols)
}
if cfg.Rows != 24 {
t.Errorf("expected Rows 24, got %d", cfg.Rows)
}
}
// TestDetectShell tests shell detection for the current OS
func TestDetectShell(t *testing.T) {
shell, args := detectShell()
if shell == "" {
t.Error("detectShell returned empty shell")
}
// Verify detected shell exists on Unix systems
if runtime.GOOS != "windows" {
if _, err := os.Stat(shell); err != nil {
// May be in PATH, try LookPath
if _, err := os.Stat(shell); err != nil && shell != os.Getenv("SHELL") {
t.Logf("Warning: detected shell %q may not exist: %v", shell, err)
}
}
// Unix shells should have -l flag for login shell
if len(args) > 0 && args[0] != "-l" {
t.Errorf("expected Unix shell args to contain '-l', got %v", args)
}
} else {
// Windows should detect cmd.exe, powershell.exe, or pwsh.exe
validShells := map[string]bool{
"cmd.exe": true,
"powershell.exe": true,
"pwsh.exe": true,
}
if !validShells[shell] {
t.Errorf("unexpected Windows shell: %q", shell)
}
}
}
// TestDetectShellWithSHELLEnv tests shell detection respects SHELL env var on Unix
func TestDetectShellWithSHELLEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("SHELL env var is not used on Windows")
}
originalShell := os.Getenv("SHELL")
defer func() { _ = os.Setenv("SHELL", originalShell) }()
// Test with custom SHELL
_ = os.Setenv("SHELL", "/bin/custom-shell")
shell, args := detectShell()
if shell != "/bin/custom-shell" {
t.Errorf("expected shell from SHELL env, got %q", shell)
}
if len(args) == 0 || args[0] != "-l" {
t.Errorf("expected args to contain '-l', got %v", args)
}
}
// TestNewSession tests creating a new session
func TestNewSession(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
// PTY may not work in all CI environments
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Verify session is running
status := session.Status()
if !status.Running {
t.Error("expected session to be running")
}
if status.Pid == 0 {
t.Error("expected non-zero PID")
}
if status.Cwd != workDir {
t.Errorf("expected Cwd %q, got %q", workDir, status.Cwd)
}
if status.Shell == "" {
t.Error("expected non-empty Shell")
}
if status.StartedAt.IsZero() {
t.Error("expected non-zero StartedAt")
}
}
// TestSessionStop tests stopping a session
func TestSessionStop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
// Stop the session
if err := session.Stop(); err != nil {
t.Errorf("Stop failed: %v", err)
}
// Verify session is stopped
status := session.Status()
if status.Running {
t.Error("expected session to not be running after Stop")
}
// Calling Stop again should be idempotent
if err := session.Stop(); err != nil {
t.Errorf("second Stop failed: %v", err)
}
}
// TestSessionWrite tests writing to the shell
func TestSessionWrite(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Write a simple command
data := []byte("echo hello\n")
n, err := session.Write(data)
if err != nil {
t.Errorf("Write failed: %v", err)
}
if n != len(data) {
t.Errorf("expected to write %d bytes, wrote %d", len(data), n)
}
}
// TestSessionWriteNotRunning tests writing to a stopped session
func TestSessionWriteNotRunning(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
// Stop the session first
_ = session.Stop()
// Write should fail
_, err = session.Write([]byte("echo hello\n"))
if err == nil {
t.Error("expected Write to fail on stopped session")
}
}
// TestSessionSubscribeUnsubscribe tests the subscriber pattern
func TestSessionSubscribeUnsubscribe(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Create subscriber channel
ch := make(chan []byte, 100)
// Subscribe
session.Subscribe(ch)
// Write something to trigger output
_, _ = session.Write([]byte("echo test\n"))
// Wait a bit for output
time.Sleep(100 * time.Millisecond)
// Unsubscribe
session.Unsubscribe(ch)
// The channel should have received some data (shell prompt + echo output)
select {
case data := <-ch:
if len(data) == 0 {
t.Error("expected non-empty output")
}
default:
// May not receive data immediately, that's okay
t.Log("no output received (may be timing-dependent)")
}
}
// TestSessionMultipleSubscribers tests multiple subscribers receive output
func TestSessionMultipleSubscribers(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Create multiple subscriber channels
ch1 := make(chan []byte, 100)
ch2 := make(chan []byte, 100)
ch3 := make(chan []byte, 100)
session.Subscribe(ch1)
session.Subscribe(ch2)
session.Subscribe(ch3)
// Write something
_, _ = session.Write([]byte("echo multi\n"))
// Wait for output
time.Sleep(100 * time.Millisecond)
// All channels should potentially receive data
// Just verify no panics or errors occurred
session.Unsubscribe(ch1)
session.Unsubscribe(ch2)
session.Unsubscribe(ch3)
}
// TestSessionStatus tests Status returns correct information
func TestSessionStatus(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
status := session.Status()
// Verify all fields
if !status.Running {
t.Error("expected Running to be true")
}
if status.Pid <= 0 {
t.Errorf("expected positive PID, got %d", status.Pid)
}
if status.Shell == "" {
t.Error("expected non-empty Shell")
}
if status.Cwd != workDir {
t.Errorf("expected Cwd %q, got %q", workDir, status.Cwd)
}
if status.StartedAt.IsZero() {
t.Error("expected non-zero StartedAt")
}
if time.Since(status.StartedAt) > 5*time.Second {
t.Error("StartedAt seems too old")
}
}
// TestSessionStatusAfterStop tests Status after stopping
func TestSessionStatusAfterStop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
_ = session.Stop()
status := session.Status()
if status.Running {
t.Error("expected Running to be false after Stop")
}
}
// TestBuildShellEnv tests the buildShellEnv function
func TestBuildShellEnv(t *testing.T) {
workDir := "/test/workspace"
env := buildShellEnv(workDir)
// Check that env is not empty
if len(env) == 0 {
t.Error("expected non-empty environment")
}
// Check for PWD
pwdFound := false
termFound := false
for _, e := range env {
if e == "PWD="+workDir {
pwdFound = true
}
if e == "TERM=xterm-256color" {
termFound = true
}
}
if !pwdFound {
t.Error("expected PWD to be set in environment")
}
if !termFound {
t.Error("expected TERM to be set in environment")
}
}
// TestSessionConcurrentSubscribe tests concurrent subscribe/unsubscribe
func TestSessionConcurrentSubscribe(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Concurrently subscribe and unsubscribe
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ch := make(chan []byte, 10)
session.Subscribe(ch)
time.Sleep(10 * time.Millisecond)
session.Unsubscribe(ch)
}()
}
wg.Wait()
}
// TestSessionConcurrentWrite tests concurrent writes
func TestSessionConcurrentWrite(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Concurrently write
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
_, _ = session.Write([]byte("echo test\n"))
}(i)
}
wg.Wait()
}
// TestSessionConcurrentStatus tests concurrent status reads
func TestSessionConcurrentStatus(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY is not supported on Windows")
}
if os.Getenv("CI") != "" {
t.Skip("Skipping PTY test in CI environment")
}
log := newTestLogger()
workDir := t.TempDir()
cfg := DefaultConfig(workDir)
session, err := NewSession(cfg, log)
if err != nil {
t.Fatalf("NewSession failed: %v", err)
}
defer func() { _ = session.Stop() }()
// Concurrently read status
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
status := session.Status()
if !status.Running {
t.Error("expected session to be running")
}
}()
}
wg.Wait()
}
// TestDetectShellFallback tests shell fallback when SHELL is unset
func TestDetectShellFallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("SHELL env var fallback is for Unix only")
}
originalShell := os.Getenv("SHELL")
defer func() { _ = os.Setenv("SHELL", originalShell) }()
// Unset SHELL
_ = os.Unsetenv("SHELL")
shell, _ := detectShell()
// Should fall back to one of the common shells
validShells := map[string]bool{
"/bin/bash": true,
"/bin/zsh": true,
"/bin/sh": true,
}
if !validShells[shell] {
t.Logf("Detected shell: %q (may be valid if it exists on this system)", shell)
}
}