diff --git a/dogfood/issue-21-run-completion-clean/01-create.json b/dogfood/issue-21-run-completion-clean/01-create.json new file mode 100644 index 0000000..642de19 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/01-create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-26T20:49:52.589Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "createdAt": "2026-04-26T20:49:51.659Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-21-run-completion-clean/02-run.json b/dogfood/issue-21-run-completion-clean/02-run.json new file mode 100644 index 0000000..5ddb825 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/02-run.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "run", + "timestamp": "2026-04-26T20:50:07.184Z", + "result": { + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 2, + "durationMs": 208, + "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__" + } +} diff --git a/dogfood/issue-21-run-completion-clean/03-snapshot.json b/dogfood/issue-21-run-completion-clean/03-snapshot.json new file mode 100644 index 0000000..23ea0a4 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/03-snapshot.json @@ -0,0 +1,113 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-26T20:50:19.514Z", + "result": { + "format": "structured", + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "capturedAtSeq": 8, + "cols": 80, + "rows": 24, + "cursorRow": 4, + "cursorCol": 20, + "isAltScreen": false, + "visibleLines": [ + { + "row": 0, + "text": "bash-5.1$ printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-m" + }, + { + "row": 1, + "text": "arker-proof\\n\"" + }, + { + "row": 2, + "text": "before-clean-marker-proof" + }, + { + "row": 3, + "text": "after-clean-marker-proof" + }, + { + "row": 4, + "text": "bash-5.1$ bash-5.1$" + }, + { + "row": 5, + "text": "" + }, + { + "row": 6, + "text": "" + }, + { + "row": 7, + "text": "" + }, + { + "row": 8, + "text": "" + }, + { + "row": 9, + "text": "" + }, + { + "row": 10, + "text": "" + }, + { + "row": 11, + "text": "" + }, + { + "row": 12, + "text": "" + }, + { + "row": 13, + "text": "" + }, + { + "row": 14, + "text": "" + }, + { + "row": 15, + "text": "" + }, + { + "row": 16, + "text": "" + }, + { + "row": 17, + "text": "" + }, + { + "row": 18, + "text": "" + }, + { + "row": 19, + "text": "" + }, + { + "row": 20, + "text": "" + }, + { + "row": 21, + "text": "" + }, + { + "row": 22, + "text": "" + }, + { + "row": 23, + "text": "" + } + ] + } +} diff --git a/dogfood/issue-21-run-completion-clean/04-screenshot.json b/dogfood/issue-21-run-completion-clean/04-screenshot.json new file mode 100644 index 0000000..ce58b8e --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/04-screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-26T20:50:29.854Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "capturedAtSeq": 8, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-tty-issue21-dogfood-aC31nw/sessions/01KQ5RWH177CRZKPXSW91K16RG/artifacts/screenshot-8-reference-dark.png", + "pngSizeBytes": 8884, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "5d25d81377d59dfa79928b7ce1824ab521328eba74f73b53abd02f58d4658ccf", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/issue-21-run-completion-clean/04-screenshot.png b/dogfood/issue-21-run-completion-clean/04-screenshot.png new file mode 100644 index 0000000..a9ef1af Binary files /dev/null and b/dogfood/issue-21-run-completion-clean/04-screenshot.png differ diff --git a/dogfood/issue-21-run-completion-clean/05-asciicast.json b/dogfood/issue-21-run-completion-clean/05-asciicast.json new file mode 100644 index 0000000..c01c49d --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/05-asciicast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-26T20:50:38.337Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-4d32/dogfood/issue-21-run-completion-clean/05-recording.cast", + "bytes": 543, + "sha256": "167aea68969d96fd2b159f5f6a5ad5c201e3db1396de889eab2be9026a54ad82", + "capturedAtSeq": 8, + "durationMs": 14205, + "metadata": { + "width": 80, + "height": 24, + "title": "01KQ5RWH177CRZKPXSW91K16RG", + "timestamp": 1777236592, + "outputEventCount": 7, + "resizeEventCount": 0, + "markerCount": 0 + } + } +} diff --git a/dogfood/issue-21-run-completion-clean/05-recording.cast b/dogfood/issue-21-run-completion-clean/05-recording.cast new file mode 100644 index 0000000..3c7c699 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/05-recording.cast @@ -0,0 +1,8 @@ +{"version":2,"width":80,"height":24,"timestamp":1777236592,"title":"01KQ5RWH177CRZKPXSW91K16RG","sessionId":"01KQ5RWH177CRZKPXSW91K16RG","env":{"TERM":"xterm-256color"},"toolVersion":"0.1.1-beta.4"} +[0,"o","\u001b[?2004h"] +[0.001,"o","bash-5.1$ "] +[13.998,"o","printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-ma\rarker-proof\\n\"\r\n\u001b[?2004l\r"] +[13.999,"o","before-clean-marker-proof\r\n"] +[14.201,"o","after-clean-marker-proof\r\n\u001b[?2004h"] +[14.201,"o","bash-5.1$ "] +[14.205,"o","\u001b[?2004hbash-5.1$ "] diff --git a/dogfood/issue-21-run-completion-clean/06-recording.webm b/dogfood/issue-21-run-completion-clean/06-recording.webm new file mode 100644 index 0000000..9f2a58f Binary files /dev/null and b/dogfood/issue-21-run-completion-clean/06-recording.webm differ diff --git a/dogfood/issue-21-run-completion-clean/06-webm.json b/dogfood/issue-21-run-completion-clean/06-webm.json new file mode 100644 index 0000000..a990dcd --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/06-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-26T20:50:41.609Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-4d32/dogfood/issue-21-run-completion-clean/06-recording.webm", + "bytes": 26432, + "sha256": "27384d1fafaeee27c0e8511f3df659c3b195c4fccf56262fdb5605caa7b7dcee", + "capturedAtSeq": 8, + "durationMs": 14205, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "rendererBackend": "ghostty-web", + "outputEventCount": 7, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/issue-21-run-completion-clean/07-events.jsonl b/dogfood/issue-21-run-completion-clean/07-events.jsonl new file mode 100644 index 0000000..89de225 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/07-events.jsonl @@ -0,0 +1,9 @@ +{"seq":0,"ts":"2026-04-26T20:49:52.489Z","type":"output","payload":{"data":"\u001b[?2004h"}} +{"seq":1,"ts":"2026-04-26T20:49:52.490Z","type":"output","payload":{"data":"bash-5.1$ "}} +{"seq":2,"ts":"2026-04-26T20:50:06.485Z","type":"input_run","payload":{"command":"printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-marker-proof\\n\"","marker":"__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__","noWait":false}} +{"seq":3,"ts":"2026-04-26T20:50:06.487Z","type":"output","payload":{"data":"printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-ma\rarker-proof\\n\"\r\n\u001b[?2004l\r"}} +{"seq":4,"ts":"2026-04-26T20:50:06.488Z","type":"output","payload":{"data":"before-clean-marker-proof\r\n"}} +{"seq":5,"ts":"2026-04-26T20:50:06.690Z","type":"output","payload":{"data":"after-clean-marker-proof\r\n\u001b[?2004h"}} +{"seq":6,"ts":"2026-04-26T20:50:06.690Z","type":"output","payload":{"data":"bash-5.1$ "}} +{"seq":7,"ts":"2026-04-26T20:50:06.693Z","type":"run_complete","payload":{"marker":"__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__","inputRunSeq":2}} +{"seq":8,"ts":"2026-04-26T20:50:06.694Z","type":"output","payload":{"data":"\u001b[?2004hbash-5.1$ "}} diff --git a/dogfood/issue-21-run-completion-clean/08-destroy.json b/dogfood/issue-21-run-completion-clean/08-destroy.json new file mode 100644 index 0000000..088a733 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/08-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-26T20:51:54.509Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "destroyed": true + } +} diff --git a/dogfood/issue-21-run-completion-clean/README.md b/dogfood/issue-21-run-completion-clean/README.md new file mode 100644 index 0000000..f2b0133 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/README.md @@ -0,0 +1,80 @@ +# Issue #21 — Run completion markers stay out of rendered artifacts + +This bundle proves that `run --wait` no longer leaks the internal completion +marker into reviewer-facing artifacts (snapshot, screenshot, asciicast, WebM) +while still preserving the public CLI JSON envelope. + +Generated under an isolated `AGENT_TTY_HOME` (`/tmp/agent-tty-issue21-dogfood-…`, +removed after capture). See `commands.sh` for the exact reproduction. + +## What was exercised + +The waited `run` command was: + +```sh +printf "before-clean-marker-proof\n"; sleep 0.2; printf "after-clean-marker-proof\n" +``` + +Run marker returned in the JSON envelope: `__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__` +(UUID portion: `739e240b0762477e833bf3fd8b0dfd5f`). + +## Verification matrix + +| Artifact | User output present | `__AT_MARKER_` count | `agent-tty:run-complete:` count | Marker UUID count | +| --------------------------------------- | --------------------- | -------------------- | ------------------------------- | ----------------- | +| `03-snapshot.json` (visibleLines text) | ✅ both lines | 0 | 0 | 0 | +| `05-recording.cast` (asciicast frames) | ✅ both lines | 0 | 0 | 0 | +| `06-recording.webm` (binary, byte scan) | n/a (encoded video) | 0 | 0 | 0 | +| `07-events.jsonl` `output` events | ✅ visible bytes only | 0 | 0 | 0 | + +Allowed and expected: the marker text appears only in the structured metadata +events that never reach renderer/export — `input_run.payload.marker` and +`run_complete.payload.marker`. + +## Event-log highlight + +Single waited run produced 9 events: + +- 1 × `input_run` (carries marker as correlation metadata only) +- 7 × `output` (visible PTY bytes; marker-free) +- 1 × `run_complete` (new structured non-rendered event with `{ marker, inputRunSeq: 2 }`) + +`run_complete` event payload: + +```json +{ "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__", "inputRunSeq": 2 } +``` + +## Public envelope preserved + +`02-run.json` still includes: + +```json +{ + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 2, + "durationMs": 208, + "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__" +} +``` + +## Files + +- `01-create.json` — `create` JSON envelope +- `02-run.json` — `run --wait` JSON envelope (still exposes `marker`, `completed`, `durationMs`, …) +- `03-snapshot.json` — semantic snapshot +- `04-screenshot.{json,png}` — rendered screenshot result + PNG (640 × 384, ghostty-web) +- `05-recording.cast` + `05-asciicast.json` — exported asciicast and result envelope +- `06-recording.webm` + `06-webm.json` — exported WebM (accelerated timing) and result envelope +- `07-events.jsonl` — canonical event log copy +- `08-destroy.json` — session teardown envelope +- `commands.sh` — exact reproduction script + +## Suggested review order + +1. `02-run.json` to confirm the public envelope is unchanged. +2. `07-events.jsonl` to see the new `run_complete` event and confirm `output` events are marker-free. +3. `03-snapshot.json` and `04-screenshot.png` to confirm the rendered terminal state contains user output but no marker. +4. `05-recording.cast` and `06-recording.webm` to confirm exported recordings are also marker-free. diff --git a/dogfood/issue-21-run-completion-clean/commands.sh b/dogfood/issue-21-run-completion-clean/commands.sh new file mode 100755 index 0000000..d07532e --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/commands.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Reproduce the issue-21 run-completion-clean dogfood bundle. +# All paths use an isolated AGENT_TTY_HOME under /tmp; nothing writes to ~/.agent-tty. +set -euo pipefail + +DOGFOOD_HOME=$(mktemp -d -t agent-tty-issue21-dogfood-XXXXXX) +ARTIFACTS_DIR="$(pwd)/dogfood/issue-21-run-completion-clean" +mkdir -p "$ARTIFACTS_DIR" + +CLI=(npx tsx src/cli/main.ts --home "$DOGFOOD_HOME") + +# 1. Create an interactive bash session +"${CLI[@]}" create --json -- bash --noprofile --norc \ + | tee "$ARTIFACTS_DIR/01-create.json" +SESSION_ID=$(jq -r '.result.sessionId' "$ARTIFACTS_DIR/01-create.json") + +# 2. Run a waited command with recognizable user output +"${CLI[@]}" run --json --timeout 10000 "$SESSION_ID" \ + 'printf "before-clean-marker-proof\n"; sleep 0.2; printf "after-clean-marker-proof\n"' \ + | tee "$ARTIFACTS_DIR/02-run.json" + +# 3. Capture artifacts +"${CLI[@]}" snapshot --json "$SESSION_ID" > "$ARTIFACTS_DIR/03-snapshot.json" +"${CLI[@]}" screenshot --json "$SESSION_ID" > "$ARTIFACTS_DIR/04-screenshot.json" +cp "$(jq -r '.result.artifactPath' "$ARTIFACTS_DIR/04-screenshot.json")" \ + "$ARTIFACTS_DIR/04-screenshot.png" +"${CLI[@]}" record export --json "$SESSION_ID" --format asciicast \ + --out "$ARTIFACTS_DIR/05-recording.cast" > "$ARTIFACTS_DIR/05-asciicast.json" +"${CLI[@]}" record export --json "$SESSION_ID" --format webm --timing accelerated \ + --out "$ARTIFACTS_DIR/06-recording.webm" > "$ARTIFACTS_DIR/06-webm.json" + +# 4. Copy the canonical event log for cleanliness inspection +cp "$DOGFOOD_HOME/sessions/$SESSION_ID/events.jsonl" "$ARTIFACTS_DIR/07-events.jsonl" + +# 5. Tear down +"${CLI[@]}" destroy --json "$SESSION_ID" > "$ARTIFACTS_DIR/08-destroy.json" +rm -rf "$DOGFOOD_HOME" diff --git a/src/export/asciicast.ts b/src/export/asciicast.ts index 3ca1e05..7d918cf 100644 --- a/src/export/asciicast.ts +++ b/src/export/asciicast.ts @@ -1,7 +1,7 @@ import type { EventRecord, SessionRecord } from '../protocol/schemas.js'; import { DEFAULT_TERM } from '../config/defaults.js'; -import { invariant } from '../util/assert.js'; +import { invariant, unreachable } from '../util/assert.js'; export interface AsciicastHeader { version: 2; @@ -117,39 +117,52 @@ export function generateAsciicast( ); previousTimestampMs = eventTimestampMs; - if (event.type === 'output') { - outputEventCount += 1; - lines.push( - JSON.stringify([ - relativeSeconds(eventTimestampMs, firstTimestampMs), - 'o', - event.payload.data, - ]), - ); - continue; - } - - if (event.type === 'resize') { - resizeEventCount += 1; - lines.push( - JSON.stringify([ - relativeSeconds(eventTimestampMs, firstTimestampMs), - 'r', - `${String(event.payload.cols)}x${String(event.payload.rows)}`, - ]), - ); - continue; - } - - if (event.type === 'marker') { - markerCount += 1; - lines.push( - JSON.stringify([ - relativeSeconds(eventTimestampMs, firstTimestampMs), - 'm', - event.payload.label, - ]), - ); + switch (event.type) { + case 'output': { + outputEventCount += 1; + lines.push( + JSON.stringify([ + relativeSeconds(eventTimestampMs, firstTimestampMs), + 'o', + event.payload.data, + ]), + ); + break; + } + case 'resize': { + resizeEventCount += 1; + lines.push( + JSON.stringify([ + relativeSeconds(eventTimestampMs, firstTimestampMs), + 'r', + `${String(event.payload.cols)}x${String(event.payload.rows)}`, + ]), + ); + break; + } + case 'marker': { + markerCount += 1; + lines.push( + JSON.stringify([ + relativeSeconds(eventTimestampMs, firstTimestampMs), + 'm', + event.payload.label, + ]), + ); + break; + } + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'input_run': + case 'run_complete': + case 'signal': + case 'exit': { + break; + } + default: { + unreachable(event, 'unsupported asciicast event type'); + } } } diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 01a8133..e6bf13f 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -9,9 +9,11 @@ import { EventRecordSchema, InputRunEventPayloadSchema, MarkerEventPayloadSchema, + RunCompleteEventPayloadSchema, type EventRecord, type InputRunEventPayload, type MarkerEventPayload, + type RunCompleteEventPayload, } from '../protocol/schemas.js'; import { invariant } from '../util/assert.js'; @@ -79,6 +81,7 @@ type EventLogEventType = | 'input_paste' | 'input_keys' | 'input_run' + | 'run_complete' | 'resize' | 'signal' | 'exit' @@ -89,6 +92,7 @@ type EventLogPayload = | InputPasteEventPayload | InputKeysEventPayload | InputRunEventPayload + | RunCompleteEventPayload | ResizeEventPayload | SignalEventPayload | ExitEventPayload @@ -137,6 +141,11 @@ function validatePayload( invariant(result.success, 'input_run payload must match schema'); return result.data; } + case 'run_complete': { + const result = RunCompleteEventPayloadSchema.safeParse(payload); + invariant(result.success, 'run_complete payload must match schema'); + return result.data; + } case 'resize': { const result = ResizeEventPayloadSchema.safeParse(payload); invariant(result.success, 'resize payload must match schema'); @@ -320,6 +329,10 @@ export class EventLog { type: 'input_run', payload: InputRunEventPayload, ): Promise; + async append( + type: 'run_complete', + payload: RunCompleteEventPayload, + ): Promise; async append(type: 'resize', payload: ResizeEventPayload): Promise; async append(type: 'signal', payload: SignalEventPayload): Promise; async append(type: 'exit', payload: ExitEventPayload): Promise; diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index e75ffdb..c8d837d 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -9,6 +9,13 @@ import { EventLog } from './eventLog.js'; import { buildReplayInput } from './replay.js'; import { HostRendererManager } from './renderer.js'; import { RpcServer, type MethodHandler } from './rpcServer.js'; +import { + buildRunCompleteSignalSentinel, + RUN_MARKER_PATTERN, + RunCompletionPostambleEchoSanitizer, + RunCompletionSentinelScanner, + type SentinelPiece, +} from './runCompletionSentinel.js'; import { SessionState } from './sessionState.js'; import { createPty } from '../pty/createPty.js'; import { encodeKey } from '../pty/keyEncoder.js'; @@ -83,6 +90,29 @@ type WaitOutcome = { timedOut: boolean; }; +interface ActiveRunCompletion { + inputRunSeq?: number; + sentinel: string; +} + +type RunCompletionWaitResult = + | { kind: 'completed'; seq: number } + | { kind: 'exited' }; + +interface RunCompletionWaiter { + reject: (error: unknown) => void; + resolve: (result: RunCompletionWaitResult) => void; +} + +type TimedRunCompletionWaitResult = + | RunCompletionWaitResult + | { kind: 'timeout' }; + +const RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX = String.raw`printf '\033\137`; + +const RUN_COMPLETION_SIGNAL_TOKEN_BYTES = 4; +const MAX_RUN_COMPLETION_POSTAMBLE_ECHO_LENGTH = 64; + function normalizeExitSignal(signal: number | null): string | null { invariant( signal === null || (Number.isInteger(signal) && signal >= 0), @@ -102,6 +132,65 @@ function rethrowAsync(error: unknown): void { }); } +function shellOctalEscapedBytes(value: string): string { + invariant(typeof value === 'string', 'value must be a string'); + + return [...Buffer.from(value, 'utf8')] + .map((byte) => `\\${byte.toString(8).padStart(3, '0')}`) + .join(''); +} + +function generateRunCompleteSignalSentinel(): string { + const token = crypto + .randomBytes(RUN_COMPLETION_SIGNAL_TOKEN_BYTES) + .toString('base64url'); + invariant( + token.length === 6, + 'run-completion signal token must encode to six base64url characters', + ); + + return buildRunCompleteSignalSentinel(token); +} + +function buildRunCompletePostamble(marker: string, sentinel: string): string { + const markerMatch = RUN_MARKER_PATTERN.exec(marker); + invariant(markerMatch !== null, 'run marker must match expected format'); + invariant( + typeof sentinel === 'string' && sentinel.length > 0, + 'sentinel must be non-empty', + ); + + const markerPayload = markerMatch[1]; + invariant( + markerPayload !== undefined && markerPayload.length === 32, + 'run marker payload must be 32 lowercase hex characters', + ); + + const postamble = `printf '${shellOctalEscapedBytes(sentinel)}'`; + invariant( + postamble.length <= MAX_RUN_COMPLETION_POSTAMBLE_ECHO_LENGTH, + 'run-completion postamble echo must stay short enough to avoid terminal wrapping', + ); + invariant( + postamble.startsWith(RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX), + 'run-completion postamble echo prefix must stay in sync with sanitizer', + ); + invariant( + !postamble.includes('agent-tty:run-complete:'), + 'run-completion postamble must not echo the complete sentinel label', + ); + invariant( + !postamble.includes('__AT_MARKER_'), + 'run-completion postamble must not echo the complete marker prefix', + ); + invariant( + !postamble.includes(markerPayload), + 'run-completion postamble must not echo the complete marker payload', + ); + + return `${postamble}\n`; +} + function resolveHostRendererName(input: string | undefined): RendererName { try { return resolveRendererName( @@ -257,9 +346,14 @@ export async function runHost(sessionId: string): Promise { createRendererBackend(rendererName, sid, profile), }); - const loadReplayInput = () => { + const loadReplayInput = (targetSeq?: number) => { const events = [...eventLog.getEvents()]; - const replayInput = buildReplayInput(sessionId, state.snapshot(), events); + const replayInput = buildReplayInput( + sessionId, + state.snapshot(), + events, + targetSeq, + ); return replayInput.targetSeq === -1 ? null : replayInput; }; @@ -275,6 +369,12 @@ export async function runHost(sessionId: string): Promise { invariant(false, 'PTY exit resolver must be initialized'); }; + const sentinelScanner = new RunCompletionSentinelScanner(); + const postambleEchoSanitizer = new RunCompletionPostambleEchoSanitizer(); + const activeRunCompletions = new Map(); + const runCompletionWaiters = new Map(); + let ptyIngestionQueue: Promise = Promise.resolve(); + const ptyExitPromise = new Promise((resolve) => { markPtyExited = (): void => { if (ptyHasExited) { @@ -301,6 +401,189 @@ export async function runHost(sessionId: string): Promise { ); state.setChildPid(pty.pid); + const resolveRunCompletionWaiter = (marker: string, seq: number): void => { + const waiter = runCompletionWaiters.get(marker); + if (waiter === undefined) { + return; + } + + runCompletionWaiters.delete(marker); + waiter.resolve({ kind: 'completed', seq }); + }; + + const rejectRunCompletionWaiter = (marker: string, error: unknown): void => { + const waiter = runCompletionWaiters.get(marker); + if (waiter === undefined) { + return; + } + + runCompletionWaiters.delete(marker); + waiter.reject(error); + }; + + const resolveRunCompletionWaitersForExit = (): void => { + for (const [marker, waiter] of runCompletionWaiters) { + runCompletionWaiters.delete(marker); + waiter.resolve({ kind: 'exited' }); + } + }; + + const subscribeRunCompletion = ( + marker: string, + ): Promise => { + invariant( + !runCompletionWaiters.has(marker), + 'run completion waiter must be unique per marker', + ); + + const { promise, reject, resolve } = + Promise.withResolvers(); + runCompletionWaiters.set(marker, { reject, resolve }); + return promise; + }; + + const waitForRunCompletion = async ( + marker: string, + completionPromise: Promise, + timeoutMs: number, + ): Promise => { + invariant( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'timeoutMs must be positive', + ); + + const { promise, reject, resolve } = + Promise.withResolvers(); + let resolved = false; + const timeoutHandle = setTimeout(() => { + if (resolved) { + return; + } + + resolved = true; + // Keep sentinel/postamble registrations active after timeout so the + // eventual internal completion bytes are still hidden from artifacts. + runCompletionWaiters.delete(marker); + resolve({ kind: 'timeout' }); + }, timeoutMs); + + void completionPromise.then( + (result) => { + if (resolved) { + return; + } + + resolved = true; + clearTimeout(timeoutHandle); + resolve(result); + }, + (error: unknown) => { + if (resolved) { + return; + } + + resolved = true; + clearTimeout(timeoutHandle); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + + return await promise; + }; + + const replayRendererThroughSeq = async (targetSeq: number): Promise => { + invariant( + Number.isInteger(targetSeq) && targetSeq >= 0, + 'targetSeq must be a non-negative integer', + ); + + const replayInput = loadReplayInput(targetSeq); + invariant(replayInput !== null, 'run-complete replay input must exist'); + + const rendererName = resolveHostRendererName(undefined); + const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); + const backend = await rendererManager.getBackend( + rendererName, + profile, + replayInput, + ); + const snapshot = await backend.snapshot(); + invariant( + snapshot.capturedAtSeq >= targetSeq, + 'renderer snapshot must include the run-complete event sequence', + ); + }; + + const appendOutput = async (data: string): Promise => { + invariant(typeof data === 'string', 'output data must be a string'); + + const outputData = postambleEchoSanitizer.feed(data); + if (outputData.length > 0) { + await eventLog.append('output', { data: outputData }); + } + }; + + const appendFlushedPostambleEchoOutput = async (): Promise => { + const outputData = postambleEchoSanitizer.flush(); + if (outputData.length > 0) { + await eventLog.append('output', { data: outputData }); + } + }; + + const appendSentinelPieces = async ( + pieces: SentinelPiece[], + ): Promise => { + for (const piece of pieces) { + if (piece.type === 'output') { + await appendOutput(piece.data); + continue; + } + + const activeCompletion = activeRunCompletions.get(piece.marker); + invariant( + activeCompletion !== undefined, + 'run-completion sentinel must correspond to an active run marker', + ); + invariant( + activeCompletion.sentinel.length > 0, + 'active run-completion sentinel must be non-empty', + ); + + try { + const trailingEchoOutput = postambleEchoSanitizer.deregister( + piece.marker, + ); + if (trailingEchoOutput.length > 0) { + await eventLog.append('output', { data: trailingEchoOutput }); + } + + const seq = await eventLog.append('run_complete', { + marker: piece.marker, + ...(activeCompletion.inputRunSeq === undefined + ? {} + : { inputRunSeq: activeCompletion.inputRunSeq }), + }); + const deleted = activeRunCompletions.delete(piece.marker); + invariant( + deleted, + 'active run completion must be deleted after append succeeds', + ); + resolveRunCompletionWaiter(piece.marker, seq); + } catch (error) { + rejectRunCompletionWaiter(piece.marker, error); + throw error; + } + } + }; + + const enqueuePtyIngestion = ( + operation: () => Promise, + ): Promise => { + const queuedOperation = ptyIngestionQueue.then(operation, operation); + ptyIngestionQueue = queuedOperation.catch(() => undefined); + return queuedOperation; + }; + const clearIdleTimeout = (): void => { // Idempotent: safe to call multiple times during shutdown and PTY exit. if (idleTimeoutHandle === null) { @@ -400,6 +683,7 @@ export async function runHost(sessionId: string): Promise { try { await eventLog.append('exit', { exitCode, exitSignal }); } finally { + resolveRunCompletionWaitersForExit(); try { await writeManifest(mPath, state.snapshot()); } finally { @@ -684,146 +968,80 @@ export async function runHost(sessionId: string): Promise { } const shouldWait = !noWait; - let marker: string | undefined; - if (shouldWait) { - marker = `__AT_MARKER_${crypto.randomUUID().replace(/-/g, '')}__`; - } - if (shouldWait) { - invariant(marker !== undefined, 'run marker must exist when waiting'); - } - const injectedText = shouldWait - ? (() => { - const waitMarker = marker as string; - const half = Math.ceil(waitMarker.length / 2); - const markerPart1 = waitMarker.slice(0, half); - const markerPart2 = waitMarker.slice(half); - return `${command}\nprintf '%s%s\\n' '${markerPart1}' '${markerPart2}'\n`; - })() - : `${command}\n`; - pty.write(injectedText); - lastActivityAt = Date.now(); + if (!shouldWait) { + pty.write(`${command}\n`); + lastActivityAt = Date.now(); - const seq = await eventLog.append('input_run', { - command, - ...(marker === undefined ? {} : { marker }), - noWait, - }); + const seq = await eventLog.append('input_run', { + command, + noWait, + }); - if (!shouldWait) { return { accepted: true as const, seq, } satisfies RunResult; } - const effectiveTimeoutMs = timeoutMs ?? 30_000; - const startTime = Date.now(); - const rendererName = resolveHostRendererName(undefined); - const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); - const pollIntervalMs = 200; - const waitMarker = marker; - invariant(waitMarker !== undefined, 'run wait marker must be defined'); - let clearWaitPoll: (() => void) | null = null; - - const pollCondition = new Promise<{ - matched: boolean; - exited: boolean; - }>((resolve) => { - let pollInFlight = false; - let consecutiveFailures = 0; - - const checkInterval = setInterval(() => { - if (pollInFlight) { - return; - } - - pollInFlight = true; - void (async () => { - try { - const replayInput = loadReplayInput(); - const backend = await rendererManager.getBackend( - rendererName, - profile, - replayInput, - ); - const snapshot = await backend.snapshot(); - const visibleText = snapshot.visibleLines - .map((line) => line.text) - .join('\n'); - consecutiveFailures = 0; - - if (visibleText.includes(waitMarker)) { - clearInterval(checkInterval); - resolve({ matched: true, exited: false }); - return; - } - - if (!isSessionRunning(state)) { - clearInterval(checkInterval); - resolve({ matched: false, exited: true }); - return; - } - } catch (pollError) { - void pollError; - consecutiveFailures += 1; - - if (!isSessionRunning(state)) { - clearInterval(checkInterval); - resolve({ matched: false, exited: true }); - return; - } - - if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { - clearInterval(checkInterval); - resolve({ matched: false, exited: false }); - return; - } - } finally { - pollInFlight = false; - } - })(); - }, pollIntervalMs); - - clearWaitPoll = (): void => { - clearInterval(checkInterval); - }; + const marker = `__AT_MARKER_${crypto.randomUUID().replace(/-/g, '')}__`; + invariant( + RUN_MARKER_PATTERN.test(marker), + 'generated run marker must match expected format', + ); + let sentinel = generateRunCompleteSignalSentinel(); + while ( + [...activeRunCompletions.values()].some( + (completion) => completion.sentinel === sentinel, + ) + ) { + sentinel = generateRunCompleteSignalSentinel(); + } + const postamble = buildRunCompletePostamble(marker, sentinel); + const seq = await eventLog.append('input_run', { + command, + marker, + noWait, }); - const pollResult = await new Promise<{ - matched: boolean; - exited: boolean; - }>((resolve) => { - let resolved = false; - const timeoutHandle = setTimeout(() => { - if (resolved) { - return; - } - resolved = true; - clearWaitPoll?.(); - resolve({ matched: false, exited: false }); - }, effectiveTimeoutMs); - - void pollCondition.then((result) => { - if (resolved) { - return; - } - resolved = true; - clearTimeout(timeoutHandle); - clearWaitPoll?.(); - resolve(result); - }); - }); + invariant( + !activeRunCompletions.has(marker), + 'generated run marker must be unique among active completions', + ); + activeRunCompletions.set(marker, { inputRunSeq: seq, sentinel }); + sentinelScanner.register(marker, sentinel); + postambleEchoSanitizer.register(marker, postamble); + const completionPromise = subscribeRunCompletion(marker); + const injectedText = `${command}\n${postamble}`; + const effectiveTimeoutMs = timeoutMs ?? 30_000; + const startTime = Date.now(); + pty.write(injectedText); + lastActivityAt = Date.now(); + const waitResult = await waitForRunCompletion( + marker, + completionPromise, + effectiveTimeoutMs, + ); const durationMs = Date.now() - startTime; + if (waitResult.kind === 'completed') { + try { + await replayRendererThroughSeq(waitResult.seq); + } catch { + // The run already completed and was committed to the event log. Do not + // turn a best-effort renderer catch-up failure into a command retry + // hazard; replay-driven snapshots can catch up on the next request. + } + } + return { accepted: true as const, - completed: pollResult.matched, - timedOut: !pollResult.matched && !pollResult.exited, + completed: waitResult.kind === 'completed', + timedOut: waitResult.kind === 'timeout', seq, durationMs, - marker: waitMarker, + marker, } satisfies RunResult; }, sendKeys: async (params: unknown) => { @@ -1275,13 +1493,38 @@ export async function runHost(sessionId: string): Promise { // A session actively producing output (e.g., a running build, log tail) // is "in use" and should not be killed for inactivity. lastActivityAt = lastOutputAt; - void eventLog.append('output', { data }).catch(() => { - // Best-effort logging; shutdown should not fail on transient append errors. + void enqueuePtyIngestion(async () => { + await appendSentinelPieces(sentinelScanner.feed(data)); + }).catch((error: unknown) => { + // Run-completion sentinels make serialized PTY ingestion part of the + // canonical event-log contract: if appending output/control events fails, + // the log can no longer be trusted to drive waits or replay artifacts. + rethrowAsync(error); }); }); pty.onExit(({ exitCode, signal }) => { - handlePtyExit(exitCode, signal ?? null); + let ingestionError: unknown; + + void enqueuePtyIngestion(async () => { + await appendSentinelPieces(sentinelScanner.flush()); + await appendFlushedPostambleEchoOutput(); + }) + .catch((error: unknown) => { + ingestionError = error; + }) + .finally(() => { + try { + handlePtyExit(exitCode, signal ?? null); + } finally { + if (ingestionError !== undefined) { + // Still record PTY exit state first; the ingestion failure is + // surfaced asynchronously after exit handling has run. + rethrowAsync(ingestionError); + } + } + }) + .catch(rethrowAsync); }); startIdlePolling(); diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts new file mode 100644 index 0000000..d7d8fbd --- /dev/null +++ b/src/host/runCompletionSentinel.ts @@ -0,0 +1,754 @@ +/** + * Run-completion sentinels are framed as APC control strings: + * ESC _ agent-tty:run-complete: ESC \ + * + * APC gives agent-tty a private ESC-based control string whose bytes are easy to + * recognize before PTY output reaches the event log, while the ST terminator + * (ESC backslash) makes the frame boundary explicit. The scanner does not rely + * on renderer filtering and removes only exact active frames for production run + * markers. Production markers have a fixed length, so two active sentinels + * cannot prefix each other. + */ + +import { invariant } from '../util/assert.js'; + +export const RUN_COMPLETE_SENTINEL_PREFIX = '\x1b_agent-tty:run-complete:'; +export const RUN_COMPLETE_SENTINEL_SUFFIX = '\x1b\\'; +const RUN_COMPLETE_SHORT_SENTINEL_PREFIX = '\x1b_at'; +const RUN_COMPLETE_SHORT_SENTINEL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{6}$/u; +export const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; + +const MIN_TOLERANT_ECHO_PREFIX_LENGTH = String.raw`printf '\033\137`.length; +const MAX_SKIPPABLE_ECHO_CONTROL_LENGTH = 64; +const CARRIAGE_RETURN_CODE = 0x0d; +const ESC_CODE = 0x1b; +const POSTAMBLE_ECHO_START_CODE = 'p'.charCodeAt(0); + +type SkippableControlMatch = + | { kind: 'complete'; length: number } + | { kind: 'partial' }; + +export type SentinelPiece = + | { type: 'output'; data: string } + | { type: 'run_complete'; marker: string }; + +interface ActiveSentinel { + marker: string; + sentinel: string; +} + +interface TolerantEchoCandidate { + marker: string; + echo: string; + index: number; +} + +interface TolerantEchoStripState { + candidates: TolerantEchoCandidate[]; + dropControl: 'escape' | 'csi' | null; +} + +interface ActivePostambleEcho { + echoes: readonly string[]; +} + +function assertRunMarker(marker: string): void { + invariant(typeof marker === 'string', 'marker must be a string'); + invariant( + RUN_MARKER_PATTERN.test(marker), + 'run marker must match expected format', + ); +} + +function assertNonEmptyString(value: string, label: string): void { + invariant( + typeof value === 'string' && value.length > 0, + `${label} must be a non-empty string`, + ); +} + +interface EchoPrefixScanResult { + complete: boolean; + consumedLength: number; + echoIndex: number; + pending: boolean; +} + +interface TolerantEchoPrefixMatch { + candidates: TolerantEchoCandidate[]; + completed: boolean; + consumedLength: number; +} + +function isCsiFinalByte(code: number): boolean { + return code >= 0x40 && code <= 0x7e; +} + +function findSkippableEchoControl( + buffer: string, + index: number, +): SkippableControlMatch | undefined { + invariant(typeof buffer === 'string', 'buffer must be a string'); + invariant( + Number.isInteger(index) && index >= 0 && index < buffer.length, + 'control scan index must point inside the buffer', + ); + + if (buffer.charCodeAt(index) === CARRIAGE_RETURN_CODE) { + return { kind: 'complete', length: 1 }; + } + + if (buffer.charCodeAt(index) !== ESC_CODE) { + return undefined; + } + + if (index + 1 >= buffer.length) { + return { kind: 'partial' }; + } + + if (buffer.charAt(index + 1) !== '[') { + return { kind: 'complete', length: 2 }; + } + + const controlEnd = Math.min( + buffer.length, + index + MAX_SKIPPABLE_ECHO_CONTROL_LENGTH, + ); + for (let cursor = index + 2; cursor < controlEnd; cursor += 1) { + if (isCsiFinalByte(buffer.charCodeAt(cursor))) { + return { kind: 'complete', length: cursor - index + 1 }; + } + } + + if (controlEnd < buffer.length) { + return undefined; + } + + return { kind: 'partial' }; +} + +function scanEchoPrefixToleratingControls( + remaining: string, + echo: string, +): EchoPrefixScanResult { + invariant(typeof remaining === 'string', 'remaining buffer must be a string'); + assertNonEmptyString(echo, 'echo'); + + let bufferIndex = 0; + let echoIndex = 0; + + while (bufferIndex < remaining.length && echoIndex < echo.length) { + const control = findSkippableEchoControl(remaining, bufferIndex); + if (control?.kind === 'partial') { + return { + complete: false, + consumedLength: bufferIndex, + echoIndex, + pending: true, + }; + } + if (control?.kind === 'complete') { + bufferIndex += control.length; + continue; + } + + if (remaining.charCodeAt(bufferIndex) !== echo.charCodeAt(echoIndex)) { + return { + complete: false, + consumedLength: bufferIndex, + echoIndex, + pending: false, + }; + } + + bufferIndex += 1; + echoIndex += 1; + } + + return { + complete: echoIndex === echo.length, + consumedLength: bufferIndex, + echoIndex, + pending: bufferIndex === remaining.length && echoIndex < echo.length, + }; +} + +function pushOutputPiece(pieces: SentinelPiece[], data: string): void { + if (data.length > 0) { + pieces.push({ type: 'output', data }); + } +} + +function postambleEchoVariants(postamble: string): readonly string[] { + assertNonEmptyString(postamble, 'postamble'); + invariant( + postamble.endsWith('\n'), + 'run-completion postamble must end with newline', + ); + + const crlfEcho = `${postamble.slice(0, -1)}\r\n`; + return crlfEcho === postamble ? [postamble] : [crlfEcho, postamble]; +} + +export function buildRunCompleteSignalSentinel(token: string): string { + invariant( + RUN_COMPLETE_SHORT_SENTINEL_TOKEN_PATTERN.test(token), + 'run-completion signal token must be six base64url characters', + ); + + return `${RUN_COMPLETE_SHORT_SENTINEL_PREFIX}${token}${RUN_COMPLETE_SENTINEL_SUFFIX}`; +} + +export function buildRunCompleteSentinel(marker: string): string { + assertRunMarker(marker); + + return `${RUN_COMPLETE_SENTINEL_PREFIX}${marker}${RUN_COMPLETE_SENTINEL_SUFFIX}`; +} + +/** + * Removes the shell's echo of agent-tty's injected completion postamble while + * preserving command output that can arrive between echoed postamble bytes. + * Canonical TTY echo and readline repainting can interleave command output or + * cursor controls into the echoed postamble, so after a long active-postamble + * prefix match (tolerating known CSI repaint controls and line-wrap carriage + * returns) this sanitizer drops only the remaining expected postamble bytes and + * controls; nonmatching bytes continue through as user output. + */ +export class RunCompletionPostambleEchoSanitizer { + readonly #activeEchoes = new Map(); + #tolerantStripState: TolerantEchoStripState | null = null; + #pendingTail = ''; + + public register(marker: string, postamble: string): void { + assertRunMarker(marker); + + this.#activeEchoes.set(marker, { + echoes: postambleEchoVariants(postamble), + }); + this.#assertPendingTailBound(); + } + + public deregister(marker: string): string { + assertRunMarker(marker); + this.#activeEchoes.delete(marker); + + if (this.#tolerantStripState !== null) { + const candidates = this.#tolerantStripState.candidates.filter( + (candidate) => candidate.marker !== marker, + ); + this.#tolerantStripState = + candidates.length === 0 + ? null + : { candidates, dropControl: this.#tolerantStripState.dropControl }; + } + + if (this.#pendingTail.length === 0) { + return ''; + } + + const pendingTail = this.#pendingTail; + this.#pendingTail = ''; + return this.#stripBuffer(pendingTail, false); + } + + public feed(chunk: string): string { + invariant(typeof chunk === 'string', 'chunk must be a string'); + + if (chunk.length === 0) { + return ''; + } + + if (!this.hasActiveEchoes() && this.#tolerantStripState === null) { + invariant( + this.#pendingTail.length === 0, + 'postamble echo pending tail must be empty when no echoes are active', + ); + return chunk; + } + + const buffer = `${this.#pendingTail}${chunk}`; + this.#pendingTail = ''; + return this.#stripBuffer(buffer, false); + } + + public flush(): string { + if (this.#pendingTail.length === 0) { + return ''; + } + + const pendingTail = this.#pendingTail; + this.#pendingTail = ''; + return this.#stripBuffer(pendingTail, true); + } + + public hasActiveEchoes(): boolean { + return this.#activeEchoes.size > 0; + } + + #stripBuffer(buffer: string, isFinal: boolean): string { + if (buffer.length === 0) { + return ''; + } + + if (!this.hasActiveEchoes() && this.#tolerantStripState === null) { + invariant( + this.#pendingTail.length === 0, + 'postamble echo pending tail must stay empty without active echoes', + ); + return buffer; + } + + let output = ''; + let outputStart = 0; + let index = 0; + + while (index < buffer.length) { + if (this.#tolerantStripState !== null) { + output += buffer.slice(outputStart, index); + const result = this.#consumeTolerantEchoByte(buffer, index); + if (result.output.length > 0) { + output += result.output; + } + index = result.nextIndex; + outputStart = index; + continue; + } + + if (!this.hasActiveEchoes()) { + output += buffer.slice(outputStart); + outputStart = buffer.length; + break; + } + + if (buffer.charCodeAt(index) !== POSTAMBLE_ECHO_START_CODE) { + index += 1; + continue; + } + + const matchedEcho = this.#findCompleteEchoMatch(buffer, index); + if (matchedEcho !== undefined) { + output += buffer.slice(outputStart, index); + index += matchedEcho.length; + outputStart = index; + continue; + } + + const remaining = buffer.slice(index); + const tolerantMatch = this.#findTolerantEchoPrefixMatch(remaining); + if (tolerantMatch !== undefined) { + output += buffer.slice(outputStart, index); + index += tolerantMatch.consumedLength; + outputStart = index; + if (!tolerantMatch.completed) { + this.#tolerantStripState = { + candidates: tolerantMatch.candidates, + dropControl: null, + }; + } + continue; + } + + if (!isFinal && this.#hasPartialEchoMatch(remaining)) { + output += buffer.slice(outputStart, index); + this.#setPendingTail(remaining); + return output; + } + + index += 1; + } + + output += buffer.slice(outputStart); + this.#assertPendingTailBound(); + return output; + } + + #consumeTolerantEchoByte( + buffer: string, + index: number, + ): { nextIndex: number; output: string } { + const state = this.#tolerantStripState; + invariant(state !== null, 'tolerant postamble echo strip state must exist'); + + const char = buffer.charAt(index); + invariant(char.length === 1, 'tolerant strip must consume one code unit'); + + if (state.dropControl === 'escape') { + state.dropControl = char === '[' ? 'csi' : null; + return { nextIndex: index + 1, output: '' }; + } + + if (state.dropControl === 'csi') { + if (/^[\x40-\x7e]$/u.test(char)) { + state.dropControl = null; + } + return { nextIndex: index + 1, output: '' }; + } + + if (char === '\r') { + return { nextIndex: index + 1, output: '' }; + } + + if (char === '\x1b') { + state.dropControl = 'escape'; + return { nextIndex: index + 1, output: '' }; + } + + const advancedCandidates = state.candidates + .filter(({ echo, index: candidateIndex }) => + echo.startsWith(char, candidateIndex), + ) + .map((candidate) => ({ + ...candidate, + index: candidate.index + 1, + })); + + if (advancedCandidates.length === 0) { + return { nextIndex: index + 1, output: char }; + } + + const completed = advancedCandidates.some( + ({ echo, index: candidateIndex }) => candidateIndex === echo.length, + ); + this.#tolerantStripState = completed + ? null + : { candidates: advancedCandidates, dropControl: null }; + return { nextIndex: index + 1, output: '' }; + } + + #findTolerantEchoPrefixMatch( + remaining: string, + ): TolerantEchoPrefixMatch | undefined { + let completedConsumedLength: number | undefined; + let bestEchoIndex = 0; + let bestConsumedLength = 0; + let candidates: TolerantEchoCandidate[] = []; + + for (const [marker, { echoes }] of this.#activeEchoes) { + for (const echo of echoes) { + const scan = scanEchoPrefixToleratingControls(remaining, echo); + if (scan.complete) { + invariant( + scan.echoIndex === echo.length, + 'complete postamble echo scan must consume the full echo', + ); + completedConsumedLength = Math.max( + completedConsumedLength ?? 0, + scan.consumedLength, + ); + continue; + } + if (scan.echoIndex < MIN_TOLERANT_ECHO_PREFIX_LENGTH) { + continue; + } + + if ( + scan.echoIndex > bestEchoIndex || + (scan.echoIndex === bestEchoIndex && + scan.consumedLength > bestConsumedLength) + ) { + bestEchoIndex = scan.echoIndex; + bestConsumedLength = scan.consumedLength; + candidates = []; + } + if ( + scan.echoIndex === bestEchoIndex && + scan.consumedLength === bestConsumedLength + ) { + candidates.push({ echo, index: scan.echoIndex, marker }); + } + } + } + + if (completedConsumedLength !== undefined) { + return { + candidates: [], + completed: true, + consumedLength: completedConsumedLength, + }; + } + + if (bestEchoIndex === 0) { + return undefined; + } + + invariant( + candidates.length > 0, + 'tolerant postamble echo prefix match must have candidates', + ); + return { + candidates, + completed: false, + consumedLength: bestConsumedLength, + }; + } + + #findCompleteEchoMatch(buffer: string, index: number): string | undefined { + let matchedEcho: string | undefined; + + for (const { echoes } of this.#activeEchoes.values()) { + for (const echo of echoes) { + if (!buffer.startsWith(echo, index)) { + continue; + } + + invariant( + matchedEcho === undefined || matchedEcho === echo, + 'postamble echo matches must be unambiguous', + ); + matchedEcho = echo; + } + } + + return matchedEcho; + } + + #hasPartialEchoMatch(remaining: string): boolean { + const maxEchoLength = this.#maxActiveEchoLength(); + if (remaining.length >= maxEchoLength) { + return false; + } + + for (const { echoes } of this.#activeEchoes.values()) { + for (const echo of echoes) { + const scan = scanEchoPrefixToleratingControls(remaining, echo); + if (scan.pending && scan.echoIndex > 0) { + return true; + } + } + } + + return false; + } + + #setPendingTail(tail: string): void { + invariant(tail.length > 0, 'postamble echo pending tail must not be empty'); + invariant( + this.hasActiveEchoes(), + 'postamble echo pending tail requires active echoes', + ); + + this.#pendingTail = tail; + this.#assertPendingTailBound(); + } + + #maxActiveEchoLength(): number { + let maxLength = 0; + + for (const { echoes } of this.#activeEchoes.values()) { + for (const echo of echoes) { + maxLength = Math.max(maxLength, echo.length); + } + } + + invariant(maxLength > 0, 'max active echo length requires active echoes'); + return maxLength; + } + + #assertPendingTailBound(): void { + if (!this.hasActiveEchoes()) { + invariant( + this.#pendingTail.length === 0, + 'postamble echo pending tail must be empty without active echoes', + ); + return; + } + + invariant( + this.#pendingTail.length < this.#maxActiveEchoLength(), + 'postamble echo pending tail must be shorter than the longest active echo', + ); + } +} + +export class RunCompletionSentinelScanner { + readonly #activeSentinels = new Map(); + #pendingTail = ''; + + /** + * Registers a marker as active. Re-registering an already-active marker is a + * no-op; after the marker completes and deactivates, a later register() call + * activates it again for a future run. + */ + public register( + marker: string, + sentinel = buildRunCompleteSentinel(marker), + ): void { + assertRunMarker(marker); + assertNonEmptyString(sentinel, 'run-completion sentinel'); + + if (this.#activeSentinels.has(marker)) { + return; + } + + this.#activeSentinels.set(marker, { + marker, + sentinel, + }); + this.#assertPendingTailBound(); + } + + public feed(chunk: string): SentinelPiece[] { + invariant(typeof chunk === 'string', 'chunk must be a string'); + + if (chunk.length === 0) { + return []; + } + + if (!this.hasActiveMarkers()) { + invariant( + this.#pendingTail.length === 0, + 'pending tail must be empty when no run-completion markers are active', + ); + return [{ type: 'output', data: chunk }]; + } + + const buffer = `${this.#pendingTail}${chunk}`; + this.#pendingTail = ''; + return this.#scanBuffer(buffer, false); + } + + public flush(): SentinelPiece[] { + if (this.#pendingTail.length === 0) { + return []; + } + + const buffer = this.#pendingTail; + this.#pendingTail = ''; + return this.#scanBuffer(buffer, true); + } + + public hasActiveMarkers(): boolean { + return this.#activeSentinels.size > 0; + } + + #scanBuffer(buffer: string, isFinal: boolean): SentinelPiece[] { + if (buffer.length === 0) { + return []; + } + + if (!this.hasActiveMarkers()) { + invariant( + this.#pendingTail.length === 0, + 'pending tail must stay empty when no run-completion markers are active', + ); + return [{ type: 'output', data: buffer }]; + } + + const pieces: SentinelPiece[] = []; + let outputStart = 0; + let index = 0; + + while (index < buffer.length) { + if (!this.hasActiveMarkers()) { + pushOutputPiece(pieces, buffer.slice(outputStart)); + outputStart = buffer.length; + break; + } + + if (buffer.charCodeAt(index) !== ESC_CODE) { + index += 1; + continue; + } + + const matched = this.#findCompleteSentinelMatch(buffer, index); + if (matched !== undefined) { + pushOutputPiece(pieces, buffer.slice(outputStart, index)); + pieces.push({ type: 'run_complete', marker: matched.marker }); + + const deleted = this.#activeSentinels.delete(matched.marker); + invariant(deleted, 'matched run-completion marker must be active'); + + index += matched.sentinel.length; + outputStart = index; + continue; + } + + const remaining = buffer.slice(index); + if (!isFinal && this.#hasPartialSentinelMatch(remaining)) { + pushOutputPiece(pieces, buffer.slice(outputStart, index)); + this.#setPendingTail(remaining); + return pieces; + } + + index += 1; + } + + pushOutputPiece(pieces, buffer.slice(outputStart)); + this.#assertPendingTailBound(); + return pieces; + } + + #findCompleteSentinelMatch( + buffer: string, + index: number, + ): ActiveSentinel | undefined { + let matched: ActiveSentinel | undefined; + + for (const activeSentinel of this.#activeSentinels.values()) { + if (!buffer.startsWith(activeSentinel.sentinel, index)) { + continue; + } + + invariant( + matched === undefined, + 'fixed-length run sentinels must match at most one active marker', + ); + matched = activeSentinel; + } + + return matched; + } + + #hasPartialSentinelMatch(remaining: string): boolean { + for (const { sentinel } of this.#activeSentinels.values()) { + if ( + remaining.length < sentinel.length && + sentinel.startsWith(remaining) + ) { + return true; + } + } + + return false; + } + + #setPendingTail(tail: string): void { + invariant(tail.length > 0, 'pending tail must not be empty'); + invariant( + this.hasActiveMarkers(), + 'pending tail requires active run-completion markers', + ); + + this.#pendingTail = tail; + this.#assertPendingTailBound(); + } + + #maxActiveSentinelLength(): number { + let maxLength = 0; + + for (const { sentinel } of this.#activeSentinels.values()) { + maxLength = Math.max(maxLength, sentinel.length); + } + + invariant( + maxLength > 0, + 'max active sentinel length requires active run-completion markers', + ); + return maxLength; + } + + #assertPendingTailBound(): void { + if (!this.hasActiveMarkers()) { + invariant( + this.#pendingTail.length === 0, + 'pending tail must be empty without active run-completion markers', + ); + return; + } + + invariant( + this.#pendingTail.length < this.#maxActiveSentinelLength(), + 'pending tail must be shorter than the longest active sentinel', + ); + } +} diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index e2d3244..0f47f30 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -128,6 +128,16 @@ export const InputRunEventPayloadSchema = z }); export type InputRunEventPayload = z.infer; +export const RunCompleteEventPayloadSchema = z + .object({ + marker: z.string(), + inputRunSeq: NonNegativeIntSchema.optional(), + }) + .strict(); +export type RunCompleteEventPayload = z.infer< + typeof RunCompleteEventPayloadSchema +>; + export const ResizeEventPayloadSchema = z .object({ cols: PositiveIntSchema, @@ -166,6 +176,7 @@ export const EventTypeSchema = z.enum([ 'input_paste', 'input_keys', 'input_run', + 'run_complete', 'resize', 'signal', 'exit', @@ -218,6 +229,14 @@ export const InputRunEventRecordSchema = z }) .strict(); +export const RunCompleteEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('run_complete'), + payload: RunCompleteEventPayloadSchema, + }) + .strict(); + export const ResizeEventRecordSchema = z .object({ ...EventRecordBaseShape, @@ -256,6 +275,7 @@ export const EventRecordSchema = z.discriminatedUnion('type', [ InputPasteEventRecordSchema, InputKeysEventRecordSchema, InputRunEventRecordSchema, + RunCompleteEventRecordSchema, ResizeEventRecordSchema, SignalEventRecordSchema, ExitEventRecordSchema, diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index 01a3c3d..d667582 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -1464,6 +1464,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { case 'input_paste': case 'input_keys': case 'input_run': + case 'run_complete': case 'signal': case 'exit': { await flushOutputBatch(); @@ -1722,6 +1723,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { case 'input_paste': case 'input_keys': case 'input_run': + case 'run_complete': case 'signal': case 'exit': { await flushOutputBatch(); diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index 6a065b0..8c734b1 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -464,6 +464,7 @@ export class LibghosttyVtBackend implements RendererBackend { case 'input_paste': case 'input_keys': case 'input_run': + case 'run_complete': case 'signal': case 'exit': break; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 989e9ad..421a2ef 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { MarkerEventPayloadSchema, RichSnapshotLineSchema, + RunCompleteEventPayloadSchema, VisibleLineSchema, type VisibleLine, } from '../protocol/schemas.js'; @@ -95,6 +96,15 @@ const InputRunReplayEventSchema = z }) .strict(); +const RunCompleteReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('run_complete'), + payload: RunCompleteEventPayloadSchema, + }) + .strict(); + const ResizeReplayEventSchema = z .object({ seq: NonNegativeIntSchema, @@ -151,6 +161,7 @@ export const ReplayEventSchema = z.discriminatedUnion('type', [ InputPasteReplayEventSchema, InputKeysReplayEventSchema, InputRunReplayEventSchema, + RunCompleteReplayEventSchema, ResizeReplayEventSchema, MarkerReplayEventSchema, SignalReplayEventSchema, diff --git a/test/helpers.ts b/test/helpers.ts index c3b45bd..1ad4ea2 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -116,11 +116,26 @@ export async function cleanupHome(home: string): Promise { await rm(home, { recursive: true, force: true }); } +interface CreateSessionOptions { + cols?: number; + rows?: number; +} + export function createSession( testHome: string, command: string[] = ['/bin/sh', '-c', 'exec cat'], + options: CreateSessionOptions = {}, ): string { - const result = runCli(['create', '--json', '--', ...command], { + const args = ['create', '--json']; + if (options.cols !== undefined) { + args.push('--cols', String(options.cols)); + } + if (options.rows !== undefined) { + args.push('--rows', String(options.rows)); + } + args.push('--', ...command); + + const result = runCli(args, { AGENT_TTY_HOME: testHome, }); expect(result.status).toBe(0); diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index 61d4899..d0edc5e 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -1,4 +1,9 @@ -import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,10 +16,73 @@ import { readEvents, runCli, sleep, + type EventRecord, type SuccessEnvelope, } from '../helpers.js'; +import { RUN_MARKER_PATTERN } from '../../src/host/runCompletionSentinel.js'; import type { CommandErrorEnvelope } from '../../src/protocol/envelope.js'; +function expectRunMarker(marker: string): string { + const match = RUN_MARKER_PATTERN.exec(marker); + expect(match).not.toBeNull(); + + const markerPayload = match?.[1]; + if (markerPayload === undefined) { + throw new Error('expected run marker payload to be captured'); + } + + return markerPayload; +} + +function collectOutputText(events: EventRecord[]): string { + return events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + if (typeof data !== 'string') { + throw new Error('output event payload data must be a string'); + } + return data; + }) + .join(''); +} + +function expectCompletionArtifactsClean(text: string, marker: string): void { + const markerPayload = expectRunMarker(marker); + const markerPayloadPart1 = markerPayload.slice(0, 16); + const markerPayloadPart2 = markerPayload.slice(16); + + expect(text).not.toContain('__AT_MARKER_'); + expect(text).not.toContain('__AT_'); + expect(text).not.toContain('MARKER_'); + expect(text).not.toContain("printf '\\033"); + expect(text).not.toContain('agent-tty:run-complete:'); + expect(text).not.toContain(markerPayload); + expect(text).not.toContain(markerPayloadPart1); + expect(text).not.toContain(markerPayloadPart2); + expect(text).not.toContain('\x1b_at'); + expect(text).not.toContain('\x1b_agent-tty'); + expect(text).not.toContain(`\x1b_agent-tty:run-complete:${marker}\x1b\\`); +} + +function collectAsciicastOutputFrameText(contents: string): string { + return contents + .trim() + .split('\n') + .slice(1) + .map((line) => JSON.parse(line) as unknown) + .filter( + (frame): frame is [number, 'o', string] => + Array.isArray(frame) && + frame[1] === 'o' && + typeof frame[2] === 'string', + ) + .map((frame) => frame[2]) + .join(''); +} + +const WIDE_ARTIFACT_PROOF_COLS = 200; + let testHome = ''; let sessionId = ''; @@ -198,17 +266,12 @@ describe('run command integration', { timeout: 45_000 }, () => { }); }); - it('returns timedOut when marker is not found within timeout', async () => { - // Disable terminal echo so the injected marker does not appear in visible output. - sessionId = createSession(testHome, [ - '/bin/sh', - '-c', - 'stty -echo; exec sleep 60', - ]); + it('returns timedOut when run completion is not observed within timeout', async () => { + sessionId = createSession(testHome, ['/bin/bash']); await sleep(500); const result = runCli( - ['run', sessionId, 'echo delayed', '--timeout', '2000', '--json'], + ['run', sessionId, 'sleep 5', '--timeout', '300', '--json'], testEnv(), 30_000, ); @@ -228,8 +291,112 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.accepted).toBe(true); expect(envelope.result.timedOut).toBe(true); expect(envelope.result.completed).toBe(false); - expect(envelope.result.durationMs).toBeGreaterThanOrEqual(1500); - expect(envelope.result.marker).toMatch(/^__AT_MARKER_/); + expect(envelope.result.durationMs).toBeGreaterThanOrEqual(250); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + expect( + events.some( + (event) => + event.type === 'run_complete' && event.payload.marker === marker, + ), + ).toBe(false); + }); + + it('preserves command output in line-discipline echo shells', async () => { + sessionId = createSession(testHome, ['/bin/sh'], { + cols: WIDE_ARTIFACT_PROOF_COLS, + }); + await sleep(1000); + + const result = runCli( + [ + 'run', + sessionId, + "printf 'dash-before-proof\\n'; sleep 0.1; printf 'dash-after-proof\\n'", + '--timeout', + '15000', + '--json', + ], + testEnv(), + 30_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + accepted: true; + completed: boolean; + timedOut: boolean; + seq: number; + durationMs: number; + marker: string; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result.accepted).toBe(true); + expect(envelope.result.completed).toBe(true); + expect(envelope.result.timedOut).toBe(false); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + const outputText = collectOutputText(events); + expect(outputText).toContain('dash-before-proof'); + expect(outputText).toContain('dash-after-proof'); + expectCompletionArtifactsClean(outputText, marker); + }); + + it('keeps later output visible after a timed-out line-discipline echo run', async () => { + sessionId = createSession(testHome, ['/bin/sh'], { + cols: WIDE_ARTIFACT_PROOF_COLS, + }); + await sleep(1000); + + const result = runCli( + ['run', sessionId, 'cat', '--timeout', '300', '--json'], + testEnv(), + 30_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + accepted: true; + completed: boolean; + timedOut: boolean; + seq: number; + durationMs: number; + marker: string; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result.accepted).toBe(true); + expect(envelope.result.completed).toBe(false); + expect(envelope.result.timedOut).toBe(true); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const typeResult = runCli( + [ + 'type', + sessionId, + 'timeout-still-visible', + '--append-newline', + '--json', + ], + testEnv(), + 30_000, + ); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + await sleep(500); + + const events = await readEvents(testHome, sessionId); + const outputText = collectOutputText(events); + expect(outputText).toContain('timeout-still-visible'); + expectCompletionArtifactsClean(outputText, marker); }); it('detects session exit during wait before timing out', async () => { @@ -264,12 +431,105 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.durationMs).toBeLessThan(10_000); }); - it('completes when marker is found in rendered output', async () => { - sessionId = createSession(testHome, ['/bin/bash']); + it('does not log postamble cursor controls when shell echo is disabled', async () => { + sessionId = createSession( + testHome, + ['/bin/bash', '--noprofile', '--norc'], + { + cols: WIDE_ARTIFACT_PROOF_COLS, + }, + ); await sleep(1000); + const disableEchoResult = runCli( + ['run', sessionId, 'stty -echo', '--timeout', '15000', '--json'], + testEnv(), + 30_000, + ); + expect(disableEchoResult.status).toBe(0); + expect(disableEchoResult.stderr).toBe(''); + const result = runCli( - ['run', sessionId, 'echo hello', '--timeout', '15000', '--json'], + [ + 'run', + sessionId, + "printf 'noecho-before-proof\\n'; printf 'noecho-after-proof\\n'", + '--timeout', + '15000', + '--json', + ], + testEnv(), + 30_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + accepted: true; + completed: boolean; + timedOut: boolean; + seq: number; + durationMs: number; + marker: string; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result.accepted).toBe(true); + expect(envelope.result.completed).toBe(true); + expect(envelope.result.timedOut).toBe(false); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + const outputText = collectOutputText(events); + expect(outputText).toContain('noecho-before-proof'); + expect(outputText).toContain('noecho-after-proof'); + expect(outputText).not.toContain('\x1b[1A'); + expect(outputText).not.toContain('\x1b[2K'); + expectCompletionArtifactsClean(outputText, marker); + + const snapshotResult = runCli( + [ + 'snapshot', + sessionId, + '--format', + 'text', + '--include-scrollback', + '--json', + ], + testEnv(), + 30_000, + ); + expect(snapshotResult.status).toBe(0); + expect(snapshotResult.stderr).toBe(''); + const snapshotEnvelope = JSON.parse( + snapshotResult.stdout, + ) as SuccessEnvelope<{ + text: string; + }>; + expect(snapshotEnvelope.ok).toBe(true); + expect(snapshotEnvelope.result.text).toContain('noecho-before-proof'); + expect(snapshotEnvelope.result.text).toContain('noecho-after-proof'); + expect(snapshotEnvelope.result.text).not.toContain('\x1b[1A'); + expect(snapshotEnvelope.result.text).not.toContain('\x1b[2K'); + expectCompletionArtifactsClean(snapshotEnvelope.result.text, marker); + }); + + it('records structured run completion without leaking sentinel text to artifacts', async () => { + sessionId = createSession(testHome, ['/bin/bash'], { + cols: WIDE_ARTIFACT_PROOF_COLS, + }); + await sleep(1000); + + const result = runCli( + [ + 'run', + sessionId, + "printf 'before-clean-marker-proof\\n'; printf 'after-clean-marker-proof\\n'", + '--timeout', + '15000', + '--json', + ], testEnv(), 30_000, ); @@ -290,6 +550,82 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.completed).toBe(true); expect(envelope.result.timedOut).toBe(false); expect(envelope.result.durationMs).toBeTypeOf('number'); - expect(envelope.result.marker).toMatch(/^__AT_MARKER_/); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + const runCompleteEvents = events.filter( + (event) => + event.type === 'run_complete' && event.payload.marker === marker, + ); + expect(runCompleteEvents).toHaveLength(1); + + const [runCompleteEvent] = runCompleteEvents; + if (runCompleteEvent === undefined) { + throw new Error('expected run_complete event to exist'); + } + const inputRunSeq = runCompleteEvent.payload.inputRunSeq; + expect(inputRunSeq).toBeDefined(); + if (inputRunSeq === undefined) { + throw new Error('run_complete inputRunSeq must be defined'); + } + expect(inputRunSeq).toBeTypeOf('number'); + const inputRunEvent = events.find((event) => event.seq === inputRunSeq); + expect(inputRunEvent?.type).toBe('input_run'); + expect(inputRunEvent?.payload).toMatchObject({ marker }); + + const outputText = collectOutputText(events); + expect(outputText).toContain('before-clean-marker-proof'); + expect(outputText).toContain('after-clean-marker-proof'); + expectCompletionArtifactsClean(outputText, marker); + + const snapshotResult = runCli( + [ + 'snapshot', + sessionId, + '--format', + 'text', + '--include-scrollback', + '--json', + ], + testEnv(), + 30_000, + ); + expect(snapshotResult.status).toBe(0); + expect(snapshotResult.stderr).toBe(''); + const snapshotEnvelope = JSON.parse( + snapshotResult.stdout, + ) as SuccessEnvelope<{ + text: string; + }>; + expect(snapshotEnvelope.ok).toBe(true); + expect(snapshotEnvelope.result.text).toContain('before-clean-marker-proof'); + expect(snapshotEnvelope.result.text).toContain('after-clean-marker-proof'); + expectCompletionArtifactsClean(snapshotEnvelope.result.text, marker); + + const asciicastPath = join(testHome, 'run-cleanliness.cast'); + const exportResult = runCli( + [ + 'record', + 'export', + sessionId, + '--format', + 'asciicast', + '--out', + asciicastPath, + '--json', + ], + testEnv(), + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const asciicastOutputText = collectAsciicastOutputFrameText( + readFileSync(asciicastPath, 'utf8'), + ); + expect(asciicastOutputText).toContain('before-clean-marker-proof'); + expect(asciicastOutputText).toContain('after-clean-marker-proof'); + expectCompletionArtifactsClean(asciicastOutputText, marker); }); }); diff --git a/test/unit/export/asciicast.test.ts b/test/unit/export/asciicast.test.ts index 756a341..86ddd44 100644 --- a/test/unit/export/asciicast.test.ts +++ b/test/unit/export/asciicast.test.ts @@ -170,6 +170,43 @@ describe('generateAsciicast', () => { expect(result.durationMs).toBe(2000); }); + it('skips run_complete events without emitting asciicast frames', () => { + const manifest = createManifest(); + const events: EventRecord[] = [ + { + seq: 0, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'before' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:01.500Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_done__', inputRunSeq: 0 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'after' }, + }, + ]; + + const result = generateAsciicast('session-01', manifest, events); + + expect(parseAsciicastLines(result.contents)).toEqual([ + result.header, + [0, 'o', 'before'], + [1, 'o', 'after'], + ]); + expect(result.outputEventCount).toBe(2); + expect(result.resizeEventCount).toBe(0); + expect(result.markerCount).toBe(0); + expect(result.capturedAtSeq).toBe(2); + expect(result.durationMs).toBe(1000); + }); + it('produces a header-only cast for empty event logs', () => { const manifest = createManifest({ createdAt: '2026-03-19T12:34:56.000Z', diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index fd4a440..3c127e8 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -119,6 +119,61 @@ describe('EventLog', () => { } }); + it('appends run_complete events and round-trips them from JSONL', async () => { + const eventLog = await EventLog.open(eventLogPath); + + try { + const firstSeq = await eventLog.append('run_complete', { + marker: '__AT_MARKER_first__', + }); + const secondSeq = await eventLog.append('run_complete', { + marker: '__AT_MARKER_second__', + inputRunSeq: 12, + }); + + expect([firstSeq, secondSeq]).toEqual([0, 1]); + expect(await eventLog.readAll()).toEqual([ + expect.objectContaining({ + seq: 0, + type: 'run_complete', + payload: { marker: '__AT_MARKER_first__' }, + }), + expect.objectContaining({ + seq: 1, + type: 'run_complete', + payload: { marker: '__AT_MARKER_second__', inputRunSeq: 12 }, + }), + ]); + } finally { + await eventLog.close(); + } + + const reopenedEventLog = await EventLog.open(eventLogPath); + + try { + expect(reopenedEventLog.getEvents()).toEqual([ + expect.objectContaining({ + seq: 0, + type: 'run_complete', + payload: { marker: '__AT_MARKER_first__' }, + }), + expect.objectContaining({ + seq: 1, + type: 'run_complete', + payload: { marker: '__AT_MARKER_second__', inputRunSeq: 12 }, + }), + ]); + + const logLines = (await readFile(eventLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown); + expect(logLines).toEqual(reopenedEventLog.getEvents()); + } finally { + await reopenedEventLog.close(); + } + }); + it('returns buffered events without rereading the log file', async () => { const eventLog = await EventLog.open(eventLogPath); diff --git a/test/unit/host/replay.test.ts b/test/unit/host/replay.test.ts index 26432a6..65d4aa4 100644 --- a/test/unit/host/replay.test.ts +++ b/test/unit/host/replay.test.ts @@ -221,6 +221,54 @@ describe('replay helpers', () => { ); }); + it('readEventLogRecords parses legacy JSONL logs without run_complete events', async () => { + const legacyEvents: EventRecord[] = [ + { + seq: 0, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'legacy output' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:03.000Z', + type: 'input_run', + payload: { + command: 'echo done', + marker: '__AT_MARKER_legacy__', + noWait: false, + }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:04.000Z', + type: 'exit', + payload: { exitCode: 0, exitSignal: null }, + }, + ]; + await writeFile( + eventLogPath, + legacyEvents + .map((event) => JSON.stringify(event)) + .concat('') + .join('\n'), + 'utf8', + ); + + await expect(readEventLogRecords(eventLogPath)).resolves.toEqual( + legacyEvents, + ); + expect( + buildReplayInput('session-01', createManifest(), legacyEvents), + ).toEqual({ + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: legacyEvents, + targetSeq: 2, + }); + }); + it('readEventLogRecords parses and validates JSONL event logs', async () => { await writeFile( eventLogPath, diff --git a/test/unit/host/runCompletionSentinel.test.ts b/test/unit/host/runCompletionSentinel.test.ts new file mode 100644 index 0000000..d1d0974 --- /dev/null +++ b/test/unit/host/runCompletionSentinel.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildRunCompleteSentinel, + buildRunCompleteSignalSentinel, + RUN_COMPLETE_SENTINEL_PREFIX, + RUN_COMPLETE_SENTINEL_SUFFIX, + RunCompletionPostambleEchoSanitizer, + RunCompletionSentinelScanner, +} from '../../../src/host/runCompletionSentinel.js'; +import type { SentinelPiece } from '../../../src/host/runCompletionSentinel.js'; + +function runMarker(value: number): string { + return `__AT_MARKER_${value.toString(16).padStart(32, '0')}__`; +} + +function feedChunks( + scanner: RunCompletionSentinelScanner, + chunks: string[], +): SentinelPiece[] { + return chunks.flatMap((chunk) => scanner.feed(chunk)); +} + +function outputData(pieces: SentinelPiece[]): string { + return pieces + .filter( + (piece): piece is Extract => + piece.type === 'output', + ) + .map((piece) => piece.data) + .join(''); +} + +function oneCodeUnitChunks(data: string): string[] { + const chunks: string[] = []; + + for (let index = 0; index < data.length; index += 1) { + chunks.push(data.charAt(index)); + } + + return chunks; +} + +function postamble(marker: string): string { + return `printf '${marker}'\n`; +} + +function shellOctalEscapedBytes(value: string): string { + return [...Buffer.from(value, 'utf8')] + .map((byte) => `\\${byte.toString(8).padStart(3, '0')}`) + .join(''); +} + +function productionLikePostamble(marker: string): string { + return `printf '${shellOctalEscapedBytes(buildRunCompleteSentinel(marker))}'\n`; +} + +describe('buildRunCompleteSentinel', () => { + it('returns the expected APC-framed sentinel bytes', () => { + const marker = runMarker(1); + + expect(buildRunCompleteSentinel(marker)).toBe( + `\x1b_agent-tty:run-complete:${marker}\x1b\\`, + ); + expect(RUN_COMPLETE_SENTINEL_PREFIX).toBe('\x1b_agent-tty:run-complete:'); + expect(RUN_COMPLETE_SENTINEL_SUFFIX).toBe('\x1b\\'); + }); + + it('rejects non-production marker formats', () => { + expect(() => buildRunCompleteSentinel('')).toThrow( + 'run marker must match expected format', + ); + expect(() => buildRunCompleteSentinel('__AT_MARKER_123__')).toThrow( + 'run marker must match expected format', + ); + }); +}); + +describe('buildRunCompleteSignalSentinel', () => { + it('returns a short APC-framed signal sentinel', () => { + expect(buildRunCompleteSignalSentinel('abc123')).toBe( + '\x1b_atabc123\x1b\\', + ); + }); + + it('rejects invalid signal tokens', () => { + expect(() => buildRunCompleteSignalSentinel('short')).toThrow( + 'run-completion signal token must be six base64url characters', + ); + }); +}); + +describe('RunCompletionPostambleEchoSanitizer', () => { + it('removes an exact CRLF postamble echo without suppressing later output', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(10); + const echo = postamble(marker); + sanitizer.register(marker, echo); + + expect( + sanitizer.feed( + `command echo\r\n${echo.replace(/\n$/u, '\r\n')}user output\n`, + ), + ).toBe('command echo\r\nuser output\n'); + expect(sanitizer.feed('more output\n')).toBe('more output\n'); + }); + + it('removes an exact LF postamble echo', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(11); + const echo = postamble(marker); + sanitizer.register(marker, echo); + + expect(sanitizer.feed(`before${echo}after`)).toBe('beforeafter'); + }); + + it('removes postamble echoes split across chunks', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(12); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + const split = "printf '".length + 8; + expect(sanitizer.feed(`before${echo.slice(0, split)}`)).toBe('before'); + expect(sanitizer.feed(`${echo.slice(split)}after`)).toBe('after'); + }); + + it('preserves interleaved command output while stripping postamble echo bytes', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(120); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + const split = 24; + expect( + sanitizer.feed( + `${echo.slice(0, split)}visible-output\n${echo.slice(split)}`, + ), + ).toBe('visible-output\n'); + }); + + it('drops line-editor control sequences interleaved into postamble echo', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(121); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + const split = 24; + expect( + sanitizer.feed(`${echo.slice(0, split)}\x1b[A\x1b[K${echo.slice(split)}`), + ).toBe(''); + }); + + it('drops line-editor control sequences inserted before the old tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(122); + const postambleText = productionLikePostamble(marker); + const echo = postambleText.replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postambleText); + + const split = 'pri'.length; + expect( + sanitizer.feed(`${echo.slice(0, split)}\x1b[K${echo.slice(split)}`), + ).toBe(''); + }); + + it('drops line-editor control sequences split across chunks before the tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(123); + const postambleText = productionLikePostamble(marker); + const echo = postambleText.replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postambleText); + + const split = "printf '".length; + expect(sanitizer.feed(`${echo.slice(0, split)}\x1b[`)).toBe(''); + expect(sanitizer.feed(`K${echo.slice(split)}`)).toBe(''); + }); + + it('drops terminal line-wrap carriage returns inserted into the postamble echo', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(125); + const postambleText = productionLikePostamble(marker); + const echo = postambleText.replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postambleText); + + const split = String.raw`printf '\03`.length; + expect( + sanitizer.feed(`${echo.slice(0, split)}\r${echo.slice(split)}`), + ).toBe(''); + }); + + it('preserves printf-like output with carriage returns that diverges before the tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(126); + sanitizer.register(marker, productionLikePostamble(marker)); + + const output = "pri\rntf 'hello'\r\n"; + expect(sanitizer.feed(output)).toBe(output); + }); + + it('preserves printf-like output that diverges before the tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(124); + sanitizer.register(marker, productionLikePostamble(marker)); + + const output = "printf 'hello'\r\n"; + expect(sanitizer.feed(output)).toBe(output); + }); + + it('removes repeated exact postamble text while the marker remains active', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(13); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + expect(sanitizer.feed(`${echo}visible${echo}`)).toBe('visible'); + }); + + it('flushes a pending partial postamble when its marker is deregistered', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(14); + const echo = postamble(marker); + sanitizer.register(marker, echo); + + expect(sanitizer.feed(`visible${echo.slice(0, 7)}`)).toBe('visible'); + expect(sanitizer.deregister(marker)).toBe(echo.slice(0, 7)); + expect(sanitizer.hasActiveEchoes()).toBe(false); + }); + + it('passes data through unchanged when no postamble echoes are active', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const data = `before${postamble(runMarker(15))}after`; + + expect(sanitizer.feed(data)).toBe(data); + expect(sanitizer.flush()).toBe(''); + }); +}); + +describe('RunCompletionSentinelScanner', () => { + it('matches a sentinel fully contained in one chunk with output around it', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(20); + scanner.register(marker); + + expect( + scanner.feed(`before${buildRunCompleteSentinel(marker)}after`), + ).toEqual([ + { type: 'output', data: 'before' }, + { type: 'run_complete', marker }, + { type: 'output', data: 'after' }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + expect(scanner.flush()).toEqual([]); + }); + + it.each([ + ['inside prefix', 1], + [ + 'inside marker payload', + RUN_COMPLETE_SENTINEL_PREFIX.length + runMarker(21).length - 3, + ], + [ + 'inside suffix', + RUN_COMPLETE_SENTINEL_PREFIX.length + runMarker(21).length + 1, + ], + ])( + 'matches a sentinel split across two chunks with boundary %s', + (_, split) => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(21); + const sentinel = buildRunCompleteSentinel(marker); + scanner.register(marker); + + expect(scanner.feed(sentinel.slice(0, split))).toEqual([]); + expect(scanner.feed(sentinel.slice(split))).toEqual([ + { type: 'run_complete', marker }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }, + ); + + it('matches a custom short sentinel registered for a marker', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(210); + const sentinel = buildRunCompleteSignalSentinel('Abc-12'); + scanner.register(marker, sentinel); + + expect(scanner.feed(`before${sentinel}after`)).toEqual([ + { type: 'output', data: 'before' }, + { type: 'run_complete', marker }, + { type: 'output', data: 'after' }, + ]); + }); + + it('matches a sentinel split one byte at a time across the full frame', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(22); + scanner.register(marker); + + const pieces = [ + ...scanner.feed('before-'), + ...feedChunks( + scanner, + oneCodeUnitChunks(buildRunCompleteSentinel(marker)), + ), + ...scanner.feed('-after'), + ]; + + expect(pieces).toEqual([ + { type: 'output', data: 'before-' }, + { type: 'run_complete', marker }, + { type: 'output', data: '-after' }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }); + + it('completes multiple active markers in input order without cross-matching', () => { + const scanner = new RunCompletionSentinelScanner(); + const firstMarker = runMarker(23); + const secondMarker = runMarker(24); + scanner.register(firstMarker); + scanner.register(secondMarker); + + expect( + scanner.feed( + [ + 'start', + buildRunCompleteSentinel(secondMarker), + 'middle', + buildRunCompleteSentinel(firstMarker), + 'end', + ].join(''), + ), + ).toEqual([ + { type: 'output', data: 'start' }, + { type: 'run_complete', marker: secondMarker }, + { type: 'output', data: 'middle' }, + { type: 'run_complete', marker: firstMarker }, + { type: 'output', data: 'end' }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }); + + it('keeps inactive or unknown sentinel-like bytes in output', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(25); + const unknownSentinel = buildRunCompleteSentinel(runMarker(26)); + const strayApc = '\x1b_random text that is not an active sentinel'; + scanner.register(marker); + + const pieces = scanner.feed( + `pre${unknownSentinel}mid${strayApc}${buildRunCompleteSentinel( + marker, + )}post`, + ); + + expect(pieces).toEqual([ + { type: 'output', data: `pre${unknownSentinel}mid${strayApc}` }, + { type: 'run_complete', marker }, + { type: 'output', data: 'post' }, + ]); + }); + + it('passes all data through unchanged when no markers are active', () => { + const scanner = new RunCompletionSentinelScanner(); + const data = [ + 'before', + buildRunCompleteSentinel(runMarker(27)), + '\x1b_random', + 'after', + ].join(''); + + expect(scanner.feed(data)).toEqual([{ type: 'output', data }]); + expect(scanner.flush()).toEqual([]); + }); + + it('does not leak active sentinel bytes into output pieces', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(28); + const sentinel = buildRunCompleteSentinel(marker); + scanner.register(marker); + + const pieces = feedChunks(scanner, [ + 'visible-before', + sentinel.slice(0, RUN_COMPLETE_SENTINEL_PREFIX.length + 4), + sentinel.slice(RUN_COMPLETE_SENTINEL_PREFIX.length + 4), + 'visible-after', + ]); + + expect(pieces).toEqual([ + { type: 'output', data: 'visible-before' }, + { type: 'run_complete', marker }, + { type: 'output', data: 'visible-after' }, + ]); + + for (const piece of pieces) { + if (piece.type !== 'output') { + continue; + } + expect(piece.data).not.toContain(RUN_COMPLETE_SENTINEL_PREFIX); + expect(piece.data).not.toContain('agent-tty:run-complete:'); + expect(piece.data).not.toContain('__AT_MARKER_'); + expect(piece.data).not.toContain(marker); + } + }); + + it('flushes a pending non-sentinel tail once', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(29); + scanner.register(marker); + + expect( + scanner.feed(`visible${RUN_COMPLETE_SENTINEL_PREFIX.slice(0, 4)}`), + ).toEqual([{ type: 'output', data: 'visible' }]); + expect(scanner.flush()).toEqual([ + { type: 'output', data: RUN_COMPLETE_SENTINEL_PREFIX.slice(0, 4) }, + ]); + expect(scanner.flush()).toEqual([]); + }); + + it('passes the same sentinel through as output after deactivation', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(30); + const sentinel = buildRunCompleteSentinel(marker); + scanner.register(marker); + + expect(scanner.feed(sentinel)).toEqual([{ type: 'run_complete', marker }]); + expect(scanner.feed(sentinel)).toEqual([ + { type: 'output', data: sentinel }, + ]); + }); + + it('rejects non-production markers so prefix-overlap cases are impossible', () => { + const scanner = new RunCompletionSentinelScanner(); + + expect(() => scanner.register('prefix')).toThrow( + 'run marker must match expected format', + ); + }); + + it('reports whether active markers remain', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(31); + + expect(scanner.hasActiveMarkers()).toBe(false); + scanner.register(marker); + scanner.register(marker); + expect(scanner.hasActiveMarkers()).toBe(true); + expect(scanner.feed(buildRunCompleteSentinel(marker))).toEqual([ + { type: 'run_complete', marker }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }); + + it('preserves unknown output data when an active marker remains registered', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(32); + const unknownData = `${RUN_COMPLETE_SENTINEL_PREFIX}not-${marker}`; + scanner.register(marker); + + expect(outputData(scanner.feed(unknownData))).toBe(unknownData); + expect(scanner.hasActiveMarkers()).toBe(true); + }); +}); diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index 07b1a55..beb6048 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -33,6 +33,8 @@ import { import { EventRecordSchema, MarkerEventRecordSchema, + RunCompleteEventPayloadSchema, + RunCompleteEventRecordSchema, SessionRecordSchema, } from '../../../src/protocol/schemas.js'; @@ -142,6 +144,52 @@ describe('protocol schemas', () => { payload: { label: 'Step 1' }, }); }); + + it('strictly validates run_complete event payloads and records', () => { + expect( + RunCompleteEventPayloadSchema.parse({ + marker: '__AT_MARKER_123__', + inputRunSeq: 7, + }), + ).toEqual({ + marker: '__AT_MARKER_123__', + inputRunSeq: 7, + }); + expect( + RunCompleteEventPayloadSchema.parse({ marker: '__AT_MARKER_456__' }), + ).toEqual({ marker: '__AT_MARKER_456__' }); + expect( + RunCompleteEventRecordSchema.parse({ + seq: 2, + ts: '2026-03-19T12:00:04.000Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_789__', inputRunSeq: 1 }, + }), + ).toEqual({ + seq: 2, + ts: '2026-03-19T12:00:04.000Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_789__', inputRunSeq: 1 }, + }); + + expect( + RunCompleteEventPayloadSchema.safeParse({ + marker: '__AT_MARKER_extra__', + extra: true, + }).success, + ).toBe(false); + expect( + RunCompleteEventPayloadSchema.safeParse({ + marker: '__AT_MARKER_bad_seq__', + inputRunSeq: -1, + }).success, + ).toBe(false); + expect( + RunCompleteEventPayloadSchema.safeParse({ + marker: 123, + }).success, + ).toBe(false); + }); }); describe('CapabilityEntrySchema', () => { diff --git a/test/unit/renderer/ghosttyWebBackend.test.ts b/test/unit/renderer/ghosttyWebBackend.test.ts index 3163b1d..5f518a9 100644 --- a/test/unit/renderer/ghosttyWebBackend.test.ts +++ b/test/unit/renderer/ghosttyWebBackend.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import type { ReplayInput } from '../../../src/renderer/types.js'; import { BUNDLED_FONT_ASSETS } from '../../../src/renderer/bundledFont.js'; import { hashProfile, resolveProfile } from '../../../src/renderer/profiles.js'; import { GhosttyWebBackend } from '../../../src/renderer/ghosttyWeb/index.js'; @@ -70,6 +71,68 @@ describe('GhosttyWebBackend unit guards', () => { expect(recordedChunks).toEqual(chunks); }); + it('flushes and skips run_complete events during replay', async () => { + const backend = createBackend(); + const flushedChunks: string[][] = []; + const resizeBridge = vi.fn(() => Promise.resolve()); + const page = { isClosed: () => false }; + + Object.assign(backend as object, { + isBooted: true, + page, + resizeBridge, + flushOutputBatch: (_page: object, dataChunks: string[]) => { + flushedChunks.push([...dataChunks]); + return Promise.resolve(); + }, + readHarnessSnapshot: () => + Promise.resolve( + createHarnessSnapshotPayload({ + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + visibleLines: [{ row: 0, text: 'after' }], + }), + ), + }); + + const input: ReplayInput = { + sessionId: 'renderer-unit-session', + initialCols: 80, + initialRows: 24, + targetSeq: 2, + events: [ + { + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'before' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_done__', inputRunSeq: 0 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'after' }, + }, + ], + }; + + await expect(backend.replayTo(input)).resolves.toMatchObject({ + lastSeq: 2, + cols: 80, + rows: 24, + }); + expect(resizeBridge).toHaveBeenCalledWith(page, 80, 24); + expect(flushedChunks).toEqual([['before'], ['after']]); + }); + it('rejects oversized bridge batches before page evaluation', async () => { const backend = createBackend(); const evaluate = vi.fn(); diff --git a/test/unit/renderer/libghosttyVtBackend.test.ts b/test/unit/renderer/libghosttyVtBackend.test.ts index 9630eac..1de9c7c 100644 --- a/test/unit/renderer/libghosttyVtBackend.test.ts +++ b/test/unit/renderer/libghosttyVtBackend.test.ts @@ -254,6 +254,51 @@ describe('LibghosttyVtBackend', () => { }); }); + it('skips run_complete events during replay', async () => { + const fixture = createNativeFixture(); + const backend = createBackend(fixture); + + await backend.boot(); + const state = await backend.replayTo( + createReplayInput({ + targetSeq: 3, + events: [ + { + seq: 0, + ts: '2026-03-20T12:00:00.000Z', + type: 'output', + payload: { data: 'hello' }, + }, + { + seq: 1, + ts: '2026-03-20T12:00:00.100Z', + type: 'run_complete', + payload: { + marker: '__AT_MARKER_00000000000000000000000000000001__', + }, + }, + { + seq: 2, + ts: '2026-03-20T12:00:00.200Z', + type: 'resize', + payload: { cols: 12, rows: 5 }, + }, + { + seq: 3, + ts: '2026-03-20T12:00:00.300Z', + type: 'output', + payload: { data: ' world' }, + }, + ], + }), + ); + + expect(fixture.feed).toHaveBeenCalledTimes(2); + expect(fixture.feed).toHaveBeenNthCalledWith(1, 'hello'); + expect(fixture.feed).toHaveBeenNthCalledWith(2, ' world'); + expect(state.lastSeq).toBe(3); + }); + it('maps native snapshots into semantic snapshots', async () => { const fixture = createNativeFixture(); const backend = createBackend(fixture); diff --git a/test/unit/renderer/types.test.ts b/test/unit/renderer/types.test.ts index 7d0962e..d6debd2 100644 --- a/test/unit/renderer/types.test.ts +++ b/test/unit/renderer/types.test.ts @@ -68,24 +68,40 @@ describe('renderer schemas', () => { { seq: 4, ts: '2026-03-19T12:00:04.000Z', - type: 'resize', - payload: { cols: 120, rows: 40 }, + type: 'input_run', + payload: { + command: 'echo ready', + marker: '__AT_MARKER_ready__', + noWait: false, + }, }, { seq: 5, ts: '2026-03-19T12:00:05.000Z', - type: 'marker', - payload: { label: '' }, + type: 'run_complete', + payload: { marker: '__AT_MARKER_ready__', inputRunSeq: 4 }, }, { seq: 6, ts: '2026-03-19T12:00:06.000Z', - type: 'signal', - payload: { signal: 'SIGINT' }, + type: 'resize', + payload: { cols: 120, rows: 40 }, }, { seq: 7, ts: '2026-03-19T12:00:07.000Z', + type: 'marker', + payload: { label: '' }, + }, + { + seq: 8, + ts: '2026-03-19T12:00:08.000Z', + type: 'signal', + payload: { signal: 'SIGINT' }, + }, + { + seq: 9, + ts: '2026-03-19T12:00:09.000Z', type: 'exit', payload: { exitCode: 0, exitSignal: null }, },