From 05065ce668163b823a77982b0068fbd9ff438b45 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sat, 15 Aug 2026 03:47:45 -0700 Subject: [PATCH 1/5] fix(e2e): avoid duplicate OpenClaw PTY input Signed-off-by: Senthil Ravichandran --- test/e2e/live/launch-agent-turn.ts | 45 +++++++++++++--------- test/e2e/support/launch-agent-turn.test.ts | 22 ++++++++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/test/e2e/live/launch-agent-turn.ts b/test/e2e/live/launch-agent-turn.ts index 74a867c6a65..710bef89ba6 100644 --- a/test/e2e/live/launch-agent-turn.ts +++ b/test/e2e/live/launch-agent-turn.ts @@ -304,31 +304,39 @@ wait_for_turn_count() { fail_launch_session "launch did not record the required structured session turns" } +wait_for_launch_readiness() { + for _ in {1..1800}; do + if grep -aEq -- 'idle.*\|[[:space:]]*(gateway[[:space:]]+)?connected([^[:alpha:]]|$)' "$capture"; then + return 0 + fi + if ! kill -0 "$session_pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + fail_launch_session "launch did not report OpenClaw input readiness" +} + submit_turn() { local expected_turns="$1" local content="$2" local evidence_status - for _ in {1..90}; do - if ! kill -0 "$session_pid" 2>/dev/null; then - break + if ! kill -0 "$session_pid" 2>/dev/null || ! printf '%s\r' "$content" >&3; then + fail_launch_session "launch exited before PTY input was submitted" + fi + for _ in {1..180}; do + if session_evidence qualify-input "$expected_turns" "$content" >/dev/null 2>"$evidence_error"; then + return 0 + else + evidence_status=$? fi - if ! printf '%s\r' "$content" >&3; then + if [[ "$evidence_status" != 1 ]]; then + fail_launch_session "structured session input evidence was invalid or unavailable (status $evidence_status)" + fi + if ! kill -0 "$session_pid" 2>/dev/null; then break fi - for _ in {1..2}; do - if session_evidence qualify-input "$expected_turns" "$content" >/dev/null 2>"$evidence_error"; then - return 0 - else - evidence_status=$? - fi - if [[ "$evidence_status" != 1 ]]; then - fail_launch_session "structured session input evidence was invalid or unavailable (status $evidence_status)" - fi - if ! kill -0 "$session_pid" 2>/dev/null; then - break 2 - fi - sleep 1 - done + sleep 1 done fail_launch_session "launch did not record PTY input in the structured session" } @@ -368,6 +376,7 @@ if [[ "$capture_ready" != 1 ]]; then fail_launch_session "launch did not create a PTY diagnostic capture" fi +wait_for_launch_readiness submit_turn 1 "$NEMOCLAW_LAUNCH_FIRST_INPUT" wait_for_turn_count 1 submit_turn 2 "$NEMOCLAW_LAUNCH_SECOND_INPUT" diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index 0ca85b017cf..f3e99c4a9e4 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -25,9 +25,9 @@ import { type SessionRecords = Record; type FixtureMode = + | "buffered-before-ready" | "cleanup-failure" | "delayed-duplicate" - | "delayed-input" | "invalid-order" | "nonzero" | "nonzero-cleanup-failure" @@ -162,11 +162,16 @@ const append = (role, content) => fs.appendFileSync( ); (async () => { - if (mode === "delayed-input") { - await ask(); + const bufferedInputs = []; + if (mode === "buffered-before-ready") { + const bufferInput = (line) => bufferedInputs.push(line); + rl.on("line", bufferInput); + await new Promise((resolve) => setTimeout(resolve, 3500)); + rl.off("line", bufferInput); } + process.stdout.write("idle | connected\n"); - const first = await ask(); + const first = bufferedInputs.shift() ?? await ask(); if (mode === "invalid-order") { append("assistant", "response before input"); append("user", first); @@ -176,6 +181,11 @@ const append = (role, content) => fs.appendFileSync( append("assistant", "first response"); } + for (const duplicate of bufferedInputs) { + append("user", duplicate); + append("assistant", "duplicate response"); + } + if (mode === "delayed-duplicate") { await new Promise((resolve) => setTimeout(resolve, 1500)); append("user", first); @@ -384,10 +394,10 @@ it.runIf(process.platform === "linux")( ); it.runIf(process.platform === "linux")( - "retries PTY input until structured user evidence is recorded (#9160)", + "does not queue duplicate PTY input before OpenClaw is ready (#9160)", () => { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( - "delayed-input", + "buffered-before-ready", "plain", ); From 149a10987f17db32369ccd7da8eab48e9bfe8360 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sat, 15 Aug 2026 04:56:06 -0700 Subject: [PATCH 2/5] fix(e2e): gate PTY input on TUI input mode Signed-off-by: Senthil Ravichandran --- test/e2e/live/launch-agent-turn.ts | 154 ++++++++++------- test/e2e/support/launch-agent-turn.test.ts | 185 ++++++++++++--------- 2 files changed, 200 insertions(+), 139 deletions(-) diff --git a/test/e2e/live/launch-agent-turn.ts b/test/e2e/live/launch-agent-turn.ts index 710bef89ba6..2685e6f225c 100644 --- a/test/e2e/live/launch-agent-turn.ts +++ b/test/e2e/live/launch-agent-turn.ts @@ -13,10 +13,11 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; // baseline. Session content never moves to the host. export const OPENCLAW_SESSION_EVIDENCE_SCRIPT = String.raw` const crypto = require("node:crypto"); +const childProcess = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); -const [mode, sessionRoot, baselinePath, expectedTurnsText, expectedInput] = process.argv.slice(1); +const [mode, sessionRoot, baselinePath, expectedTurnsText] = process.argv.slice(1); function finish(exitCode, reason, detail = {}) { if (reason) process.stderr.write(JSON.stringify({ reason, ...detail }) + "\n"); @@ -43,6 +44,57 @@ function sessionFileNames() { } } +function openClawTuiProcessIds() { + let names; + try { + names = fs.readdirSync("/proc"); + } catch { + finish(2, "process_table_unreadable"); + } + const pids = []; + for (const name of names) { + if (!/^\d+$/.test(name)) continue; + let args; + try { + args = fs + .readFileSync(path.join("/proc", name, "cmdline")) + .toString("utf8") + .split("\0") + .filter(Boolean); + } catch { + continue; + } + if (!args.includes("tui")) continue; + if (!args.some((arg) => ["openclaw", "openclaw.mjs"].includes(path.basename(arg)))) continue; + pids.push(name); + } + return pids; +} + +function qualifyTuiInputMode() { + const pids = openClawTuiProcessIds(); + if (pids.length === 0) finish(1); + if (pids.length > 1) finish(2, "multiple_tui_processes"); + let ttyPath; + try { + ttyPath = fs.realpathSync(path.join("/proc", pids[0], "fd", "0")); + } catch { + finish(2, "tui_stdin_unavailable"); + } + if (!/^\/dev\/pts\/\d+$/.test(ttyPath)) finish(2, "tui_stdin_not_pty"); + let state; + try { + state = childProcess.execFileSync("stty", ["-F", ttyPath, "-a"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + finish(2, "tui_termios_unavailable"); + } + if (!/(^|[\s;])-icanon([\s;]|$)/.test(state)) finish(1); + finish(0); +} + function readCompleteSession(fileName) { let raw; try { @@ -108,13 +160,6 @@ function hasStructuredContent(message) { return Array.isArray(message.content) && message.content.length > 0; } -function containsExactInput(message, input) { - if (typeof message.content === "string") return message.content === input; - return Array.isArray(message.content) && message.content.some((part) => - part && typeof part === "object" && typeof part.text === "string" && part.text === input - ); -} - function appendedMessages(fileName, baseline) { const { offset, complete, raw } = readCompleteSession(fileName); const prior = baseline[fileName]; @@ -139,23 +184,16 @@ function appendedMessages(fileName, baseline) { if (!record || record.type !== "message" || !record.message) continue; const role = record.message.role; if (role !== "user" && role !== "assistant") continue; - messages.push({ - role, - hasStructuredContent: hasStructuredContent(record.message), - containsExpectedInput: containsExactInput(record.message, expectedInput), - }); + messages.push({ role, hasStructuredContent: hasStructuredContent(record.message) }); } return messages; } -function qualifyTurns(requireAssistant) { +function qualifyTurns() { const expectedTurns = Number(expectedTurnsText); if (!Number.isSafeInteger(expectedTurns) || expectedTurns < 1) { finish(2, "expected_turn_count_invalid"); } - if (!requireAssistant && (!expectedInput || expectedInput.includes("\n") || expectedInput.includes("\r"))) { - finish(2, "expected_input_invalid"); - } const baseline = readBaseline(); const currentFiles = sessionFileNames(); @@ -183,18 +221,14 @@ function qualifyTurns(requireAssistant) { } if (!message.hasStructuredContent) finish(2, "message_content_empty", { sessionId }); } - const requiredMessages = expectedRoles.length - (requireAssistant ? 0 : 1); - if (messages.length < requiredMessages) finish(1); - if (!requireAssistant && !messages[requiredMessages - 1].containsExpectedInput) { - finish(2, "input_content_mismatch", { sessionId }); - } + if (messages.length < expectedRoles.length) finish(1); finish(0); } try { if (mode === "baseline") recordBaseline(); - if (mode === "qualify") qualifyTurns(true); - if (mode === "qualify-input") qualifyTurns(false); + if (mode === "input-mode") qualifyTuiInputMode(); + if (mode === "qualify") qualifyTurns(); } catch { finish(2, "verifier_failed"); } @@ -212,6 +246,7 @@ evidence_error="$session_dir/session-evidence.err" input="$session_dir/input" baseline_path="/tmp/nemoclaw-launch-session-$NEMOCLAW_LAUNCH_RUN_ID.json" session_pid="" +session_deadline="" remove_session_baseline() { "$NEMOCLAW_OPENSHELL_COMMAND" sandbox exec \ @@ -267,27 +302,33 @@ fail_launch_session() { session_evidence() { local mode="$1" local expected_turns="" - local expected_input="" + local command_timeout=10 if [[ "$#" -gt 1 ]]; then expected_turns="$2" fi - if [[ "$#" -gt 2 ]]; then - expected_input="$3" + if [[ -n "$session_deadline" ]]; then + local remaining=$((session_deadline - SECONDS)) + if (( remaining <= 0 )); then + return 1 + fi + if (( remaining < command_timeout )); then + command_timeout="$remaining" + fi fi - "$NEMOCLAW_OPENSHELL_COMMAND" sandbox exec \ + timeout --kill-after=1s "$command_timeout"s \ + "$NEMOCLAW_OPENSHELL_COMMAND" sandbox exec \ --name "$NEMOCLAW_LAUNCH_SANDBOX" -- \ node -e "$NEMOCLAW_LAUNCH_SESSION_EVIDENCE_SCRIPT" \ "$mode" \ "$NEMOCLAW_LAUNCH_SESSION_ROOT" \ "$baseline_path" \ - "$expected_turns" \ - "$expected_input" + "$expected_turns" } wait_for_turn_count() { local expected_turns="$1" local evidence_status - for _ in {1..180}; do + while (( SECONDS < session_deadline )); do if session_evidence qualify "$expected_turns" >/dev/null 2>"$evidence_error"; then return 0 else @@ -304,41 +345,23 @@ wait_for_turn_count() { fail_launch_session "launch did not record the required structured session turns" } -wait_for_launch_readiness() { - for _ in {1..1800}; do - if grep -aEq -- 'idle.*\|[[:space:]]*(gateway[[:space:]]+)?connected([^[:alpha:]]|$)' "$capture"; then - return 0 - fi - if ! kill -0 "$session_pid" 2>/dev/null; then - break - fi - sleep 0.1 - done - fail_launch_session "launch did not report OpenClaw input readiness" -} - -submit_turn() { - local expected_turns="$1" - local content="$2" +wait_for_pty_input_mode() { local evidence_status - if ! kill -0 "$session_pid" 2>/dev/null || ! printf '%s\r' "$content" >&3; then - fail_launch_session "launch exited before PTY input was submitted" - fi - for _ in {1..180}; do - if session_evidence qualify-input "$expected_turns" "$content" >/dev/null 2>"$evidence_error"; then + while (( SECONDS < session_deadline )); do + if session_evidence input-mode >/dev/null 2>"$evidence_error"; then return 0 else evidence_status=$? fi if [[ "$evidence_status" != 1 ]]; then - fail_launch_session "structured session input evidence was invalid or unavailable (status $evidence_status)" + fail_launch_session "OpenClaw TUI input-mode evidence was invalid or unavailable (status $evidence_status)" fi if ! kill -0 "$session_pid" 2>/dev/null; then break fi - sleep 1 + sleep 0.1 done - fail_launch_session "launch did not record PTY input in the structured session" + fail_launch_session "launch PTY did not enter input mode before the session deadline" } if ! session_evidence baseline >/dev/null 2>"$evidence_error"; then @@ -360,9 +383,11 @@ timeout --kill-after=5s 250s \ <"$input" >/dev/null 2>"$driver_error" & session_pid=$! exec 3>"$input" +session_budget_seconds="$NEMOCLAW_LAUNCH_SESSION_BUDGET_SECONDS" +session_deadline=$((SECONDS + session_budget_seconds)) capture_ready=0 -for _ in {1..100}; do +while (( SECONDS < session_deadline )); do if [[ -f "$capture" ]]; then capture_ready=1 break @@ -376,10 +401,14 @@ if [[ "$capture_ready" != 1 ]]; then fail_launch_session "launch did not create a PTY diagnostic capture" fi -wait_for_launch_readiness -submit_turn 1 "$NEMOCLAW_LAUNCH_FIRST_INPUT" +wait_for_pty_input_mode +if ! printf '%s\r' "$NEMOCLAW_LAUNCH_FIRST_INPUT" >&3; then + fail_launch_session "launch exited before the first PTY input was submitted" +fi wait_for_turn_count 1 -submit_turn 2 "$NEMOCLAW_LAUNCH_SECOND_INPUT" +if ! printf '%s\r' "$NEMOCLAW_LAUNCH_SECOND_INPUT" >&3; then + fail_launch_session "launch exited before the second PTY input was submitted" +fi wait_for_turn_count 2 if [[ -n "$NEMOCLAW_LAUNCH_EXIT_COMMAND" ]]; then @@ -407,6 +436,12 @@ if [[ "$launch_status" != 0 ]]; then terminal_diagnostic exit "$launch_status" fi +if session_evidence qualify 2 >/dev/null 2>"$evidence_error"; then + : +else + evidence_status=$? + fail_launch_session "launch final structured session evidence did not qualify (status $evidence_status)" +fi if ! remove_session_baseline >/dev/null 2>"$evidence_error"; then fail_launch_session "launch could not remove the structured session baseline" fi @@ -449,6 +484,7 @@ export async function runOpenClawLaunchSession( NEMOCLAW_LAUNCH_FIRST_INPUT: inputs.first, NEMOCLAW_LAUNCH_RUN_ID: randomUUID().replaceAll("-", ""), NEMOCLAW_LAUNCH_SANDBOX: options.sandboxName, + NEMOCLAW_LAUNCH_SESSION_BUDGET_SECONDS: "230", NEMOCLAW_LAUNCH_SECOND_INPUT: inputs.second, NEMOCLAW_LAUNCH_SESSION_EVIDENCE_SCRIPT: OPENCLAW_SESSION_EVIDENCE_SCRIPT, NEMOCLAW_LAUNCH_SESSION_ROOT: "/sandbox/.openclaw/agents/main/sessions", diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index f3e99c4a9e4..0be3886949a 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -25,12 +25,15 @@ import { type SessionRecords = Record; type FixtureMode = - | "buffered-before-ready" | "cleanup-failure" - | "delayed-duplicate" + | "delayed-input-attachment" + | "delayed-recording" + | "input-mode-timeout" | "invalid-order" + | "late-extra" | "nonzero" | "nonzero-cleanup-failure" + | "recording-timeout" | "valid"; function message(role: "assistant" | "user", content = "nonempty"): string { @@ -62,9 +65,7 @@ function runEvidenceFixture(input: { after: SessionRecords; afterFinalNewline?: boolean; before?: SessionRecords; - expectedInput?: string; expectedTurns: number; - mode?: "qualify" | "qualify-input"; }) { const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-evidence-")); const baselinePath = join(fixtureRoot, "baseline.json"); @@ -83,11 +84,10 @@ function runEvidenceFixture(input: { [ "-e", OPENCLAW_SESSION_EVIDENCE_SCRIPT, - input.mode ?? "qualify", + "qualify", sessionRoot, baselinePath, String(input.expectedTurns), - input.expectedInput ?? "", ], { encoding: "utf8" }, ); @@ -132,9 +132,9 @@ function runBaselineMutationFixture(mutation: "invalid" | "removed" | "rewritten } } -function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "ansi" | "plain") { +function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "absent" | "ansi" | "reordered") { const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-turn-")); - const fakeLaunch = join(fixtureRoot, "fake-launch.cjs"); + const fakeLaunch = join(fixtureRoot, "openclaw"); const fakeOpenshell = join(fixtureRoot, "openshell"); const sessionRoot = join(fixtureRoot, "sessions"); const ttyMarker = join(fixtureRoot, "tty-observed"); @@ -148,55 +148,66 @@ function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "ansi" | "plai String.raw`#!/usr/bin/env node const fs = require("node:fs"); const readline = require("node:readline"); +const childProcess = require("node:child_process"); + +if (process.argv[2] !== "tui") { + const child = childProcess.spawnSync(process.execPath, [__filename, "tui"], { stdio: "inherit" }); + process.exit(child.status ?? 66); +} if (!process.stdin.isTTY || !process.stdout.isTTY) process.exit(64); fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_TTY_MARKER, ""); const sessionFile = process.env.NEMOCLAW_FIXTURE_SESSION_FILE; const mode = process.env.NEMOCLAW_FIXTURE_MODE; const terminalCopy = process.env.NEMOCLAW_FIXTURE_TERMINAL_COPY; -const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); -const ask = () => new Promise((resolve) => rl.question("", resolve)); const append = (role, content) => fs.appendFileSync( sessionFile, JSON.stringify({ message: { content: [{ text: content, type: "text" }], role }, type: "message" }) + "\n", ); (async () => { - const bufferedInputs = []; - if (mode === "buffered-before-ready") { - const bufferInput = (line) => bufferedInputs.push(line); - rl.on("line", bufferInput); - await new Promise((resolve) => setTimeout(resolve, 3500)); - rl.off("line", bufferInput); + if (mode === "delayed-input-attachment" || mode === "input-mode-timeout") { + let inputBeforeAttachment = false; + const recordEarlyInput = () => { inputBeforeAttachment = true; }; + process.stdin.on("data", recordEarlyInput); + await new Promise((resolve) => setTimeout(resolve, mode === "input-mode-timeout" ? 10_000 : 1_500)); + process.stdin.off("data", recordEarlyInput); + if (inputBeforeAttachment) process.exit(67); + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const ask = () => new Promise((resolve) => rl.question("", resolve)); + if (terminalCopy === "ansi") process.stdout.write("\u001b[2Kgateway connected | idle\r"); + if (terminalCopy === "reordered") process.stdout.write("idle | gateway connected\n"); + + const first = await ask(); + const delayedInputs = []; + if (mode === "delayed-recording") { + const recordDelayedInput = (line) => delayedInputs.push(line); + rl.on("line", recordDelayedInput); + await new Promise((resolve) => setTimeout(resolve, 3_500)); + rl.off("line", recordDelayedInput); + } + if (mode === "recording-timeout") { + await new Promise((resolve) => setTimeout(resolve, 10_000)); } - process.stdout.write("idle | connected\n"); - - const first = bufferedInputs.shift() ?? await ask(); if (mode === "invalid-order") { append("assistant", "response before input"); append("user", first); } else { append("user", first); - process.stdout.write(terminalCopy === "ansi" ? "\u001b[2Kignored repaint\r" : "ignored plain copy\n"); append("assistant", "first response"); } - for (const duplicate of bufferedInputs) { + for (const duplicate of delayedInputs) { append("user", duplicate); append("assistant", "duplicate response"); } - if (mode === "delayed-duplicate") { - await new Promise((resolve) => setTimeout(resolve, 1500)); - append("user", first); - await new Promise((resolve) => setTimeout(resolve, 10000)); - return; - } - const second = await ask(); append("user", second); append("assistant", "second response"); const exitCommand = await ask(); + if (mode === "late-extra") append("user", first); rl.close(); if (exitCommand !== "/exit") process.exit(65); process.exit(mode.includes("nonzero") ? 23 : 0); @@ -234,6 +245,7 @@ exec "$@" NEMOCLAW_LAUNCH_FIRST_INPUT: "first input", NEMOCLAW_LAUNCH_RUN_ID: runId, NEMOCLAW_LAUNCH_SANDBOX: "sandbox", + NEMOCLAW_LAUNCH_SESSION_BUDGET_SECONDS: mode.endsWith("-timeout") ? "2" : "230", NEMOCLAW_LAUNCH_SECOND_INPUT: "second input", NEMOCLAW_LAUNCH_SESSION_EVIDENCE_SCRIPT: OPENCLAW_SESSION_EVIDENCE_SCRIPT, NEMOCLAW_LAUNCH_SESSION_ROOT: sessionRoot, @@ -282,45 +294,6 @@ it("keeps a partial structured turn pending (#9160)", () => { expect(qualification.status).toBe(1); }); -it("qualifies PTY input from the structured user record before the assistant reply (#9160)", () => { - const accepted = runEvidenceFixture({ - after: { "session-a": [message("user", "first input")] }, - expectedInput: "first input", - expectedTurns: 1, - mode: "qualify-input", - }); - const pending = runEvidenceFixture({ - after: { "session-a": [message("user"), message("assistant")] }, - expectedInput: "second input", - expectedTurns: 2, - mode: "qualify-input", - }); - - expect(accepted.baseline.status).toBe(0); - expect(accepted.qualification.status).toBe(0); - expect(pending.baseline.status).toBe(0); - expect(pending.qualification.status).toBe(1); -}); - -it("rejects a prior turn duplicate as evidence for the next PTY input (#9160)", () => { - const { baseline, qualification } = runEvidenceFixture({ - after: { - "session-a": [ - message("user", "first input"), - message("assistant"), - message("user", "first input"), - ], - }, - expectedInput: "second input", - expectedTurns: 2, - mode: "qualify-input", - }); - - expect(baseline.status).toBe(0); - expect(qualification.status).toBe(2); - expect(qualification.stderr).toContain('"reason":"input_content_mismatch"'); -}); - it("does not qualify structured turns recorded before the baseline (#9160)", () => { const { baseline, qualification } = runEvidenceFixture({ before: { "session-a": [message("user"), message("assistant")] }, @@ -379,13 +352,13 @@ it("rejects an invalid baseline or a removed, rewritten, or truncated session (# it.runIf(process.platform === "linux")( "sends two inputs and exit through a real PTY without using terminal copy as evidence (#9160)", () => { - for (const terminalCopy of ["ansi", "plain"] as const) { + for (const terminalCopy of ["absent", "ansi", "reordered"] as const) { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( "valid", terminalCopy, ); - expect(ttyObserved).toBe(true); + expect(ttyObserved, result.stderr).toBe(true); expect(baselineRemoved).toBe(true); expect(result.signal).toBeNull(); expect(result.status).toBe(0); @@ -394,11 +367,26 @@ it.runIf(process.platform === "linux")( ); it.runIf(process.platform === "linux")( - "does not queue duplicate PTY input before OpenClaw is ready (#9160)", + "waits for the OpenClaw TUI input mode before submitting PTY input (#9160)", + () => { + const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( + "delayed-input-attachment", + "absent", + ); + + expect(ttyObserved).toBe(true); + expect(baselineRemoved).toBe(true); + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + }, +); + +it.runIf(process.platform === "linux")( + "submits each PTY turn once while structured recording is delayed (#9160)", () => { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( - "buffered-before-ready", - "plain", + "delayed-recording", + "absent", ); expect(ttyObserved).toBe(true); @@ -409,18 +397,36 @@ it.runIf(process.platform === "linux")( ); it.runIf(process.platform === "linux")( - "rejects a delayed duplicate before recording the distinct second PTY input (#9160)", + "reports a missing OpenClaw input mode before the PTY child timeout (#9160)", () => { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( - "delayed-duplicate", - "plain", + "input-mode-timeout", + "absent", ); expect(ttyObserved).toBe(true); expect(baselineRemoved).toBe(true); expect(result.signal).toBeNull(); expect(result.status).toBe(1); - expect(result.stderr).toContain('"reason":"input_content_mismatch"'); + expect(result.stderr).toContain( + "launch PTY did not enter input mode before the session deadline", + ); + }, +); + +it.runIf(process.platform === "linux")( + "reports missing structured turns before the PTY child timeout (#9160)", + () => { + const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( + "recording-timeout", + "absent", + ); + + expect(ttyObserved).toBe(true); + expect(baselineRemoved).toBe(true); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("launch did not record the required structured session turns"); }, ); @@ -429,20 +435,39 @@ it.runIf(process.platform === "linux")( () => { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( "invalid-order", - "plain", + "absent", + ); + + expect(ttyObserved).toBe(true); + expect(baselineRemoved).toBe(true); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + }, +); + +it.runIf(process.platform === "linux")( + "rejects a late extra structured record before baseline cleanup (#9160)", + () => { + const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( + "late-extra", + "absent", ); expect(ttyObserved).toBe(true); expect(baselineRemoved).toBe(true); expect(result.signal).toBeNull(); expect(result.status).toBe(1); + expect(result.stderr).toContain( + "launch final structured session evidence did not qualify (status 2)", + ); + expect(result.stderr).toContain('"reason":"extra_message"'); }, ); it.runIf(process.platform === "linux")( "propagates a nonzero TUI exit after two structured turns (#9160)", () => { - const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture("nonzero", "plain"); + const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture("nonzero", "absent"); expect(ttyObserved).toBe(true); expect(baselineRemoved).toBe(true); @@ -456,7 +481,7 @@ it.runIf(process.platform === "linux")( () => { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( "cleanup-failure", - "plain", + "absent", ); expect(ttyObserved).toBe(true); @@ -471,7 +496,7 @@ it.runIf(process.platform === "linux")( () => { const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( "nonzero-cleanup-failure", - "plain", + "absent", ); expect(ttyObserved).toBe(true); From fdd2134e2fcf6c7a2a497354af90cf6ade0d5f54 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 15 Aug 2026 07:14:27 -0700 Subject: [PATCH 3/5] test(e2e): cover ambiguous OpenClaw TUI input Signed-off-by: Carlos Villela --- test/e2e/support/launch-agent-turn.test.ts | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index 0be3886949a..372ee4f32e1 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -31,6 +31,7 @@ type FixtureMode = | "input-mode-timeout" | "invalid-order" | "late-extra" + | "multiple-tui-processes" | "nonzero" | "nonzero-cleanup-failure" | "recording-timeout" @@ -137,6 +138,8 @@ function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "absent" | "an const fakeLaunch = join(fixtureRoot, "openclaw"); const fakeOpenshell = join(fixtureRoot, "openshell"); const sessionRoot = join(fixtureRoot, "sessions"); + const inputMarker = join(fixtureRoot, "input-observed"); + const duplicateMarker = join(fixtureRoot, "duplicate-ready"); const ttyMarker = join(fixtureRoot, "tty-observed"); const runId = basename(fixtureRoot).replaceAll(/[^a-zA-Z0-9]/gu, ""); const baselinePath = `/tmp/nemoclaw-launch-session-${runId}.json`; @@ -166,6 +169,25 @@ const append = (role, content) => fs.appendFileSync( ); (async () => { + let duplicate; + if (mode === "multiple-tui-processes") { + duplicate = childProcess.spawn( + process.execPath, + [ + "-e", + 'const fs = require("node:fs"); fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER, ""); setInterval(() => { if (process.ppid === 1) process.exit(0); }, 20);', + __filename, + "tui", + ], + { env: process.env, stdio: "ignore" }, + ); + process.once("exit", () => duplicate.kill("SIGKILL")); + for (let attempt = 0; attempt < 200 && !fs.existsSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (!fs.existsSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER)) process.exit(68); + } + process.stdin.once("data", () => fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_INPUT_MARKER, "")); if (mode === "delayed-input-attachment" || mode === "input-mode-timeout") { let inputBeforeAttachment = false; const recordEarlyInput = () => { inputBeforeAttachment = true; }; @@ -236,6 +258,8 @@ exec "$@" env: { ...process.env, NEMOCLAW_FIXTURE_MODE: mode, + NEMOCLAW_FIXTURE_DUPLICATE_MARKER: duplicateMarker, + NEMOCLAW_FIXTURE_INPUT_MARKER: inputMarker, NEMOCLAW_FIXTURE_SESSION_FILE: join(sessionRoot, "session-a.jsonl"), NEMOCLAW_FIXTURE_TERMINAL_COPY: terminalCopy, NEMOCLAW_FIXTURE_TTY_MARKER: ttyMarker, @@ -257,6 +281,7 @@ exec "$@" return { baselineRemoved: !existsSync(baselinePath), + inputObserved: existsSync(inputMarker), result, ttyObserved: existsSync(ttyMarker), }; @@ -381,6 +406,23 @@ it.runIf(process.platform === "linux")( }, ); +it.runIf(process.platform === "linux")( + "rejects multiple OpenClaw TUI processes before submitting PTY input (#9160)", + () => { + const { baselineRemoved, inputObserved, result, ttyObserved } = runLaunchSessionFixture( + "multiple-tui-processes", + "absent", + ); + + expect(ttyObserved).toBe(true); + expect(inputObserved).toBe(false); + expect(baselineRemoved).toBe(true); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("multiple_tui_processes"); + }, +); + it.runIf(process.platform === "linux")( "submits each PTY turn once while structured recording is delayed (#9160)", () => { From 859d671e690dba4758358e8e5f1257602fa534cf Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 15 Aug 2026 07:29:23 -0700 Subject: [PATCH 4/5] test(e2e): distinguish launch input from PTY noise Signed-off-by: Carlos Villela --- test/e2e/support/launch-agent-turn.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index 372ee4f32e1..ea482946384 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -187,7 +187,13 @@ const append = (role, content) => fs.appendFileSync( } if (!fs.existsSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER)) process.exit(68); } - process.stdin.once("data", () => fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_INPUT_MARKER, "")); + let observedPtyInput = ""; + process.stdin.on("data", (chunk) => { + observedPtyInput += chunk.toString(); + if (observedPtyInput.includes(process.env.NEMOCLAW_LAUNCH_FIRST_INPUT)) { + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_INPUT_MARKER, ""); + } + }); if (mode === "delayed-input-attachment" || mode === "input-mode-timeout") { let inputBeforeAttachment = false; const recordEarlyInput = () => { inputBeforeAttachment = true; }; From b72c8f1bef92b098fe72096ee1c6e1e6342bc4b4 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sat, 15 Aug 2026 07:44:35 -0700 Subject: [PATCH 5/5] test(e2e): prove ambiguous OpenClaw TUI cleanup Signed-off-by: Senthil Ravichandran --- test/e2e/support/launch-agent-turn.test.ts | 125 +++++++++++++-------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index ea482946384..f36e9fabd57 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -138,12 +138,13 @@ function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "absent" | "an const fakeLaunch = join(fixtureRoot, "openclaw"); const fakeOpenshell = join(fixtureRoot, "openshell"); const sessionRoot = join(fixtureRoot, "sessions"); - const inputMarker = join(fixtureRoot, "input-observed"); - const duplicateMarker = join(fixtureRoot, "duplicate-ready"); + const tuiInputMarkerRoot = join(fixtureRoot, "tui-input"); + const tuiPidsPath = join(fixtureRoot, "tui-pids"); const ttyMarker = join(fixtureRoot, "tty-observed"); const runId = basename(fixtureRoot).replaceAll(/[^a-zA-Z0-9]/gu, ""); const baselinePath = `/tmp/nemoclaw-launch-session-${runId}.json`; mkdirSync(sessionRoot); + mkdirSync(tuiInputMarkerRoot); try { writeFileSync( @@ -153,47 +154,57 @@ const fs = require("node:fs"); const readline = require("node:readline"); const childProcess = require("node:child_process"); +const mode = process.env.NEMOCLAW_FIXTURE_MODE; if (process.argv[2] !== "tui") { - const child = childProcess.spawnSync(process.execPath, [__filename, "tui"], { stdio: "inherit" }); - process.exit(child.status ?? 66); + if (mode !== "multiple-tui-processes") { + const child = childProcess.spawnSync(process.execPath, [__filename, "tui"], { stdio: "inherit" }); + process.exit(child.status ?? 66); + } + const children = Array.from({ length: 2 }, () => + childProcess.spawn(process.execPath, [__filename, "tui"], { stdio: "inherit" }), + ); + fs.writeFileSync( + process.env.NEMOCLAW_FIXTURE_TUI_PIDS, + children.map((child) => child.pid).join("\n") + "\n", + ); + const stopChildren = () => { + for (const child of children) { + try { child.kill("SIGTERM"); } catch {} + } + }; + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) { + process.once(signal, () => { + stopChildren(); + setTimeout(() => process.exit(0), 100); + }); + } + let activeChildren = children.length; + for (const child of children) { + child.once("exit", () => { + activeChildren -= 1; + if (activeChildren === 0) process.exit(0); + }); + } } -if (!process.stdin.isTTY || !process.stdout.isTTY) process.exit(64); -fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_TTY_MARKER, ""); -const sessionFile = process.env.NEMOCLAW_FIXTURE_SESSION_FILE; -const mode = process.env.NEMOCLAW_FIXTURE_MODE; -const terminalCopy = process.env.NEMOCLAW_FIXTURE_TERMINAL_COPY; -const append = (role, content) => fs.appendFileSync( - sessionFile, - JSON.stringify({ message: { content: [{ text: content, type: "text" }], role }, type: "message" }) + "\n", -); - -(async () => { - let duplicate; +if (process.argv[2] === "tui") (async () => { + if (!process.stdin.isTTY || !process.stdout.isTTY) process.exit(64); + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_TTY_MARKER, ""); + const sessionFile = process.env.NEMOCLAW_FIXTURE_SESSION_FILE; + const terminalCopy = process.env.NEMOCLAW_FIXTURE_TERMINAL_COPY; + const append = (role, content) => fs.appendFileSync( + sessionFile, + JSON.stringify({ message: { content: [{ text: content, type: "text" }], role }, type: "message" }) + "\n", + ); if (mode === "multiple-tui-processes") { - duplicate = childProcess.spawn( - process.execPath, - [ - "-e", - 'const fs = require("node:fs"); fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER, ""); setInterval(() => { if (process.ppid === 1) process.exit(0); }, 20);', - __filename, - "tui", - ], - { env: process.env, stdio: "ignore" }, - ); - process.once("exit", () => duplicate.kill("SIGKILL")); - for (let attempt = 0; attempt < 200 && !fs.existsSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER); attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - if (!fs.existsSync(process.env.NEMOCLAW_FIXTURE_DUPLICATE_MARKER)) process.exit(68); + let observedPtyInput = ""; + process.stdin.on("data", (chunk) => { + observedPtyInput += chunk.toString(); + if (observedPtyInput.includes(process.env.NEMOCLAW_LAUNCH_FIRST_INPUT)) { + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_TUI_INPUT_MARKER_ROOT + "/" + process.pid, ""); + } + }); } - let observedPtyInput = ""; - process.stdin.on("data", (chunk) => { - observedPtyInput += chunk.toString(); - if (observedPtyInput.includes(process.env.NEMOCLAW_LAUNCH_FIRST_INPUT)) { - fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_INPUT_MARKER, ""); - } - }); if (mode === "delayed-input-attachment" || mode === "input-mode-timeout") { let inputBeforeAttachment = false; const recordEarlyInput = () => { inputBeforeAttachment = true; }; @@ -264,10 +275,10 @@ exec "$@" env: { ...process.env, NEMOCLAW_FIXTURE_MODE: mode, - NEMOCLAW_FIXTURE_DUPLICATE_MARKER: duplicateMarker, - NEMOCLAW_FIXTURE_INPUT_MARKER: inputMarker, NEMOCLAW_FIXTURE_SESSION_FILE: join(sessionRoot, "session-a.jsonl"), NEMOCLAW_FIXTURE_TERMINAL_COPY: terminalCopy, + NEMOCLAW_FIXTURE_TUI_INPUT_MARKER_ROOT: tuiInputMarkerRoot, + NEMOCLAW_FIXTURE_TUI_PIDS: tuiPidsPath, NEMOCLAW_FIXTURE_TTY_MARKER: ttyMarker, NEMOCLAW_LAUNCH_COMMAND: fakeLaunch, NEMOCLAW_LAUNCH_ENTRYPOINT: "", @@ -285,10 +296,24 @@ exec "$@" timeout: 15_000, }); + const tuiProcessIds = existsSync(tuiPidsPath) + ? readFileSync(tuiPidsPath, "utf8").trim().split("\n").filter(Boolean) + : []; + const processExitDeadline = Date.now() + 1_000; + while ( + tuiProcessIds.some((pid) => existsSync(`/proc/${pid}`)) && + Date.now() < processExitDeadline + ) { + spawnSync(process.execPath, ["-e", "setTimeout(() => {}, 25)"], { timeout: 100 }); + } return { baselineRemoved: !existsSync(baselinePath), - inputObserved: existsSync(inputMarker), + orphanedTuiProcessIds: tuiProcessIds.filter((pid) => existsSync(`/proc/${pid}`)), + recordedTuiInputProcessIds: tuiProcessIds.filter((pid) => + existsSync(join(tuiInputMarkerRoot, pid)), + ), result, + tuiProcessIds, ttyObserved: existsSync(ttyMarker), }; } finally { @@ -415,17 +440,23 @@ it.runIf(process.platform === "linux")( it.runIf(process.platform === "linux")( "rejects multiple OpenClaw TUI processes before submitting PTY input (#9160)", () => { - const { baselineRemoved, inputObserved, result, ttyObserved } = runLaunchSessionFixture( - "multiple-tui-processes", - "absent", - ); + const { + baselineRemoved, + orphanedTuiProcessIds, + recordedTuiInputProcessIds, + result, + tuiProcessIds, + ttyObserved, + } = runLaunchSessionFixture("multiple-tui-processes", "absent"); expect(ttyObserved).toBe(true); - expect(inputObserved).toBe(false); + expect(tuiProcessIds).toHaveLength(2); + expect(recordedTuiInputProcessIds).toEqual([]); + expect(orphanedTuiProcessIds).toEqual([]); expect(baselineRemoved).toBe(true); expect(result.signal).toBeNull(); expect(result.status).toBe(1); - expect(result.stderr).toContain("multiple_tui_processes"); + expect(result.stderr).toContain('"reason":"multiple_tui_processes"'); }, );