iop/apps/node/internal/adapters/cli/lifecycle/cli_test.go
toki 96f79fcd08 feat: unbounded CLI sessions support and related improvements
- Add unbounded CLI sessions agent-task with plan and code review logs
- Add edge console with tests
- Add node run manager for session lifecycle management
- Add router and store tests
- Update transport session handling
- Add runtime proto definitions
- Update configurations and packages
2026-05-03 20:07:09 +09:00

384 lines
11 KiB
Go

package lifecycle_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
clipkg "iop/apps/node/internal/adapters/cli"
"iop/apps/node/internal/adapters/cli/internal/testutil"
noderuntime "iop/apps/node/internal/runtime"
"iop/packages/config"
)
func TestCLIStartPersistentReturnsErrorWhenProcessExitsDuringStartup(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"exits-immediately": {
Command: "sh",
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
if err := c.Start(context.Background()); err == nil {
t.Fatal("expected error when persistent process exits during startup")
}
}
func TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails(t *testing.T) {
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"ok-profile": {
Command: "sh",
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: false,
StartupIdleTimeoutMS: 50,
ResponseIdleTimeoutMS: 200,
},
"fail-profile": {
Command: "sh",
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err == nil {
t.Fatal("expected Start to fail when one persistent profile exits immediately")
}
_ = c.Stop(ctx)
_ = c.Stop(ctx)
// After rollback, the pre-started default session for ok-profile is gone.
// Verify with require-existing so lazy creation is not allowed.
for _, model := range []string{"ok-profile", "fail-profile"} {
sink := &testutil.FakeSink{}
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "post-cleanup",
Model: model,
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "test"},
}, sink); err == nil {
t.Fatalf("expected Execute for %q to fail after cleanup (require-existing), got nil", model)
}
}
}
func TestCLIStartDeterministicOrder(t *testing.T) {
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"Z-alive": {
Command: "sh",
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: false,
StartupIdleTimeoutMS: 50,
ResponseIdleTimeoutMS: 200,
},
"Z-fail": {
Command: "sh",
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err == nil {
t.Fatal("expected Start to fail when one persistent profile exits immediately")
}
_ = c.Stop(ctx)
_ = c.Stop(ctx)
// After rollback, pre-started default sessions must be gone.
for _, model := range []string{"Z-alive", "Z-fail"} {
sink := &testutil.FakeSink{}
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "post-cleanup",
Model: model,
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "test"},
}, sink); err == nil {
t.Fatalf("expected Execute for %q to fail after cleanup (require-existing), got nil", model)
}
}
}
func TestCLIConcurrentExecuteAndStop(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"slow-echo": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.05; printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 50,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
errCh := make(chan error, 10)
done := make(chan struct{})
go func() {
for i := 0; i < 3; i++ {
sink := &testutil.FakeSink{}
execCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
RunID: fmt.Sprintf("run-conc-%d", i),
Model: "slow-echo",
Input: map[string]any{"prompt": fmt.Sprintf("conc-%d", i)},
}, sink)
cancel()
if err != nil {
errCh <- fmt.Errorf("concurrent execute %d: %w", i, err)
}
}
done <- struct{}{}
}()
go func() {
time.Sleep(200 * time.Millisecond)
_ = c.Stop(ctx)
done <- struct{}{}
}()
go func() {
time.Sleep(300 * time.Millisecond)
for i := 0; i < 2; i++ {
sink := &testutil.FakeSink{}
execCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
RunID: fmt.Sprintf("run-post-stop-%d", i),
Model: "slow-echo",
Input: map[string]any{"prompt": fmt.Sprintf("post-stop-%d", i)},
}, sink)
cancel()
if err != nil {
errCh <- fmt.Errorf("post-stop execute %d: %w", i, err)
}
}
done <- struct{}{}
}()
timeout := time.After(10 * time.Second)
for completed := 0; completed < 3; completed++ {
select {
case <-done:
case <-timeout:
t.Fatal("TestCLIConcurrentExecuteAndStop timed out; possible deadlock")
}
}
close(errCh)
for range errCh {
}
}
func TestCLIStartPartialRollbackWithMarkers(t *testing.T) {
testutil.RequireUnixShell(t)
markerDir := t.TempDir()
startMarker := "START_MARKER_A"
startMarkerPath := filepath.Join(markerDir, "marker_a")
profileACmd := fmt.Sprintf(`mkdir -p %s; echo %s > %s/marker_a; while IFS= read -r line; do printf "reply:%%s\n" "$line"; done`,
markerDir, startMarker, markerDir)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"profile-a": {
Command: "sh",
Args: []string{"-c", profileACmd},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
ResponseIdleTimeoutMS: 200,
},
"profile-b": {
Command: "sh",
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err == nil {
t.Fatal("expected Start to fail when profile-b exits immediately")
}
_ = c.Stop(ctx)
time.Sleep(300 * time.Millisecond)
testutil.RequireEventually(t, 2*time.Second, 50*time.Millisecond, func() bool {
return testutil.ReadMarker(startMarkerPath) == startMarker
})
// After rollback, the pre-started default session for profile-a is gone.
sink := &testutil.FakeSink{}
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "post-rollback",
Model: "profile-a",
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "test"},
}, sink); err == nil {
t.Fatal("expected Execute for profile-a to fail with no-session after rollback (require-existing)")
}
}
func TestCLITerminateSessionStopsOnlyTargetSession(t *testing.T) {
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"echo-term": {
Command: "sh",
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: false,
ResponseIdleTimeoutMS: 200,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
// Create session-a and session-b via lazy creation.
for _, sid := range []string{"session-a", "session-b"} {
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-" + sid,
Model: "echo-term",
SessionID: sid,
Input: map[string]any{"prompt": "ping"},
}, sink)
if err != nil {
t.Fatalf("create %s: %v", sid, err)
}
}
// Terminate session-a only.
if err := c.TerminateSession(ctx, "echo-term", "session-a"); err != nil {
t.Fatalf("TerminateSession: %v", err)
}
// session-b must still respond.
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-b-after",
Model: "echo-term",
SessionID: "session-b",
Input: map[string]any{"prompt": "pong"},
}, sink)
if err != nil {
t.Fatalf("session-b should still be alive: %v", err)
}
// session-a must be gone (require-existing fails).
sinkA := &testutil.FakeSink{}
err = c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-a-after",
Model: "echo-term",
SessionID: "session-a",
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "should-fail"},
}, sinkA)
if err == nil {
t.Fatal("expected error for terminated session-a with require-existing mode")
}
_ = c.Stop(ctx)
}
func TestCLIStopStopsAllLogicalSessions(t *testing.T) {
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"echo-stop": {
Command: "sh",
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: false,
ResponseIdleTimeoutMS: 200,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
// Create three logical sessions.
for _, sid := range []string{"s1", "s2", "s3"} {
sink := &testutil.FakeSink{}
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-" + sid,
Model: "echo-stop",
SessionID: sid,
Input: map[string]any{"prompt": "ping"},
}, sink); err != nil {
t.Fatalf("create %s: %v", sid, err)
}
}
if err := c.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
// After Stop, all sessions must be gone (require-existing must fail).
for _, sid := range []string{"s1", "s2", "s3"} {
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-after-" + sid,
Model: "echo-stop",
SessionID: sid,
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "should-fail"},
}, sink)
if err == nil {
t.Fatalf("session %q should be stopped after Stop()", sid)
}
}
}