iop/apps/node/internal/adapters/cli/persistent_completion_test.go
toki 4a76851254 feat(node): 연결 supervisor 및 CLI adaptors 개선
- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리
- 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16)
- finite 재연결 시도 고갈 시 노드 종료
- CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
2026-07-22 18:11:06 +09:00

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)
}
}