iop/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go
toki 77ab8c294c feat: codex-app-server streaming migration and node CLI improvements
- Add codex_app_server adapter for streaming migration
- Update node CLI adapters with persistent execution support
- Add output filter for blackbox filtering
- Update agent-roadmap with codex-app-server-streaming-migration milestone
- Add agent-task for codex-app-server-streaming-migration
- Update edge.yaml configuration
2026-06-13 06:05:41 +09:00

1462 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cli_test
import (
"context"
"errors"
"fmt"
"os"
osexec "os/exec"
"strings"
"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/go/config"
)
func TestCLIExecutePersistentWaitsForSlowFirstOutput(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.2; 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, 1*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "slow-echo",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
if events[len(events)-1].Type != noderuntime.EventTypeComplete {
t.Fatalf("expected last event to be complete, got %q", events[len(events)-1].Type)
}
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") {
t.Fatalf("expected reply:hello in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentProcessExitReturnsError(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"exit-on-input": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "before-exit\n"; exit 2; 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) }()
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "exit-on-input",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err == nil {
t.Fatal("expected non-nil error when persistent process exits")
}
errStr := err.Error()
t.Logf("Got error: %q", errStr)
if !strings.Contains(errStr, "before-exit") {
t.Errorf("expected error to contain recent output 'before-exit', got %q", errStr)
}
if !strings.Contains(errStr, "exit status 2") {
t.Errorf("expected error to contain exit status 'exit status 2', got %q", errStr)
}
if !strings.Contains(errStr, "target=exit-on-input") {
t.Errorf("expected error to contain target=exit-on-input, got %q", errStr)
}
events := sink.Events()
var errorEvent noderuntime.RuntimeEvent
var hasError bool
for _, e := range events {
if e.Type == noderuntime.EventTypeError {
hasError = true
errorEvent = e
}
}
if !hasError {
t.Fatal("expected error event in emitted events")
}
if !strings.Contains(errorEvent.Error, "before-exit") {
t.Errorf("expected error event to contain recent output, got %q", errorEvent.Error)
}
if len(events) > 0 && events[len(events)-1].Type == noderuntime.EventTypeComplete {
t.Fatal("last event should not be complete when process exits unexpectedly")
}
}
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: 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-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: 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-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, 2*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, 2*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 TestRawTUIHelperProcess(t *testing.T) {
switch {
case os.Getenv("IOP_RAW_TUI_HELPER") == "1":
runRawTUIHelper()
case os.Getenv("IOP_CLAUDE_WARNING_HELPER") == "1":
runClaudeWarningHelper()
case os.Getenv("IOP_CLAUDE_TRUST_WARNING_HELPER") == "1":
runClaudeTrustAndWarningHelper()
case os.Getenv("IOP_CLAUDE_RATE_LIMIT_HELPER") == "1":
runClaudeRateLimitHelper()
case os.Getenv("IOP_CLAUDE_READY_REPLAY_HELPER") == "1":
runClaudeReadyReplayHelper()
default:
return
}
}
func setRawTerminal() {
cmd := osexec.Command("stty", "raw", "-echo")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "stty raw: %v\n", err)
os.Exit(2)
}
}
func runRawTUIHelper() {
setRawTerminal()
readPromptAndReply()
}
func runClaudeWarningHelper() {
setRawTerminal()
fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n")
_ = os.Stdout.Sync()
want := []byte{'\x1b', '[', 'B', '\r'}
seen := make([]byte, 0, len(want))
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
seen = append(seen, buf[0])
if len(seen) > len(want) {
seen = seen[len(seen)-len(want):]
}
if string(seen) == string(want) {
fmt.Fprint(os.Stdout, "ready\r\n")
_ = os.Stdout.Sync()
break
}
}
if err != nil {
os.Exit(0)
}
}
readPromptAndReply()
}
func runClaudeTrustAndWarningHelper() {
setRawTerminal()
fmt.Fprint(os.Stdout, "Quick safety check: Is this a project you created or one you trust?\r\n 1. Yes, I trust this folder\r\n2. No, exit\r\n")
_ = os.Stdout.Sync()
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if n > 0 && buf[0] == '\r' {
break
}
if err != nil {
os.Exit(0)
}
}
fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n")
_ = os.Stdout.Sync()
want := []byte{'\x1b', '[', 'B', '\r'}
seen := make([]byte, 0, len(want))
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
seen = append(seen, buf[0])
if len(seen) > len(want) {
seen = seen[len(seen)-len(want):]
}
if string(seen) == string(want) {
fmt.Fprint(os.Stdout, "ready\r\n")
_ = os.Stdout.Sync()
break
}
}
if err != nil {
os.Exit(0)
}
}
readPromptAndReply()
}
func runClaudeRateLimitHelper() {
setRawTerminal()
for {
buf := make([]byte, 1)
n, err := os.Stdin.Read(buf)
if n > 0 && buf[0] == '\r' {
fmt.Fprint(os.Stdout, "\u25cf \u23bf You've hit your session limit \u00b7 resets 12:50pm (Asia/Seoul)\r\n")
fmt.Fprint(os.Stdout, "What do you want to do?\r\n 1. Stop and wait for limit to reset\r\n2. Upgrade your plan\r\n")
_ = os.Stdout.Sync()
}
if err != nil {
os.Exit(0)
}
}
}
func runClaudeReadyReplayHelper() {
setRawTerminal()
fmt.Fprint(os.Stdout, "Quick safety check: Is this a project you created or one you trust?\r\n 1. Yes, I trust this folder\r\n2. No, exit\r\n")
_ = os.Stdout.Sync()
_ = readRawLine()
time.Sleep(150 * time.Millisecond)
_ = readRawLine()
fmt.Fprint(os.Stdout, "\r\n ")
_ = os.Stdout.Sync()
prompt := readRawLine()
fmt.Fprintf(os.Stdout, "\r\n\u25cf replay:%s\r\n\u276f ", prompt)
_ = os.Stdout.Sync()
buf := make([]byte, 1)
for {
if _, err := os.Stdin.Read(buf); err != nil {
os.Exit(0)
}
}
}
func readRawLine() string {
var line []byte
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if buf[0] == '\r' || buf[0] == '\n' {
return string(line)
}
line = append(line, buf[0])
}
if err != nil {
os.Exit(0)
}
}
}
func readPromptAndReply() {
var prompt []byte
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if buf[0] == '\r' {
fmt.Fprintf(os.Stdout, "reply:%s\n", string(prompt))
_ = os.Stdout.Sync()
prompt = prompt[:0]
} else {
prompt = append(prompt, buf[0])
}
}
if err != nil {
os.Exit(0)
}
}
}
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)
}
}
}
// 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 TestCLIExecutePersistentConsecutiveExecutes(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"consecutive-echo": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$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) }()
sink1 := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "consecutive-echo",
Input: map[string]any{"prompt": "first"},
}, sink1)
if err != nil {
t.Fatalf("first execute: %v", err)
}
events1 := sink1.Events()
if events1[len(events1)-1].Type != noderuntime.EventTypeComplete {
t.Fatalf("first run: expected last event to be complete, got %q", events1[len(events1)-1].Type)
}
sink2 := &testutil.FakeSink{}
err = c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-2",
Target: "consecutive-echo",
Input: map[string]any{"prompt": "second"},
}, sink2)
if err != nil {
t.Fatalf("second execute: %v", err)
}
events2 := sink2.Events()
if events2[len(events2)-1].Type != noderuntime.EventTypeComplete {
t.Fatalf("second run: expected last event to be complete, got %q", events2[len(events2)-1].Type)
}
for _, e := range events2 {
if e.Type == noderuntime.EventTypeDelta && strings.Contains(e.Delta, "first") {
t.Fatalf("second run received first run's delta (session contamination): %q", e.Delta)
}
}
}
func TestCLIExecutePersistentCreatesIndependentSessionsForSameProfile(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"multi-echo": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 200,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
sinkA := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-a",
Target: "multi-echo",
SessionID: "session-a",
Input: map[string]any{"prompt": "alpha"},
}, sinkA)
if err != nil {
t.Fatalf("session-a execute: %v", err)
}
sinkB := &testutil.FakeSink{}
err = c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-b",
Target: "multi-echo",
SessionID: "session-b",
Input: map[string]any{"prompt": "beta"},
}, sinkB)
if err != nil {
t.Fatalf("session-b execute: %v", err)
}
if combined := testutil.CollectDeltas(sinkA.Events()); !strings.Contains(combined, "alpha") {
t.Fatalf("session-a: expected alpha in deltas, got %q", combined)
}
if combined := testutil.CollectDeltas(sinkB.Events()); !strings.Contains(combined, "beta") {
t.Fatalf("session-b: expected beta in deltas, got %q", combined)
}
if combined := testutil.CollectDeltas(sinkA.Events()); strings.Contains(combined, "beta") {
t.Fatalf("session-a contaminated with session-b output: %q", combined)
}
}
func TestCLIExecutePersistentRequireExistingSessionFailsWhenMissing(t *testing.T) {
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"echo-req": {
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()
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "echo-req",
SessionID: "nonexistent",
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "hello"},
}, sink)
if err == nil {
t.Fatal("expected error for require-existing with missing session")
}
if strings.Contains(err.Error(), "start") {
t.Fatalf("should not have started a new process, got: %v", err)
}
}
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, 1*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, 1*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, 1*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, 1*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)
}
}
func TestCLIExecutePersistentMaintainsHundredLogicalSessions(t *testing.T) {
if testing.Short() {
t.Skip("skipping heavy session test in short mode")
}
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"hundred-echo": {
Command: "sh",
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: false,
ResponseIdleTimeoutMS: 300,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
const n = 20
for i := 0; i < n; i++ {
sessionID := "session-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26))
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-first-" + sessionID,
Target: "hundred-echo",
SessionID: sessionID,
Input: map[string]any{"prompt": "ping-" + sessionID},
}, sink)
if err != nil {
t.Fatalf("session %q first execute: %v", sessionID, err)
}
}
// Second round — all sessions must still be alive.
for i := 0; i < n; i++ {
sessionID := "session-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26))
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-second-" + sessionID,
Target: "hundred-echo",
SessionID: sessionID,
Input: map[string]any{"prompt": "pong-" + sessionID},
}, sink)
if err != nil {
t.Fatalf("session %q second execute: %v", sessionID, err)
}
}
_ = c.Stop(ctx)
}