iop/apps/node/internal/adapters/cli/persistent_terminal_test.go
toki 01dc2ef78b refactor: readability baseline 및 테스트 구조 개선
- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동
- 새 테스트 파일 추가 (edge, node, client, config, readability)
- readability_audit 스크립트 및 baseline 추가
- roadmap/SDD 문서 갱신
- agent-client/pi/extensions/openai-sampling-parameters 추가
2026-07-17 16:02:12 +09:00

590 lines
17 KiB
Go

package cli_test
import (
"context"
"errors"
"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"
"os"
osexec "os/exec"
"strings"
"testing"
"time"
)
func TestCLIStartPersistentTerminalAndExecuteWritesPrompt(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"echo-persistent": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 300,
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) }()
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "echo-persistent",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
var started, completed bool
for _, e := range events {
switch e.Type {
case noderuntime.EventTypeStart:
started = true
case noderuntime.EventTypeComplete:
completed = true
}
}
if !started {
t.Fatal("expected start event")
}
if !completed {
t.Fatal("expected complete event")
}
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") {
t.Fatalf("expected reply:hello in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentTerminalSendsCarriageReturn(t *testing.T) {
testutil.RequirePTYSupport(t)
if _, err := osexec.LookPath("stty"); err != nil {
t.Skip("stty required")
}
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"raw-tui": {
Command: os.Args[0],
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
Env: []string{"IOP_RAW_TUI_HELPER=1"},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
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, 1*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
RunID: "run-raw-tui",
Target: "raw-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") {
t.Fatalf("expected reply:hello in deltas, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete {
t.Fatalf("expected complete event, got %s", last.Type)
}
}
func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) {
testutil.RequirePTYSupport(t)
if _, err := osexec.LookPath("stty"); err != nil {
t.Skip("stty required")
}
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-warning": {
Command: os.Args[0],
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
Env: []string{"IOP_CLAUDE_WARNING_HELPER=1"},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 250,
},
},
}
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-claude-warning",
Target: "claude-warning",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") {
t.Fatalf("expected reply:hello in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning(t *testing.T) {
testutil.RequirePTYSupport(t)
if _, err := osexec.LookPath("stty"); err != nil {
t.Skip("stty required")
}
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-trust-warning": {
Command: os.Args[0],
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
Env: []string{"IOP_CLAUDE_TRUST_WARNING_HELPER=1"},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 250,
},
},
}
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-claude-trust-warning",
Target: "claude-trust-warning",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") {
t.Fatalf("expected reply:hello in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentClaudeTUICancelsSessionLimitUpgradePrompt(t *testing.T) {
testutil.RequirePTYSupport(t)
if _, err := osexec.LookPath("stty"); err != nil {
t.Skip("stty required")
}
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: os.Args[0],
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
Env: []string{"IOP_CLAUDE_RATE_LIMIT_HELPER=1"},
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, 5*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
RunID: "run-claude-rate-limit",
Target: "claude-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if !errors.Is(err, noderuntime.ErrRunCancelled) {
t.Fatalf("expected run cancelled, got %v", err)
}
var cancelMsg string
for _, ev := range sink.Events() {
if ev.Type == noderuntime.EventTypeCancelled {
cancelMsg = ev.Message
}
if ev.Type == noderuntime.EventTypeError {
t.Fatalf("expected cancellation, got error event %q", ev.Error)
}
}
if !strings.Contains(cancelMsg, "session limit") {
t.Fatalf("expected cancelled event to mention session limit, got %q", cancelMsg)
}
}
func TestCLIExecutePersistentClaudeTUIReplaysPromptAfterStartupReadyRace(t *testing.T) {
testutil.RequirePTYSupport(t)
if _, err := osexec.LookPath("stty"); err != nil {
t.Skip("stty required")
}
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: os.Args[0],
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
Env: []string{"IOP_CLAUDE_READY_REPLAY_HELPER=1"},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 40,
},
},
}
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-claude-ready-race",
Target: "claude-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
if combined := testutil.CollectDeltas(sink.Events()); combined != "replay:hello" {
t.Fatalf("expected replayed prompt response, got %q", combined)
}
}
func TestCLIStartPersistentTerminalEmitsRawChunksWithoutNewline(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"raw-terminal": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "chunk:%s" "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
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) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-raw",
Target: "raw-terminal",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "chunk:hello") {
t.Fatalf("expected raw chunk in deltas, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete {
t.Fatalf("expected complete event, got %s", last.Type)
}
}
func TestCLIStartPersistentTerminalUsesWidePTY(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"size-terminal": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do stty size; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
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) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-size",
Target: "size-terminal",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "720 1024") {
t.Fatalf("expected wide PTY size in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentClaudeTUIFiltersTerminalChrome(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\033[2C\r\033[7A\342\227\217\033[1Cmessage:%s\r\n\302\267 Cogitating... (1s, 1 tokens)\r\n\342\235\257 ' "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
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) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-filter",
Target: "claude-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
combined := testutil.CollectDeltas(events)
if combined != "message:hello" {
t.Fatalf("expected only assistant message delta, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" {
t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message)
}
}
func TestCLIExecutePersistentClaudeTUIStreamsLineBeforeIdle(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf 'sud fragment\r\n\342\234\242 Dilly-dallying\342\200\246\r\n* Dilly-dallying\342\200\246\r\n'; sleep 0.05; printf '\342\227\217 sudo runs commands\r\n\342\235\257 '; sleep 0.15; printf '\r\033[2A\033[2K\342\227\217 sudo runs commands with elevated privileges.\r\n\342\235\257 '; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 300,
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) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-growing",
Target: "claude-tui",
Input: map[string]any{"prompt": "explain sudo"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
combined := testutil.CollectDeltas(events)
if combined != "sudo runs commands with elevated privileges." {
t.Fatalf("expected full growing message, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" {
t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message)
}
}
func TestCLIExecutePersistentClaudeTUIWaitsForPromptReadyAfterPartialOutput(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\342\227\217 first chunk\r\n\342\234\242 Dilly-dallying\342\200\246\r\n'; sleep 0.15; printf 'second chunk\r\n\342\235\257 '; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 60,
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) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-delayed-tail",
Target: "claude-tui",
Input: map[string]any{"prompt": "write two chunks"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
combined := testutil.CollectDeltas(events)
if !strings.Contains(combined, "first chunk") ||
!strings.Contains(combined, "second chunk") ||
strings.Index(combined, "first chunk") > strings.Index(combined, "second chunk") {
t.Fatalf("expected full delayed message, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" {
t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message)
}
}
func TestCLIExecutePersistentClaudeTUIDoesNotCompleteOnStatusOnlyIdle(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '3\r\n\r\nThundering\342\200\2466\r\n'; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 60,
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) }()
runCtx, cancel := context.WithTimeout(ctx, 220*time.Millisecond)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-status-only",
Target: "claude-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err == nil {
t.Fatal("execute completed successfully before any assistant message")
}
for _, event := range sink.Events() {
if event.Type == noderuntime.EventTypeDelta {
t.Fatalf("status-only output should not be emitted as delta: %q", event.Delta)
}
if event.Type == noderuntime.EventTypeComplete {
t.Fatalf("status-only output should not emit complete: %+v", event)
}
}
}