feat(node/cli): improve persistent session handling and Claude terminal support

- Add writePrompt with character-by-character typing for terminal profiles (2ms delay)
- Separate cliOutput into text and markerLine fields for precise completion matching
- Add consumeCompleteLines for buffered output parsing
- Add matchAny for multi-line completion marker checking
- Auto-accept Claude Code Bypass Permissions warning at startup
- Change PTY output reader from bufio.Scanner to ptmx.Read for raw byte output
- Improve drainUntilIdle with profile info and Claude bypass warning handling
- Add Claude status raw output fallback when usage parsing fails
- Add comprehensive blackbox tests for persistent sessions (21 test cases)
- Update e2e smoke test with proper wait functions, mock CLI script, and timeout variables
- Update edge.yaml console target from gemini to claude-tui, increase timeouts
- Update web app: add dev documentation, verify script, engines field, test message
This commit is contained in:
toki 2026-05-18 14:22:58 +09:00
parent ed882cf6a4
commit 1479949db0
17 changed files with 669 additions and 40 deletions

View file

@ -27,13 +27,14 @@ import (
const Name = "cli"
const (
modeCodexExec = "codex-exec"
modeOpencodeSSE = "opencode-sse"
modePersistentLazy = "persistent-lazy"
modeCodexExec = "codex-exec"
modeOpencodeSSE = "opencode-sse"
modePersistentLazy = "persistent-lazy"
)
type cliOutput struct {
line string
text string
markerLine string
}
// sessionKey uniquely identifies a logical worker session.

View file

@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"time"
"unicode"
"github.com/creack/pty"
"go.uber.org/zap"
@ -17,6 +18,8 @@ import (
"iop/packages/config"
)
const terminalInputDelay = 2 * time.Millisecond
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
@ -81,7 +84,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
Timestamp: time.Now(),
})
if _, err := fmt.Fprintf(sess.input, "%s\n", prompt); err != nil {
if err := writePrompt(ctx, sess.input, prompt, profile); err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err))
}
@ -93,6 +96,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
}
}()
outputTokens := 0
var markerBuf strings.Builder
for {
select {
@ -116,14 +120,20 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
c.mu.Unlock()
return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly")
}
outputTokens += len(strings.Fields(out.line))
outputTokens += len(strings.Fields(out.text))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: out.line + "\n",
Delta: out.text,
Timestamp: time.Now(),
})
if matcher.match(out.line) {
markerLines := []string(nil)
if out.markerLine != "" {
markerLines = append(markerLines, out.markerLine)
} else {
markerLines = consumeCompleteLines(&markerBuf, out.text)
}
if matcher.matchAny(markerLines) {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
@ -173,6 +183,68 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
}
}
func (m completionMatcher) matchAny(lines []string) bool {
for _, line := range lines {
if m.match(line) {
return true
}
}
return false
}
func consumeCompleteLines(buf *strings.Builder, text string) []string {
if text == "" {
return nil
}
buf.WriteString(text)
raw := buf.String()
start := 0
var lines []string
for i, r := range raw {
if r != '\n' {
continue
}
lines = append(lines, strings.TrimRight(raw[start:i], "\r"))
start = i + 1
}
if start > 0 {
buf.Reset()
buf.WriteString(raw[start:])
} else if len(raw) > 8192 {
buf.Reset()
buf.WriteString(raw[len(raw)-8192:])
}
return lines
}
func promptTerminator(profile config.CLIProfileConf) string {
if profile.Terminal {
return "\r"
}
return "\n"
}
func writePrompt(ctx context.Context, input io.Writer, prompt string, profile config.CLIProfileConf) error {
if !profile.Terminal {
_, err := io.WriteString(input, prompt+promptTerminator(profile))
return err
}
for _, r := range prompt {
if _, err := io.WriteString(input, string(r)); err != nil {
return err
}
timer := time.NewTimer(terminalInputDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
_, err := io.WriteString(input, promptTerminator(profile))
return err
}
// resolveSession returns an existing session for the given key or creates one
// when SessionMode allows it.
func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) {
@ -219,13 +291,18 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
input = ptmx
closeFn = ptmx.Close
go func() {
scanner := bufio.NewScanner(ptmx)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
outputCh <- cliOutput{line: line}
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if n > 0 {
outputCh <- cliOutput{text: string(buf[:n])}
}
if err != nil {
doneCh <- cmd.Wait()
close(outputCh)
return
}
}
doneCh <- cmd.Wait()
close(outputCh)
}()
} else {
cmd = exec.Command(profile.Command, profile.Args...)
@ -250,7 +327,8 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
outputCh <- cliOutput{line: scanner.Text()}
line := scanner.Text()
outputCh <- cliOutput{text: line + "\n", markerLine: line}
}
doneCh <- cmd.Wait()
close(outputCh)
@ -258,7 +336,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
}
if profile.StartupIdleTimeoutMS > 0 {
drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key.target)
drainUntilIdle(outputCh, input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile)
}
select {
@ -286,15 +364,28 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
}, nil
}
func drainUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, name string) {
func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) {
timer := time.NewTimer(timeout)
defer timer.Stop()
var startupBuf strings.Builder
acceptedClaudeBypassWarning := false
for {
select {
case _, ok := <-outputCh:
case out, ok := <-outputCh:
if !ok {
return
}
if profile.Terminal && !acceptedClaudeBypassWarning && out.text != "" {
appendBounded(&startupBuf, out.text, 8192)
if shouldAcceptClaudeBypassWarning(startupBuf.String()) {
if _, err := io.WriteString(input, "\x1b[B\r"); err != nil {
logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err))
} else {
acceptedClaudeBypassWarning = true
logger.Info("cli adapter: accepted claude bypass warning", zap.String("target", key.target))
}
}
}
if !timer.Stop() {
select {
case <-timer.C:
@ -302,13 +393,50 @@ func drainUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *za
}
}
timer.Reset(timeout)
logger.Debug("cli adapter: startup drain", zap.String("profile", name))
logger.Debug("cli adapter: startup drain", zap.String("target", key.target), zap.String("session", key.sessionID))
case <-timer.C:
return
}
}
}
func appendBounded(buf *strings.Builder, s string, max int) {
buf.WriteString(s)
raw := buf.String()
if len(raw) <= max {
return
}
buf.Reset()
buf.WriteString(raw[len(raw)-max:])
}
func shouldAcceptClaudeBypassWarning(raw string) bool {
compact := compactTerminalText(raw)
return strings.Contains(compact, "claudecoderunninginbypasspermissionsmode") &&
strings.Contains(compact, "yesiaccept")
}
func compactTerminalText(s string) string {
var b strings.Builder
inEscape := false
for _, r := range s {
if r == '\x1b' {
inEscape = true
continue
}
if inEscape {
if r >= '@' && r <= '~' {
inEscape = false
}
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return b.String()
}
func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, key sessionKey) {
timer := time.NewTimer(timeout)
defer timer.Stop()

View file

@ -2,6 +2,9 @@ package cli_test
import (
"context"
"fmt"
"os"
osexec "os/exec"
"strings"
"testing"
"time"
@ -164,6 +167,224 @@ func TestCLIStartPersistentTerminalAndExecuteWritesPrompt(t *testing.T) {
}
}
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 TestRawTUIHelperProcess(t *testing.T) {
switch {
case os.Getenv("IOP_RAW_TUI_HELPER") == "1":
runRawTUIHelper()
case os.Getenv("IOP_CLAUDE_WARNING_HELPER") == "1":
runClaudeWarningHelper()
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 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)
}
}
// TestCLIExecutePersistentCancelDoesNotTerminateSession verifies that a
// cancelled run leaves the session alive so subsequent runs can reuse it.
func TestCLIExecutePersistentCancelDoesNotTerminateSession(t *testing.T) {

View file

@ -151,6 +151,9 @@ func (c *ClaudeChecker) Check(ctx context.Context) (*UsageStatus, error) {
}
}
if err := fullUsageWait(60 * time.Second); err != nil {
if st, parseErr := ParseStatusOutput(fullOutput); parseErr == nil && st != nil && strings.TrimSpace(st.RawOutput) != "" {
return st, nil
}
return nil, fmt.Errorf("failed waiting for usage output: %w", err)
}

View file

@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@ -125,6 +126,48 @@ exit 0
}
}
func TestClaudeCheckerReturnsRawFallbackWhenUsageHasNoLimitBlock(t *testing.T) {
dir := t.TempDir()
fakeClaude := filepath.Join(dir, "claude")
script := `#!/usr/bin/env sh
printf 'Claude Code v2.1.142\n'
printf '? for shortcuts\n'
sleep 0.1
IFS= read -r command
cmd="${command#?}"
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
printf 'unexpected command: %%s\n' "$command"
exit 3
fi
printf 'Last 24h · these are independent characteristics of your usage, not a breakdown\n'
printf 'Nothing over 10%% in this period try the other window.\n'
printf 'd to day · w to week\n'
printf 'Esc to cancel\n'
exit 0
`
if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
checker := NewClaudeChecker(fakeClaude)
status, err := checker.Check(ctx)
if err != nil {
t.Fatalf("Check failed: %v", err)
}
if status.DailyLimit != "" || status.WeeklyLimit != "" {
t.Fatalf("expected no parsed limits, got daily=%q weekly=%q", status.DailyLimit, status.WeeklyLimit)
}
if !strings.Contains(status.RawOutput, "Nothing over 10%") {
t.Fatalf("expected raw fallback output, got %q", status.RawOutput)
}
}
func TestClaudeCheckerSelectsUsageAndParsesRepaintedScreen(t *testing.T) {
dir := t.TempDir()
fakeClaude := filepath.Join(dir, "claude")

View file

@ -6,14 +6,61 @@ Control Plane과의 주요 통신은 edge-node에서 사용 중인 protobuf-sock
## Local
웹 개발과 테스트는 기본적으로 호스트 환경에서 수행한다. Docker는 컨테이너 패키징과 compose 통합 확인이 필요할 때 사용한다.
필요한 런타임:
- Node.js `>=20.9.0`
- npm `>=10`
- Go toolchain
루트에서 Control Plane을 먼저 실행한다.
```bash
npm install
npm run dev
go run ./apps/control-plane/cmd/control-plane serve --config configs/control-plane.yaml
```
다른 터미널에서 Web Portal을 실행한다.
```bash
npm ci --prefix apps/web
./bin/web.sh
```
기본 호스트 endpoint는 코드 기본값과 `configs/control-plane.yaml` 기준을 따른다.
```bash
CONTROL_PLANE_INTERNAL_HTTP_URL=http://localhost:9080
NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL=http://localhost:9080
NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL=tcp://localhost:19080
```
호스트 검증은 다음 명령을 기준으로 한다.
```bash
cd apps/web
npm run verify
```
현재 `npm run verify`는 TypeScript check와 production build를 수행한다. Next.js 16 기준 lint는 `next lint`가 아니라 ESLint CLI(`eslint .`)를 사용한다. 아직 이 앱에는 ESLint 의존성, 설정 파일, `lint` script가 없으므로 lint는 필수 검증에 포함하지 않는다.
lint를 도입할 때는 다음 기준을 따른다.
- `eslint`, `eslint-config-next`와 flat config(`eslint.config.*`)를 함께 추가한다.
- `package.json`에는 `"lint": "eslint ."`를 추가한다.
- `verify``npm run check && npm run lint && npm run build` 순서로 갱신한다.
production standalone 실행을 확인할 때는 먼저 build를 만든 뒤 실행한다.
```bash
cd apps/web
npm run build
PORT=3000 HOSTNAME=0.0.0.0 npm run start
```
## Docker
루트에서 실행한다.
Docker는 compose 기반 통합 확인이 필요할 때 루트에서 실행한다.
```bash
docker compose up --build

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -7,6 +7,10 @@
"": {
"name": "@iop/web",
"version": "0.1.0",
"engines": {
"node": ">=20.9.0",
"npm": ">=10"
},
"dependencies": {
"@radix-ui/react-slot": "1.2.4",
"class-variance-authority": "0.7.1",

View file

@ -2,12 +2,18 @@
"name": "@iop/web",
"version": "0.1.0",
"private": true,
"packageManager": "npm@10.8.2",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node .next/standalone/server.js",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"verify": "npm run check && npm run build"
},
"engines": {
"node": ">=20.9.0",
"npm": ">=10"
},
"dependencies": {
"@radix-ui/react-slot": "1.2.4",

View file

@ -43,6 +43,7 @@ export default async function Home() {
<p className="text-lg leading-8 text-muted-foreground">
IOP Web Portal을 Next.js . .
</p>
<p className="text-sm font-medium text-emerald-600"> .</p>
</div>
</div>

BIN
bin/control-plane Executable file

Binary file not shown.

BIN
bin/edge Executable file

Binary file not shown.

BIN
bin/node Executable file

Binary file not shown.

28
bin/web.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
WEB_DIR="$REPO_ROOT/apps/web"
WEB_HOST="${IOP_WEB_HOST:-0.0.0.0}"
WEB_PORT="${IOP_WEB_PORT:-3000}"
export CONTROL_PLANE_INTERNAL_HTTP_URL="${CONTROL_PLANE_INTERNAL_HTTP_URL:-http://localhost:9080}"
export NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL="${NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL:-http://localhost:9080}"
export NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL="${NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL:-tcp://localhost:19080}"
if [[ ! -d "$WEB_DIR/node_modules" ]]; then
echo "[web] missing dependencies in $WEB_DIR/node_modules" >&2
echo "[web] run: cd apps/web && npm ci" >&2
exit 1
fi
cd "$WEB_DIR"
echo "[web] dir=$WEB_DIR"
echo "[web] listen=$WEB_HOST:$WEB_PORT"
echo "[web] control_plane_internal_http=$CONTROL_PLANE_INTERNAL_HTTP_URL"
echo "[web] control_plane_public_http=$NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL"
echo "[web] control_plane_wire=$NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL"
exec npm run dev -- --hostname "$WEB_HOST" --port "$WEB_PORT"

BIN
bin/worker Executable file

Binary file not shown.

View file

@ -17,7 +17,7 @@ metrics:
console:
adapter: "cli"
target: "gemini"
target: "claude-tui"
session_id: "default"
background: false
timeout_sec: 300
@ -59,8 +59,8 @@ nodes:
- "TERM=xterm-256color"
persistent: true
terminal: true
response_idle_timeout_ms: 3000
startup_idle_timeout_ms: 1500
response_idle_timeout_ms: 8000
startup_idle_timeout_ms: 4000
mode: "persistent-lazy"
gemini:
command: "gemini"

View file

@ -22,13 +22,37 @@ EDGE_METRICS_PORT=$((40000 + RANDOM % 10000))
NODE_METRICS_PORT=$((50000 + RANDOM % 10000))
PROFILE="${IOP_E2E_PROFILE:-mock}"
IDLE_SECONDS="${IOP_E2E_IDLE_SECONDS:-0}"
REGISTER_TIMEOUT="${IOP_E2E_REGISTER_TIMEOUT:-60}"
EVENT_TIMEOUT="${IOP_E2E_EVENT_TIMEOUT:-30}"
RUN_TIMEOUT="${IOP_E2E_RUN_TIMEOUT:-120}"
STATUS_TIMEOUT="${IOP_E2E_STATUS_TIMEOUT:-$RUN_TIMEOUT}"
COMMAND_SETTLE_SECONDS="${IOP_E2E_COMMAND_SETTLE_SECONDS:-0.2}"
IS_PERSISTENT=0
HAS_STATUS=0
FIRST_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_alpha"
FIRST_EXPECTED="SMOKE_ALPHA"
SECOND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_beta"
SECOND_EXPECTED="SMOKE_BETA"
BACKGROUND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_bg"
BACKGROUND_EXPECTED="SMOKE_BG"
if [ "$PROFILE" = "mock" ]; then
echo "[e2e] preparing honest mock smoke test (using cli adapter + cat)..."
echo "[e2e] preparing honest mock smoke test (using scripted cli adapter)..."
IS_PERSISTENT=1
TARGET="fake-cat"
TARGET="fake-cli"
MOCK_CLI="$TMP_DIR/fake-cli.sh"
cat <<'EOF' > "$MOCK_CLI"
#!/usr/bin/env sh
while IFS= read -r line; do
case "$line" in
*iop_smoke_alpha*) printf 'IOP_SMOKE_ALPHA\n' ;;
*iop_smoke_beta*) printf 'IOP_SMOKE_BETA\n' ;;
*iop_smoke_bg*) printf 'IOP_SMOKE_BG\n' ;;
*) printf 'IOP_SMOKE_UNKNOWN\n' ;;
esac
done
EOF
chmod +x "$MOCK_CLI"
cat <<EOF > "$EDGE_CONFIG"
server:
listen: "127.0.0.1:$PORT"
@ -46,13 +70,13 @@ nodes:
cli:
enabled: true
profiles:
fake-cat:
command: "cat"
fake-cli:
command: "$MOCK_CLI"
persistent: true
response_idle_timeout_ms: 1000
console:
adapter: cli
target: fake-cat
target: fake-cli
session_id: default
EOF
else
@ -149,32 +173,147 @@ done
IOP_NODE_CONFIG="$NODE_CONFIG" "$REPO_ROOT/bin/node.sh" > "$NODE_OUT" 2>&1 &
NODE_PID=$!
LAST_CMD_START_LINE=1
send_cmd() {
echo "[e2e] > $1"
echo "$1" >&3
sleep 3
ensure_smoke_processes_running() {
local context="$1"
if ! kill -0 "$EDGE_PID" 2>/dev/null; then
echo "[e2e] edge exited while waiting for $context"
echo "=== EDGE OUTPUT ==="
cat "$EDGE_OUT"
exit 1
fi
if ! kill -0 "$NODE_PID" 2>/dev/null; then
echo "[e2e] node exited while waiting for $context"
echo "=== NODE OUTPUT ==="
cat "$NODE_OUT"
exit 1
fi
}
STRICT_PROMPT="Reply with exactly 'OK' and do not inspect files or use any tools."
wait_for_node_registration() {
local deadline
deadline=$((SECONDS + REGISTER_TIMEOUT))
sleep 3
echo "[e2e] waiting for node registration (timeout: ${REGISTER_TIMEOUT}s)"
while ! grep -q '\[node-test-node-event\] connected reason="registered"' "$EDGE_OUT"; do
ensure_smoke_processes_running "node registration"
if (( SECONDS >= deadline )); then
echo "[e2e] node registration timed out"
echo "=== EDGE OUTPUT ==="
cat "$EDGE_OUT"
echo "=== NODE OUTPUT ==="
cat "$NODE_OUT"
exit 1
fi
sleep 0.5
done
}
wait_for_edge_pattern_since() {
local pattern="$1"
local start_line="$2"
local msg="$3"
local timeout="${4:-$EVENT_TIMEOUT}"
local deadline
local slice
deadline=$((SECONDS + timeout))
slice="$TMP_DIR/edge_wait_slice"
while true; do
tail -n +"$start_line" "$EDGE_OUT" > "$slice" || true
if grep -qE "$pattern" "$slice"; then
return 0
fi
ensure_smoke_processes_running "$msg"
if (( SECONDS >= deadline )); then
echo "[e2e] FAIL: timed out waiting for $msg (pattern: '$pattern', timeout: ${timeout}s)"
echo "=== EDGE OUTPUT ==="
cat "$EDGE_OUT"
echo "=== NODE OUTPUT ==="
cat "$NODE_OUT"
exit 1
fi
sleep 0.5
done
}
wait_for_edge_text_since() {
local text="$1"
local start_line="$2"
local msg="$3"
local timeout="${4:-$EVENT_TIMEOUT}"
local deadline
local slice
deadline=$((SECONDS + timeout))
slice="$TMP_DIR/edge_wait_slice"
while true; do
tail -n +"$start_line" "$EDGE_OUT" > "$slice" || true
if grep -qF "$text" "$slice"; then
return 0
fi
ensure_smoke_processes_running "$msg"
if (( SECONDS >= deadline )); then
echo "[e2e] FAIL: timed out waiting for $msg (text: '$text', timeout: ${timeout}s)"
echo "=== EDGE OUTPUT ==="
cat "$EDGE_OUT"
echo "=== NODE OUTPUT ==="
cat "$NODE_OUT"
exit 1
fi
sleep 0.5
done
}
send_cmd() {
LAST_CMD_START_LINE=$(($(wc -l < "$EDGE_OUT" | tr -d ' ') + 1))
echo "[e2e] > $1"
echo "$1" >&3
sleep "$COMMAND_SETTLE_SECONDS"
}
wait_for_node_registration
send_cmd "/nodes"
wait_for_edge_text_since "test-node (test-node)" "$LAST_CMD_START_LINE" "/nodes output"
send_cmd "/capabilities"
wait_for_edge_text_since "[node-test-node-capabilities]" "$LAST_CMD_START_LINE" "/capabilities output"
wait_for_edge_text_since "targets = ${TARGET}" "$LAST_CMD_START_LINE" "/capabilities targets"
send_cmd "/transport"
send_cmd "$STRICT_PROMPT"
wait_for_edge_text_since "[node-test-node-transport]" "$LAST_CMD_START_LINE" "/transport output"
wait_for_edge_text_since "connected = true" "$LAST_CMD_START_LINE" "/transport connected status"
send_cmd "$FIRST_PROMPT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 1 start" "$RUN_TIMEOUT"
wait_for_edge_text_since "$FIRST_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 1 response" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 1 completion" "$RUN_TIMEOUT"
send_cmd "$SECOND_PROMPT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 2 start" "$RUN_TIMEOUT"
wait_for_edge_text_since "$SECOND_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 2 response" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 2 completion" "$RUN_TIMEOUT"
send_cmd "/session session2"
wait_for_edge_pattern_since "session .*session2" "$LAST_CMD_START_LINE" "/session acknowledgement"
send_cmd "/background on"
send_cmd "Reply with exactly 'OK' again."
wait_for_edge_pattern_since "background .*on" "$LAST_CMD_START_LINE" "/background on acknowledgement"
send_cmd "$BACKGROUND_PROMPT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=.*session=session2.*background=true" "$LAST_CMD_START_LINE" "background run start" "$RUN_TIMEOUT"
wait_for_edge_text_since "$BACKGROUND_EXPECTED" "$LAST_CMD_START_LINE" "background run response" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "background run completion" "$RUN_TIMEOUT"
send_cmd "/background off"
wait_for_edge_pattern_since "background .*off" "$LAST_CMD_START_LINE" "/background off acknowledgement"
send_cmd "/sessions"
wait_for_edge_text_since "[node-test-node-sessions]" "$LAST_CMD_START_LINE" "/sessions output"
if [ "$IS_PERSISTENT" -eq 1 ]; then
send_cmd "/terminate-session"
wait_for_edge_text_since "terminated session" "$LAST_CMD_START_LINE" "/terminate-session acknowledgement"
fi
if [ "$HAS_STATUS" -eq 1 ]; then
send_cmd "/status"
wait_for_edge_text_since "[node-test-node-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT"
fi
IDLE_FAIL=0
@ -249,6 +388,14 @@ check_fail_markers() {
check_grep "test-node" "$EDGE_OUT" "node registration not found"
check_grep "start run_id=" "$EDGE_OUT" "run start not found"
check_grep "complete run_id=" "$EDGE_OUT" "run completion not found"
if [ "$(grep -c "\\[node-test-node-event\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then
echo "[e2e] FAIL: expected at least 3 completed runs (foreground x2 + background x1)"
FAIL=1
fi
check_grep "$FIRST_EXPECTED" "$EDGE_OUT" "foreground run 1 response not found"
check_grep "$SECOND_EXPECTED" "$EDGE_OUT" "foreground run 2 response not found"
check_grep "$BACKGROUND_EXPECTED" "$EDGE_OUT" "background run response not found"
check_no_grep "\\[node-test-node-message\\] <empty>" "$EDGE_OUT" "empty node message found"
check_grep "\\[node-test-node-capabilities\\]" "$EDGE_OUT" "/capabilities output not found"
check_grep "targets = ${TARGET}" "$EDGE_OUT" "/capabilities targets not found"