- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리 - 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16) - finite 재연결 시도 고갈 시 노드 종료 - CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
390 lines
11 KiB
Go
390 lines
11 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"context"
|
|
"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/go/config"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestCLIExecutePersistentCancelDoesNotTerminateSession verifies that a
|
|
// cancelled run leaves the session alive so subsequent runs can reuse it.
|
|
func TestCLIExecutePersistentCancelDoesNotTerminateSession(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
// ResponseIdleTimeoutMS is short so the cancel-drain exits quickly,
|
|
// allowing the second execute to acquire the session lock promptly.
|
|
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: 150,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
ctx := context.Background()
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
// First execute: cancel before idle timeout fires.
|
|
firstCtx, firstCancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
|
defer firstCancel()
|
|
|
|
sink1 := &testutil.FakeSink{}
|
|
_ = c.Execute(firstCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-1",
|
|
Target: "slow-echo",
|
|
Input: map[string]any{"prompt": "first"},
|
|
}, sink1)
|
|
|
|
// Give time for cancel drain to finish (drain uses idleTimeout=150ms).
|
|
time.Sleep(400 * time.Millisecond)
|
|
|
|
// Second execute on same session — must succeed (session still alive).
|
|
execCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
|
|
sink2 := &testutil.FakeSink{}
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-2",
|
|
Target: "slow-echo",
|
|
Input: map[string]any{"prompt": "second"},
|
|
}, sink2)
|
|
if err != nil {
|
|
t.Fatalf("second execute after cancel must succeed (session should be alive): %v", err)
|
|
}
|
|
events2 := sink2.Events()
|
|
if len(events2) == 0 || events2[len(events2)-1].Type != noderuntime.EventTypeComplete {
|
|
t.Fatalf("expected complete event on second run, got %v", events2)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecutePersistent_UserCancelEmitsUserCancelMessage(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 10; printf "reply:%s\n" "$line"; done`},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
ResponseIdleTimeoutMS: 500,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
ctx := context.Background()
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
execCtx, cancel := context.WithCancel(ctx)
|
|
|
|
sink := &testutil.FakeSink{}
|
|
go func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-usercancel",
|
|
Target: "slow-echo",
|
|
Input: map[string]any{"prompt": "hello"},
|
|
}, sink)
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
|
|
events := sink.Events()
|
|
var lastMsg string
|
|
var cancelEvent bool
|
|
for i := len(events) - 1; i >= 0; i-- {
|
|
if events[i].Type == noderuntime.EventTypeCancelled {
|
|
cancelEvent = true
|
|
lastMsg = events[i].Message
|
|
}
|
|
}
|
|
if !cancelEvent {
|
|
t.Fatal("expected cancelled event")
|
|
}
|
|
if lastMsg != "user-cancel" {
|
|
t.Fatalf("expected cancel Message 'user-cancel', got %q", lastMsg)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecutePersistent_TimeoutEmitsTimeoutMessage(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 10; printf "reply:%s\n" "$line"; done`},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
ResponseIdleTimeoutMS: 500,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
ctx := context.Background()
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
sink := &testutil.FakeSink{}
|
|
executeErr := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-timeout",
|
|
Target: "slow-echo",
|
|
Input: map[string]any{"prompt": "hello"},
|
|
}, sink)
|
|
if executeErr == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
|
|
events := sink.Events()
|
|
var lastMsg string
|
|
var cancelEvent bool
|
|
for i := len(events) - 1; i >= 0; i-- {
|
|
if events[i].Type == noderuntime.EventTypeCancelled {
|
|
cancelEvent = true
|
|
lastMsg = events[i].Message
|
|
}
|
|
}
|
|
if !cancelEvent {
|
|
t.Fatal("expected cancelled event")
|
|
}
|
|
if lastMsg != "timeout" {
|
|
t.Fatalf("expected cancel Message 'timeout', got %q", lastMsg)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecutePersistent_CompletionMarkerLineEndsRun(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"marker-line": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; printf "<<END_OF_RESPONSE>>\n"; done`},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
ResponseIdleTimeoutMS: 500,
|
|
StartupIdleTimeoutMS: 50,
|
|
CompletionMarker: config.CompletionMarkerConf{Line: "<<END_OF_RESPONSE>>"},
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
ctx := context.Background()
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
sink := &testutil.FakeSink{}
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-marker-line",
|
|
Target: "marker-line",
|
|
Input: map[string]any{"prompt": "hello"},
|
|
}, sink)
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
events := sink.Events()
|
|
last := events[len(events)-1]
|
|
if last.Type != noderuntime.EventTypeComplete {
|
|
t.Fatalf("expected last event to be complete, got %q", last.Type)
|
|
}
|
|
if last.Message != "completion-marker" {
|
|
t.Fatalf("expected Message 'completion-marker', got %q", last.Message)
|
|
}
|
|
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") {
|
|
t.Fatalf("expected reply:hello in deltas, got %q", combined)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecutePersistent_CompletionMarkerRegexEndsRun(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"marker-regex": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; printf "DONE 42\n"; done`},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
ResponseIdleTimeoutMS: 500,
|
|
StartupIdleTimeoutMS: 50,
|
|
CompletionMarker: config.CompletionMarkerConf{Regex: "^DONE \\d+$"},
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
ctx := context.Background()
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
sink := &testutil.FakeSink{}
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-marker-regex",
|
|
Target: "marker-regex",
|
|
Input: map[string]any{"prompt": "hi"},
|
|
}, sink)
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
events := sink.Events()
|
|
last := events[len(events)-1]
|
|
if last.Type != noderuntime.EventTypeComplete {
|
|
t.Fatalf("expected last event to be complete, got %q", last.Type)
|
|
}
|
|
if last.Message != "completion-marker" {
|
|
t.Fatalf("expected Message 'completion-marker', got %q", last.Message)
|
|
}
|
|
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hi") {
|
|
t.Fatalf("expected reply:hi in deltas, got %q", combined)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecutePersistent_NoMarkerFallsBackToIdleTimeout(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"no-marker": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.1; 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)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
sink := &testutil.FakeSink{}
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-no-marker",
|
|
Target: "no-marker",
|
|
Input: map[string]any{"prompt": "fallback"},
|
|
}, sink)
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
events := sink.Events()
|
|
last := events[len(events)-1]
|
|
if last.Type != noderuntime.EventTypeComplete {
|
|
t.Fatalf("expected last event to be complete, got %q", last.Type)
|
|
}
|
|
if last.Message != "idle-timeout" {
|
|
t.Fatalf("expected Message 'idle-timeout' when no marker is set, got %q", last.Message)
|
|
}
|
|
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:fallback") {
|
|
t.Fatalf("expected reply:fallback in deltas, got %q", combined)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecutePersistent_IdleTimeoutMessageReason(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"idle-reason": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.1; 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)
|
|
}
|
|
defer func() { _ = c.Stop(ctx) }()
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
sink := &testutil.FakeSink{}
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: "run-idle-reason",
|
|
Target: "idle-reason",
|
|
Input: map[string]any{"prompt": "test"},
|
|
}, sink)
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
events := sink.Events()
|
|
var complete *noderuntime.RuntimeEvent
|
|
for i := range events {
|
|
if events[i].Type == noderuntime.EventTypeComplete {
|
|
complete = &events[i]
|
|
}
|
|
}
|
|
if complete == nil {
|
|
t.Fatal("expected complete event")
|
|
}
|
|
if complete.Message != "idle-timeout" {
|
|
t.Fatalf("expected Message 'idle-timeout', got %q", complete.Message)
|
|
}
|
|
}
|