- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리 - 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16) - finite 재연결 시도 고갈 시 노드 종료 - CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
613 lines
18 KiB
Go
613 lines
18 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,
|
|
// This scenario asserts the raw "\r" terminator, so the prompt must be
|
|
// written only after the helper has entered raw mode. The readiness
|
|
// marker anchors the drain to that point; the timeout must exceed the
|
|
// re-exec'd helper's worst-case cold start (~700ms observed) so the
|
|
// drain never settles before the marker arrives.
|
|
StartupIdleTimeoutMS: 800,
|
|
},
|
|
},
|
|
}
|
|
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) }()
|
|
|
|
// Generous bound: the startup drain alone is 800ms (see profile) and process
|
|
// spawn is slow and variable in constrained CI, so a 1s cap would race the run
|
|
// to completion. This still asserts completion, not a timeout.
|
|
execCtx, cancel := context.WithTimeout(ctx, 5*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,
|
|
// The startup drain must observe the bypass-warning screen (and
|
|
// auto-accept it) before the prompt is sent; cover the re-exec'd
|
|
// helper's worst-case cold start, which is larger under -race.
|
|
StartupIdleTimeoutMS: 800,
|
|
},
|
|
},
|
|
}
|
|
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,
|
|
// The startup drain must observe the trust + bypass-warning screens
|
|
// (and auto-accept them) before the prompt is sent; cover the
|
|
// re-exec'd helper's worst-case cold start, which is larger under -race.
|
|
StartupIdleTimeoutMS: 800,
|
|
},
|
|
},
|
|
}
|
|
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,
|
|
// Cover the re-exec'd helper's variable cold start so the startup
|
|
// drain waits for its readiness marker before the prompt is sent.
|
|
StartupIdleTimeoutMS: 300,
|
|
},
|
|
},
|
|
}
|
|
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,
|
|
// Cover the re-exec'd helper's variable cold start so the startup
|
|
// drain observes the trust screen and auto-accepts it before the
|
|
// prompt is sent, keeping the replay-after-ready path deterministic.
|
|
StartupIdleTimeoutMS: 300,
|
|
},
|
|
},
|
|
}
|
|
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",
|
|
// The frame begins with cursor-home + erase-display, mirroring a real
|
|
// claude TUI redraw, so the rendered assistant screen is independent
|
|
// of whether the prompt raced `stty -echo` and got echoed: any echoed
|
|
// characters are erased before the assistant line is drawn.
|
|
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\033[H\033[2J\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)
|
|
}
|
|
}
|
|
}
|