diff --git a/CONTEXT.md b/CONTEXT.md index 067f104..9be3070 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,6 +23,10 @@ _Avoid_: Finished session **Commandable Session**: A **Session** that can still accept user input and control commands. +**Command Target**: +The **Commandable Session** selected and verified as eligible to receive an input or control command. +_Avoid_: Running Session Target + **Waited Run**: A run request where the caller asks `agent-tty` to wait until the command's completion signal is observed. _Avoid_: Blocking run @@ -78,6 +82,7 @@ The tag-triggered automation that validates, packages, and publishes a release a - An **Offline Replay Eligible Session** is reconstructed from its persisted **Event Log** and manifest. - A **Snapshot Result** is derived from exactly one **Semantic Snapshot**. - A **Snapshot Artifact** contains exactly the **Snapshot Result** emitted to the caller. +- A **Command Target** is exactly one **Commandable Session** selected by an input or control command. - A **Commandable Session** can accept a **Waited Run**. - A **Waited Run** may produce one **Run Completion**, time out for its caller, or be interrupted by **Session** exit. - Caller timeout does not cancel the underlying **Run Completion**; it may still be observed later to keep internal completion bytes out of artifacts. @@ -93,7 +98,10 @@ The tag-triggered automation that validates, packages, and publishes a release a > **Domain expert:** "No. It is still an **Active Session**, even though renderer inspection should use **Offline Replay** instead of the live host." > **Dev:** "Does **Snapshot Capture** ask the renderer for terminal state?" -> **Domain expert:** "No — the renderer first produces a **Semantic Snapshot**; **Snapshot Capture** derives and records the public result from that snapshot." +> **Domain expert:** "No. The renderer first produces a **Semantic Snapshot**; **Snapshot Capture** derives and records the public result from that snapshot." + +> **Dev:** "Should `snapshot` resolve a **Command Target**?" +> **Domain expert:** "No. A **Command Target** is for commands that must send input or control to a **Commandable Session**, not inspection or replay operations." > **Dev:** "If a **Waited Run** times out, did the command finish?" > **Domain expert:** "No. The caller stopped waiting, but the **Run Completion** may still arrive later and must still be recognized." @@ -101,3 +109,4 @@ The tag-triggered automation that validates, packages, and publishes a release a ## Flagged ambiguities - "Active" and "offline replay eligible" are independent classifications: `destroying` is both **Active** and **Offline Replay Eligible**. +- "Running Session Target" was used during design discussion, but the canonical term is **Command Target** because commandability is the policy being resolved. diff --git a/dogfood/issue-63-command-target/01-create-main.json b/dogfood/issue-63-command-target/01-create-main.json new file mode 100644 index 0000000..27be5a8 --- /dev/null +++ b/dogfood/issue-63-command-target/01-create-main.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T19:39:13.331Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "createdAt": "2026-04-29T19:39:12.504Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-63-command-target/02-run.json b/dogfood/issue-63-command-target/02-run.json new file mode 100644 index 0000000..7cef3bb --- /dev/null +++ b/dogfood/issue-63-command-target/02-run.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "run", + "timestamp": "2026-04-29T19:39:14.891Z", + "result": { + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 1, + "durationMs": 3, + "marker": "__AT_MARKER_b90108d5edac4369a6f43da422783394__" + } +} diff --git a/dogfood/issue-63-command-target/03-type.json b/dogfood/issue-63-command-target/03-type.json new file mode 100644 index 0000000..a35ce88 --- /dev/null +++ b/dogfood/issue-63-command-target/03-type.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-29T19:39:15.990Z", + "result": {} +} diff --git a/dogfood/issue-63-command-target/04-wait-typed.json b/dogfood/issue-63-command-target/04-wait-typed.json new file mode 100644 index 0000000..dca3f39 --- /dev/null +++ b/dogfood/issue-63-command-target/04-wait-typed.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:17.293Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "typed issue 63", + "cursorRow": 4, + "cursorCol": 10, + "capturedAtSeq": 7 + } +} diff --git a/dogfood/issue-63-command-target/05-paste.json b/dogfood/issue-63-command-target/05-paste.json new file mode 100644 index 0000000..2bc4a7e --- /dev/null +++ b/dogfood/issue-63-command-target/05-paste.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "paste", + "timestamp": "2026-04-29T19:39:18.396Z", + "result": {} +} diff --git a/dogfood/issue-63-command-target/06-send-keys.json b/dogfood/issue-63-command-target/06-send-keys.json new file mode 100644 index 0000000..ba3dedf --- /dev/null +++ b/dogfood/issue-63-command-target/06-send-keys.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-29T19:39:19.570Z", + "result": { + "accepted": ["Enter"], + "bytesWritten": 1, + "seq": 10 + } +} diff --git a/dogfood/issue-63-command-target/07-wait-pasted.json b/dogfood/issue-63-command-target/07-wait-pasted.json new file mode 100644 index 0000000..4324351 --- /dev/null +++ b/dogfood/issue-63-command-target/07-wait-pasted.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:20.827Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "pasted issue 63", + "cursorRow": 6, + "cursorCol": 10, + "capturedAtSeq": 11 + } +} diff --git a/dogfood/issue-63-command-target/08-mark.json b/dogfood/issue-63-command-target/08-mark.json new file mode 100644 index 0000000..10b5653 --- /dev/null +++ b/dogfood/issue-63-command-target/08-mark.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "mark", + "timestamp": "2026-04-29T19:39:21.913Z", + "result": { + "seq": 12 + } +} diff --git a/dogfood/issue-63-command-target/09-resize.json b/dogfood/issue-63-command-target/09-resize.json new file mode 100644 index 0000000..2d18635 --- /dev/null +++ b/dogfood/issue-63-command-target/09-resize.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "resize", + "timestamp": "2026-04-29T19:39:23.025Z", + "result": { + "cols": 100, + "rows": 30 + } +} diff --git a/dogfood/issue-63-command-target/10-wait-stable.json b/dogfood/issue-63-command-target/10-wait-stable.json new file mode 100644 index 0000000..7d89324 --- /dev/null +++ b/dogfood/issue-63-command-target/10-wait-stable.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-29T19:39:24.720Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 6, + "cursorCol": 10, + "capturedAtSeq": 14 + } +} diff --git a/dogfood/issue-63-command-target/11-create-signal.json b/dogfood/issue-63-command-target/11-create-signal.json new file mode 100644 index 0000000..6b6ff69 --- /dev/null +++ b/dogfood/issue-63-command-target/11-create-signal.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-29T19:39:26.433Z", + "result": { + "sessionId": "01KQDC1Q4BBGBZNZJ8EECKQPQ9", + "createdAt": "2026-04-29T19:39:25.710Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-63-command-target/12-signal.json b/dogfood/issue-63-command-target/12-signal.json new file mode 100644 index 0000000..8df297e --- /dev/null +++ b/dogfood/issue-63-command-target/12-signal.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "signal", + "timestamp": "2026-04-29T19:39:27.581Z", + "result": { + "signal": "SIGUSR1", + "delivered": true + } +} diff --git a/dogfood/issue-63-command-target/13-snapshot-text.json b/dogfood/issue-63-command-target/13-snapshot-text.json new file mode 100644 index 0000000..6d01204 --- /dev/null +++ b/dogfood/issue-63-command-target/13-snapshot-text.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-29T19:39:28.672Z", + "result": { + "format": "text", + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "capturedAtSeq": 14, + "cols": 100, + "rows": 30, + "cursorRow": 6, + "cursorCol": 10, + "text": "bash-5.1$ printf 'hello issue 63\\n'\nhello issue 63\nbash-5.1$ echo typed issue 63\ntyped issue 63\nbash-5.1$ echo pasted issue 63\npasted issue 63\nbash-5.1$\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} diff --git a/dogfood/issue-63-command-target/14-screenshot.json b/dogfood/issue-63-command-target/14-screenshot.json new file mode 100644 index 0000000..dd665a5 --- /dev/null +++ b/dogfood/issue-63-command-target/14-screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-29T19:39:29.863Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "capturedAtSeq": 14, + "profileName": "reference-dark", + "cols": 100, + "rows": 30, + "artifactPath": "/tmp/tmp.wLvWPyLbya/sessions/01KQDC1A7M9FN8EVETNKPJBDDT/artifacts/screenshot-14-reference-dark.png", + "pngSizeBytes": 18355, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 800, + "pixelHeight": 480, + "sha256": "3403c12fffcd366a8672c88233425946899268cc5c89a7da394fd671c5a842b8", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/issue-63-command-target/15-record-asciicast.json b/dogfood/issue-63-command-target/15-record-asciicast.json new file mode 100644 index 0000000..28ee663 --- /dev/null +++ b/dogfood/issue-63-command-target/15-record-asciicast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T19:39:30.941Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.cast", + "bytes": 741, + "sha256": "3dc13d10376a460aafbb63320be732e1121032caccaee67fb308c77c1292a250", + "capturedAtSeq": 14, + "durationMs": 9723, + "metadata": { + "width": 100, + "height": 30, + "title": "01KQDC1A7M9FN8EVETNKPJBDDT", + "timestamp": 1777491553, + "outputEventCount": 8, + "resizeEventCount": 1, + "markerCount": 1 + } + } +} diff --git a/dogfood/issue-63-command-target/16-record-webm.json b/dogfood/issue-63-command-target/16-record-webm.json new file mode 100644 index 0000000..0ba49db --- /dev/null +++ b/dogfood/issue-63-command-target/16-record-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-29T19:39:33.368Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-8wce/dogfood/issue-63-command-target/session.webm", + "bytes": 22955, + "sha256": "e76fe6870455cc2531421434fb5497e5ee6bb1e631cf15c46eb85e12463aa738", + "capturedAtSeq": 14, + "durationMs": 9723, + "metadata": { + "width": 100, + "height": 30, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "max-speed", + "rendererBackend": "ghostty-web", + "outputEventCount": 8, + "resizeEventCount": 1 + } + } +} diff --git a/dogfood/issue-63-command-target/98-destroy-signal.json b/dogfood/issue-63-command-target/98-destroy-signal.json new file mode 100644 index 0000000..d825fcb --- /dev/null +++ b/dogfood/issue-63-command-target/98-destroy-signal.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-29T19:39:35.814Z", + "result": { + "sessionId": "01KQDC1Q4BBGBZNZJ8EECKQPQ9", + "destroyed": true + } +} diff --git a/dogfood/issue-63-command-target/99-destroy-main.json b/dogfood/issue-63-command-target/99-destroy-main.json new file mode 100644 index 0000000..f5f7c27 --- /dev/null +++ b/dogfood/issue-63-command-target/99-destroy-main.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-29T19:39:34.611Z", + "result": { + "sessionId": "01KQDC1A7M9FN8EVETNKPJBDDT", + "destroyed": true + } +} diff --git a/dogfood/issue-63-command-target/README.md b/dogfood/issue-63-command-target/README.md new file mode 100644 index 0000000..a74f131 --- /dev/null +++ b/dogfood/issue-63-command-target/README.md @@ -0,0 +1,23 @@ +# Issue 63 command-target dogfood + +This bundle was generated with an isolated `AGENT_TTY_HOME` recorded in `agent-tty-home.txt`. + +## Refactored command-target commands exercised + +- `run`: `02-run.json` +- `type`: `03-type.json` +- `paste`: `05-paste.json` +- `send-keys`: `06-send-keys.json` +- `mark`: `08-mark.json` +- `resize`: `09-resize.json` +- `signal`: `12-signal.json` against a disposable signal session + +## Review artifacts + +- Command transcript: `transcript.txt` +- Text snapshot: `13-snapshot-text.json` +- Screenshot JSON: `14-screenshot.json` +- Screenshot image: `screenshot.png` +- Asciicast export: `session.cast` with JSON envelope `15-record-asciicast.json` +- WebM export: `session.webm` with JSON envelope `16-record-webm.json` +- Main session manifest/event log copies: `main-session-manifest.json`, `main-session-events.jsonl` diff --git a/dogfood/issue-63-command-target/agent-tty-home.txt b/dogfood/issue-63-command-target/agent-tty-home.txt new file mode 100644 index 0000000..041f368 --- /dev/null +++ b/dogfood/issue-63-command-target/agent-tty-home.txt @@ -0,0 +1 @@ +/tmp/tmp.wLvWPyLbya diff --git a/dogfood/issue-63-command-target/commands.sh b/dogfood/issue-63-command-target/commands.sh new file mode 100755 index 0000000..e8085b3 --- /dev/null +++ b/dogfood/issue-63-command-target/commands.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUNDLE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$BUNDLE/../.." && pwd)" +export AGENT_TTY_HOME="$(mktemp -d)" +printf '%s\n' "$AGENT_TTY_HOME" > "$BUNDLE/agent-tty-home.txt" + +cd "$REPO" +exec > >(tee "$BUNDLE/transcript.txt") 2>&1 + +run_json() { + local output="$1" + shift + printf '+' + for argument in "$@"; do + printf ' %q' "$argument" + done + printf '\n' + "$@" > "$output" + cat "$output" + printf '\n' +} + +json_field() { + local file="$1" + local expression="$2" + node -e "const fs = require('node:fs'); const envelope = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log($expression);" "$file" +} + +cleanup() { + if [[ -n "${MAIN_SESSION_ID:-}" ]]; then + npx tsx src/cli/main.ts destroy "$MAIN_SESSION_ID" --json > "$BUNDLE/99-destroy-main.json" || true + fi + if [[ -n "${SIGNAL_SESSION_ID:-}" ]]; then + npx tsx src/cli/main.ts destroy "$SIGNAL_SESSION_ID" --json > "$BUNDLE/98-destroy-signal.json" || true + fi + if [[ -n "${AGENT_TTY_HOME:-}" ]]; then + rm -rf "$AGENT_TTY_HOME" + fi +} +trap cleanup EXIT + +run_json "$BUNDLE/01-create-main.json" \ + npx tsx src/cli/main.ts create --json --name issue-63-command-target -- /bin/bash --noprofile --norc +MAIN_SESSION_ID="$(json_field "$BUNDLE/01-create-main.json" 'envelope.result.sessionId')" +printf '%s\n' "$MAIN_SESSION_ID" > "$BUNDLE/main-session-id.txt" + +run_json "$BUNDLE/02-run.json" \ + npx tsx src/cli/main.ts run "$MAIN_SESSION_ID" "printf 'hello issue 63\\n'" --json +run_json "$BUNDLE/03-type.json" \ + npx tsx src/cli/main.ts type "$MAIN_SESSION_ID" "echo typed issue 63" --append-newline --json +run_json "$BUNDLE/04-wait-typed.json" \ + npx tsx src/cli/main.ts wait "$MAIN_SESSION_ID" --text "typed issue 63" --timeout 10000 --json +run_json "$BUNDLE/05-paste.json" \ + npx tsx src/cli/main.ts paste "$MAIN_SESSION_ID" "echo pasted issue 63" --json +run_json "$BUNDLE/06-send-keys.json" \ + npx tsx src/cli/main.ts send-keys "$MAIN_SESSION_ID" Enter --json +run_json "$BUNDLE/07-wait-pasted.json" \ + npx tsx src/cli/main.ts wait "$MAIN_SESSION_ID" --text "pasted issue 63" --timeout 10000 --json +run_json "$BUNDLE/08-mark.json" \ + npx tsx src/cli/main.ts mark "$MAIN_SESSION_ID" issue-63-proof --json +run_json "$BUNDLE/09-resize.json" \ + npx tsx src/cli/main.ts resize "$MAIN_SESSION_ID" --cols 100 --rows 30 --json +run_json "$BUNDLE/10-wait-stable.json" \ + npx tsx src/cli/main.ts wait "$MAIN_SESSION_ID" --screen-stable-ms 300 --timeout 10000 --json + +run_json "$BUNDLE/11-create-signal.json" \ + npx tsx src/cli/main.ts create --json --name issue-63-signal -- /bin/sh -c 'trap "echo got-sigusr1" USR1; while :; do sleep 1; done' +SIGNAL_SESSION_ID="$(json_field "$BUNDLE/11-create-signal.json" 'envelope.result.sessionId')" +printf '%s\n' "$SIGNAL_SESSION_ID" > "$BUNDLE/signal-session-id.txt" +run_json "$BUNDLE/12-signal.json" \ + npx tsx src/cli/main.ts signal "$SIGNAL_SESSION_ID" SIGUSR1 --json + +run_json "$BUNDLE/13-snapshot-text.json" \ + npx tsx src/cli/main.ts snapshot "$MAIN_SESSION_ID" --format text --json +run_json "$BUNDLE/14-screenshot.json" \ + npx tsx src/cli/main.ts screenshot "$MAIN_SESSION_ID" --hide-cursor --json +SCREENSHOT_PATH="$(json_field "$BUNDLE/14-screenshot.json" 'envelope.result.artifactPath')" +cp "$SCREENSHOT_PATH" "$BUNDLE/screenshot.png" + +run_json "$BUNDLE/15-record-asciicast.json" \ + npx tsx src/cli/main.ts record export "$MAIN_SESSION_ID" --format asciicast --out "$BUNDLE/session.cast" --json +run_json "$BUNDLE/16-record-webm.json" \ + npx tsx src/cli/main.ts record export "$MAIN_SESSION_ID" --format webm --timing max-speed --out "$BUNDLE/session.webm" --json + +SESSION_DIR="$AGENT_TTY_HOME/sessions/$MAIN_SESSION_ID" +cp "$SESSION_DIR/session.json" "$BUNDLE/main-session-manifest.json" +cp "$SESSION_DIR/events.jsonl" "$BUNDLE/main-session-events.jsonl" + +cat > "$BUNDLE/README.md" < { + const sessionDirectory = sessionDir(options.home, options.sessionId); + const resolvedManifestPath = resolveManifestPath(sessionDirectory); + const manifest = await readManifestIfExists(resolvedManifestPath); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: resolvedManifestPath, + }, + }); + } + + assertSessionCommandable(manifest, options.sessionId); + assertRunningManifest(manifest); + + return { + sessionId: options.sessionId, + sessionDirectory, + manifestPath: resolvedManifestPath, + socketPath: resolveSocketPath(sessionDirectory), + manifest, + }; +} diff --git a/src/cli/commands/mark.ts b/src/cli/commands/mark.ts index 3e28e7f..54966f3 100644 --- a/src/cli/commands/mark.ts +++ b/src/cli/commands/mark.ts @@ -1,17 +1,11 @@ import type { CommandContext } from '../context.js'; import type { MarkResult } from '../../protocol/messages.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { MarkResultSchema } from '../../protocol/messages.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export type { MarkResult } from '../../protocol/messages.js'; @@ -23,30 +17,14 @@ interface CommandOptions { } export async function runMarkCommand(options: CommandOptions): Promise { - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - const rawResult: unknown = await sendRpc( - socketPath(sessionDirectory), - 'mark', - { - label: options.label, - }, - ); + const rawResult: unknown = await sendRpc(target.socketPath, 'mark', { + label: options.label, + }); const parsedResult = MarkResultSchema.safeParse(rawResult); if (!parsedResult.success) { throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts index 23d4957..97a2ce0 100644 --- a/src/cli/commands/paste.ts +++ b/src/cli/commands/paste.ts @@ -1,16 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; -import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; import { resolveCommandInputText } from './inputSource.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export interface PasteResult { [key: string]: never; @@ -31,33 +24,12 @@ export async function runPasteCommand(options: CommandOptions): Promise { file: options.file, }); - if (text.length === 0) { - throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Text must not be empty.', - details: { - text, - }, - }); - } - - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - await sendRpc(socketPath(sessionDirectory), 'paste', { + await sendRpc(target.socketPath, 'paste', { text, }); diff --git a/src/cli/commands/resize.ts b/src/cli/commands/resize.ts index 0486bb3..cc6315b 100644 --- a/src/cli/commands/resize.ts +++ b/src/cli/commands/resize.ts @@ -1,15 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export interface ResizeResult { cols: number; @@ -25,22 +19,10 @@ interface CommandOptions { } export async function runResizeCommand(options: CommandOptions): Promise { - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); if ( !Number.isInteger(options.cols) || @@ -57,7 +39,7 @@ export async function runResizeCommand(options: CommandOptions): Promise { }); } - await sendRpc(socketPath(sessionDirectory), 'resize', { + await sendRpc(target.socketPath, 'resize', { cols: options.cols, rows: options.rows, }); diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 42fa8ed..6bb1dad 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -1,18 +1,12 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import type { RunResult } from '../../protocol/messages.js'; import { RunResultSchema } from '../../protocol/messages.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; import { resolveCommandInputText } from './inputSource.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; interface CommandOptions { context: CommandContext; @@ -31,15 +25,6 @@ export async function runRunCommand(options: CommandOptions): Promise { file: options.file, }); - if (command.length === 0) { - throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Command text must not be empty.', - details: { - command, - }, - }); - } - if (!Number.isFinite(options.timeout) || options.timeout <= 0) { throw makeCliError(ERROR_CODES.INVALID_INPUT, { message: 'Timeout must be a positive integer in milliseconds', @@ -49,22 +34,10 @@ export async function runRunCommand(options: CommandOptions): Promise { }); } - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); const noWait = !options.wait; const rpcParams: Record = { @@ -78,7 +51,7 @@ export async function runRunCommand(options: CommandOptions): Promise { const rpcTimeoutMs = noWait ? 10_000 : options.timeout + 10_000; const rawResult = await sendRpc( - socketPath(sessionDirectory), + target.socketPath, 'run', rpcParams, rpcTimeoutMs, diff --git a/src/cli/commands/send-keys.ts b/src/cli/commands/send-keys.ts index 28f4dd5..ca63142 100644 --- a/src/cli/commands/send-keys.ts +++ b/src/cli/commands/send-keys.ts @@ -1,17 +1,11 @@ import type { CommandContext } from '../context.js'; import type { SendKeysResult } from '../../protocol/messages.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { SendKeysResultSchema } from '../../protocol/messages.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export type { SendKeysResult } from '../../protocol/messages.js'; @@ -25,30 +19,14 @@ interface CommandOptions { export async function runSendKeysCommand( options: CommandOptions, ): Promise { - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - const rawResult: unknown = await sendRpc( - socketPath(sessionDirectory), - 'sendKeys', - { - keys: options.keys, - }, - ); + const rawResult: unknown = await sendRpc(target.socketPath, 'sendKeys', { + keys: options.keys, + }); const parsedResult = SendKeysResultSchema.safeParse(rawResult); if (!parsedResult.success) { throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { diff --git a/src/cli/commands/signal.ts b/src/cli/commands/signal.ts index b613dc6..a54f2ca 100644 --- a/src/cli/commands/signal.ts +++ b/src/cli/commands/signal.ts @@ -1,15 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; const ALLOWED_SIGNALS = [ 'SIGTERM', @@ -33,22 +27,10 @@ interface CommandOptions { } export async function runSignalCommand(options: CommandOptions): Promise { - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); if ( !ALLOWED_SIGNALS.includes( @@ -63,7 +45,7 @@ export async function runSignalCommand(options: CommandOptions): Promise { }); } - await sendRpc(socketPath(sessionDirectory), 'signal', { + await sendRpc(target.socketPath, 'signal', { signal: options.signal, }); diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts index 322f4e8..8b8492c 100644 --- a/src/cli/commands/type.ts +++ b/src/cli/commands/type.ts @@ -1,16 +1,9 @@ import type { CommandContext } from '../context.js'; +import { resolveCommandTarget } from '../commandTarget.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; -import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { readManifestIfExists } from '../../storage/manifests.js'; -import { - manifestPath, - sessionDir, - socketPath, -} from '../../storage/sessionPaths.js'; import { resolveCommandInputText } from './inputSource.js'; -import { assertSessionCommandable } from '../sessionGuards.js'; export interface TypeResult { [key: string]: never; @@ -34,33 +27,12 @@ export async function runTypeCommand(options: CommandOptions): Promise { const text = options.appendNewline === true ? resolvedText + '\n' : resolvedText; - if (text.length === 0) { - throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Text must not be empty.', - details: { - text, - }, - }); - } - - const home = options.context.home; - const sessionDirectory = sessionDir(home, options.sessionId); - const manifestFile = manifestPath(sessionDirectory); - const manifest = await readManifestIfExists(manifestFile); - - if (manifest === null) { - throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { - message: `Session "${options.sessionId}" was not found.`, - details: { - sessionId: options.sessionId, - manifestPath: manifestFile, - }, - }); - } - - assertSessionCommandable(manifest, options.sessionId); + const target = await resolveCommandTarget({ + home: options.context.home, + sessionId: options.sessionId, + }); - await sendRpc(socketPath(sessionDirectory), 'type', { + await sendRpc(target.socketPath, 'type', { text, }); diff --git a/test/helpers.ts b/test/helpers.ts index a26b1f2..9b5c141 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; +import type { SessionRecord as ProtocolSessionRecord } from '../src/protocol/schemas.js'; import type { SemanticSnapshot } from '../src/renderer/types.js'; import { afterEach, expect } from 'vitest'; @@ -83,6 +84,27 @@ export function createTestSemanticSnapshot( }; } +export function createTestSessionRecord( + overrides: Partial = {}, +): ProtocolSessionRecord { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + ...overrides, + }; +} + export async function createTemporarySessionDir( prefix: string, sessionId = 'session-01', diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 400688f..0d60b84 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -313,6 +313,55 @@ describe('CLI integration', () => { expect(envelope.error.message).toContain('mutually exclusive'); }); + it('returns SESSION_NOT_FOUND for resize before validating dimensions', () => { + const result = runCli( + ['resize', 'missing-session', '--cols', '0', '--rows', '0', '--json'], + testEnv(), + ); + + expect(result.status).toBe(3); + expect(result.stderr).toBe(''); + + const envelope = parseErrorEnvelope(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('resize'); + expect(envelope.error.code).toBe('SESSION_NOT_FOUND'); + expect(envelope.error.message).toBe( + 'Session "missing-session" was not found.', + ); + }); + + it('returns SESSION_NOT_FOUND for signal before validating the signal name', () => { + const result = runCli( + ['signal', 'missing-session', 'BAD', '--json'], + testEnv(), + ); + + expect(result.status).toBe(3); + expect(result.stderr).toBe(''); + + const envelope = parseErrorEnvelope(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('signal'); + expect(envelope.error.code).toBe('SESSION_NOT_FOUND'); + expect(envelope.error.message).toBe( + 'Session "missing-session" was not found.', + ); + }); + + it('rejects empty run text before resolving the command target', () => { + const result = runCli(['run', 'missing-session', '', '--json'], testEnv()); + + expect(result.status).toBe(2); + expect(result.stderr).toBe(''); + + const envelope = parseErrorEnvelope(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('run'); + expect(envelope.error.code).toBe('INVALID_INPUT'); + expect(envelope.error.message).toBe('Text must not be empty.'); + }); + it('prints a JSON envelope for doctor including the new health checks', () => { const result = runCli(['doctor', '--json'], testEnv()); expect(result.status).toBe(0); diff --git a/test/unit/cli/commandTarget.test.ts b/test/unit/cli/commandTarget.test.ts new file mode 100644 index 0000000..d4f2234 --- /dev/null +++ b/test/unit/cli/commandTarget.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCommandTarget } from '../../../src/cli/commandTarget.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { createTestSessionRecord } from '../../helpers.js'; +import type { + SessionRecord, + SessionStatus, +} from '../../../src/protocol/schemas.js'; +import { writeManifest } from '../../../src/storage/manifests.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../../src/storage/sessionPaths.js'; + +let testHome = ''; + +async function createHome(): Promise { + testHome = await mkdtemp(join(tmpdir(), 'agent-tty-command-target-')); + return testHome; +} + +afterEach(async () => { + if (testHome.length > 0) { + await rm(testHome, { recursive: true, force: true }); + } + testHome = ''; +}); + +async function writeSessionManifest( + home: string, + sessionId: string, + status: SessionStatus, +): Promise { + const manifest = createTestSessionRecord({ + sessionId, + status, + hostPid: status === 'running' ? 123 : null, + childPid: status === 'running' ? 456 : null, + exitCode: status === 'exited' ? 0 : null, + }); + await writeManifest(manifestPath(sessionDir(home, sessionId)), manifest); + return manifest; +} + +describe('resolveCommandTarget', () => { + it('throws SESSION_NOT_FOUND when the session manifest is missing', async () => { + const home = await createHome(); + + await expect( + resolveCommandTarget({ home, sessionId: 'missing-session' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_NOT_FOUND, + message: 'Session "missing-session" was not found.', + details: { + sessionId: 'missing-session', + manifestPath: join(home, 'sessions', 'missing-session', 'session.json'), + }, + }); + }); + + it('throws SESSION_NOT_RUNNING when the session is not commandable', async () => { + const home = await createHome(); + await writeSessionManifest(home, 'exited-session', 'exited'); + + await expect( + resolveCommandTarget({ home, sessionId: 'exited-session' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_NOT_RUNNING, + message: 'Session "exited-session" is not running.', + details: { + sessionId: 'exited-session', + status: 'exited', + }, + }); + }); + + it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { + const home = await createHome(); + await writeSessionManifest(home, 'destroyed-session', 'destroyed'); + + await expect( + resolveCommandTarget({ home, sessionId: 'destroyed-session' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_ALREADY_DESTROYED, + message: 'Session "destroyed-session" is already destroyed.', + details: { + sessionId: 'destroyed-session', + status: 'destroyed', + }, + }); + }); + + it('returns the resolved command target for a running session', async () => { + const home = await createHome(); + const manifest = await writeSessionManifest( + home, + 'running-session', + 'running', + ); + const sessionDirectory = sessionDir(home, 'running-session'); + + await expect( + resolveCommandTarget({ home, sessionId: 'running-session' }), + ).resolves.toEqual({ + sessionId: 'running-session', + sessionDirectory, + manifestPath: manifestPath(sessionDirectory), + socketPath: socketPath(sessionDirectory), + manifest, + }); + }); +}); diff --git a/test/unit/commands/mark.test.ts b/test/unit/commands/mark.test.ts index 5752a39..4938273 100644 --- a/test/unit/commands/mark.test.ts +++ b/test/unit/commands/mark.test.ts @@ -4,12 +4,12 @@ import { ERROR_CODES } from '../../../src/protocol/errors.js'; const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), sendRpc: vi.fn(), - readManifestIfExists: vi.fn(), - resolveHome: vi.fn(), - sessionDir: vi.fn(), - manifestPath: vi.fn(), - socketPath: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, })); vi.mock('../../../src/cli/output.js', () => ({ @@ -20,20 +20,6 @@ vi.mock('../../../src/host/rpcClient.js', () => ({ sendRpc: mocks.sendRpc, })); -vi.mock('../../../src/storage/manifests.js', () => ({ - readManifestIfExists: mocks.readManifestIfExists, -})); - -vi.mock('../../../src/storage/home.js', () => ({ - resolveHome: mocks.resolveHome, -})); - -vi.mock('../../../src/storage/sessionPaths.js', () => ({ - sessionDir: mocks.sessionDir, - manifestPath: mocks.manifestPath, - socketPath: mocks.socketPath, -})); - import { runMarkCommand } from '../../../src/cli/commands/mark.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -48,48 +34,22 @@ const TEST_CONTEXT = { configFile: null, } as const; -function createSessionRecord( - status: 'running' | 'exiting' | 'exited' | 'destroyed' = 'running', -) { - return { - version: 1, - sessionId: 'session-01', - createdAt: '2026-03-19T12:00:00.000Z', - updatedAt: '2026-03-19T12:00:01.000Z', - status, - command: ['/bin/sh'], - cwd: '/tmp/workspace', - cols: 80, - rows: 24, - hostPid: status === 'running' ? 123 : null, - childPid: status === 'running' ? 456 : null, - exitCode: status === 'exited' ? 0 : null, - exitSignal: null, - }; -} +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; describe('mark command', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.resolveHome.mockReturnValue('/tmp/agent-tty'); - mocks.sessionDir.mockImplementation( - (_home: string, sessionId: string) => - `/tmp/agent-tty/sessions/${sessionId}`, - ); - mocks.manifestPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/session.json`, - ); - mocks.socketPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, - ); - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('running'), - ); - }); - - it('sends the mark RPC for a running session and emits the committed seq', async () => { + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); mocks.sendRpc.mockResolvedValue({ seq: 12 }); + }); + it('sends the mark RPC for a command target and emits the committed seq', async () => { await runMarkCommand({ context: TEST_CONTEXT, json: false, @@ -97,8 +57,12 @@ describe('mark command', () => { label: 'checkpoint', }); + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'mark', { label: 'checkpoint' }, ); @@ -110,63 +74,6 @@ describe('mark command', () => { }); }); - it('throws SESSION_NOT_RUNNING when the session is not running', async () => { - mocks.readManifestIfExists.mockResolvedValue(createSessionRecord('exited')); - - await expect( - runMarkCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - label: 'checkpoint', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_NOT_RUNNING, - message: 'Session "session-01" is not running.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('destroyed'), - ); - - await expect( - runMarkCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - label: 'checkpoint', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_ALREADY_DESTROYED, - message: 'Session "session-01" is already destroyed.', - details: { - sessionId: 'session-01', - status: 'destroyed', - }, - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_NOT_FOUND when the session does not exist', async () => { - mocks.readManifestIfExists.mockResolvedValue(null); - - await expect( - runMarkCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - label: 'checkpoint', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_NOT_FOUND, - message: 'Session "session-01" was not found.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - it('accepts an empty label', async () => { mocks.sendRpc.mockResolvedValue({ seq: 7 }); @@ -178,7 +85,7 @@ describe('mark command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'mark', { label: '' }, ); @@ -207,4 +114,21 @@ describe('mark command', () => { lines: ['Marker set at seq 99.'], }); }); + + it('rejects PROTOCOL_ERROR responses without sending success output', async () => { + mocks.sendRpc.mockResolvedValueOnce({ unexpected: true }); + + await expect( + runMarkCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + label: 'broken', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); }); diff --git a/test/unit/commands/paste.test.ts b/test/unit/commands/paste.test.ts new file mode 100644 index 0000000..78a3a47 --- /dev/null +++ b/test/unit/commands/paste.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + resolveCommandInputText: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/cli/commands/inputSource.js', () => ({ + resolveCommandInputText: mocks.resolveCommandInputText, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runPasteCommand } from '../../../src/cli/commands/paste.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('paste command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + mocks.resolveCommandInputText.mockResolvedValue('hello from paste'); + }); + + it('pastes resolved input into a command target', async () => { + await runPasteCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + text: 'hello from paste', + }); + + expect(mocks.resolveCommandInputText).toHaveBeenCalledWith({ + commandName: 'paste', + text: 'hello from paste', + file: undefined, + }); + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'paste', + { text: 'hello from paste' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'paste', + json: false, + result: {}, + lines: ['Pasted text into session.'], + }); + }); +}); diff --git a/test/unit/commands/resize.test.ts b/test/unit/commands/resize.test.ts new file mode 100644 index 0000000..0f89a60 --- /dev/null +++ b/test/unit/commands/resize.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runResizeCommand } from '../../../src/cli/commands/resize.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('resize command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + }); + + it('sends resize dimensions to a command target and emits dimensions', async () => { + await runResizeCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + cols: 100, + rows: 30, + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'resize', + { cols: 100, rows: 30 }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'resize', + json: false, + result: { cols: 100, rows: 30 }, + lines: ['Resized session to 100x30.'], + }); + }); + + it('rejects invalid dimensions after resolving the command target', async () => { + await expect( + runResizeCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + cols: 0, + rows: 30, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_DIMENSIONS, + message: 'Resize dimensions must be positive integers.', + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/run.test.ts b/test/unit/commands/run.test.ts index ccd2343..5167f05 100644 --- a/test/unit/commands/run.test.ts +++ b/test/unit/commands/run.test.ts @@ -2,36 +2,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), - sendRpc: vi.fn(), - readManifestIfExists: vi.fn(), - sessionDir: vi.fn(), - manifestPath: vi.fn(), - socketPath: vi.fn(), + resolveCommandTarget: vi.fn(), resolveCommandInputText: vi.fn(), + sendRpc: vi.fn(), })); -vi.mock('../../../src/cli/output.js', () => ({ - emitSuccess: mocks.emitSuccess, -})); - -vi.mock('../../../src/host/rpcClient.js', () => ({ - sendRpc: mocks.sendRpc, -})); - -vi.mock('../../../src/storage/manifests.js', () => ({ - readManifestIfExists: mocks.readManifestIfExists, +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, })); -vi.mock('../../../src/storage/sessionPaths.js', () => ({ - sessionDir: mocks.sessionDir, - manifestPath: mocks.manifestPath, - socketPath: mocks.socketPath, +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, })); vi.mock('../../../src/cli/commands/inputSource.js', () => ({ resolveCommandInputText: mocks.resolveCommandInputText, })); +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + import { runRunCommand } from '../../../src/cli/commands/run.js'; import { ERROR_CODES } from '../../../src/protocol/errors.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -47,25 +38,13 @@ const TEST_CONTEXT = { configFile: null, } as const; -function createSessionRecord( - status: 'running' | 'exited' | 'destroyed' = 'running', -) { - return { - version: 1, - sessionId: 'session-01', - createdAt: '2026-03-19T12:00:00.000Z', - updatedAt: '2026-03-19T12:00:01.000Z', - status, - command: ['/bin/sh'], - cwd: '/tmp/workspace', - cols: 80, - rows: 24, - hostPid: status === 'running' ? 123 : null, - childPid: status === 'running' ? 456 : null, - exitCode: status === 'exited' ? 0 : null, - exitSignal: null, - }; -} +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; type RunCommandOptions = Parameters[0]; @@ -86,19 +65,7 @@ function createOptions( describe('run command', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.sessionDir.mockImplementation( - (_home: string, sessionId: string) => - `/tmp/agent-tty/sessions/${sessionId}`, - ); - mocks.manifestPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/session.json`, - ); - mocks.socketPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, - ); - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('running'), - ); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); mocks.resolveCommandInputText.mockResolvedValue('echo hello'); mocks.sendRpc.mockResolvedValue({ accepted: true, @@ -114,7 +81,7 @@ describe('run command', () => { await runRunCommand(createOptions()); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'run', { command: 'echo hello', @@ -147,7 +114,7 @@ describe('run command', () => { await runRunCommand(createOptions({ wait: false })); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'run', { command: 'echo hello', @@ -166,11 +133,38 @@ describe('run command', () => { }); }); + it('reports the timed-out branch when the host signals a timeout', async () => { + mocks.sendRpc.mockResolvedValueOnce({ + accepted: true, + completed: false, + timedOut: true, + seq: 17, + durationMs: 30_000, + marker: 'test-marker', + }); + + await runRunCommand(createOptions()); + + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'run', + json: false, + result: { + accepted: true, + completed: false, + timedOut: true, + seq: 17, + durationMs: 30_000, + marker: 'test-marker', + }, + lines: ['Command timed out after 30000ms (seq=17).'], + }); + }); + it('uses the provided timeout for wait mode', async () => { await runRunCommand(createOptions({ timeout: 5_000 })); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'run', { command: 'echo hello', @@ -189,44 +183,7 @@ describe('run command', () => { code: ERROR_CODES.INVALID_INPUT, message: 'Timeout must be a positive integer in milliseconds', }); - expect(mocks.readManifestIfExists).not.toHaveBeenCalled(); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_NOT_FOUND when the session does not exist', async () => { - mocks.readManifestIfExists.mockResolvedValueOnce(null); - - await expect(runRunCommand(createOptions())).rejects.toMatchObject({ - name: 'CliError', - code: ERROR_CODES.SESSION_NOT_FOUND, - message: 'Session "session-01" was not found.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_NOT_RUNNING when the session is not running', async () => { - mocks.readManifestIfExists.mockResolvedValueOnce( - createSessionRecord('exited'), - ); - - await expect(runRunCommand(createOptions())).rejects.toMatchObject({ - name: 'CliError', - code: ERROR_CODES.SESSION_NOT_RUNNING, - message: 'Session "session-01" is not running.', - }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); - }); - - it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { - mocks.readManifestIfExists.mockResolvedValueOnce( - createSessionRecord('destroyed'), - ); - - await expect(runRunCommand(createOptions())).rejects.toMatchObject({ - name: 'CliError', - code: ERROR_CODES.SESSION_ALREADY_DESTROYED, - message: 'Session "session-01" is already destroyed.', - }); + expect(mocks.resolveCommandTarget).not.toHaveBeenCalled(); expect(mocks.sendRpc).not.toHaveBeenCalled(); }); diff --git a/test/unit/commands/send-keys.test.ts b/test/unit/commands/send-keys.test.ts new file mode 100644 index 0000000..2eae21c --- /dev/null +++ b/test/unit/commands/send-keys.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runSendKeysCommand } from '../../../src/cli/commands/send-keys.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('send-keys command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + mocks.sendRpc.mockResolvedValue({ + accepted: ['Enter'], + bytesWritten: 1, + seq: 12, + }); + }); + + it('sends encoded key names to a command target and emits host results', async () => { + await runSendKeysCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + keys: ['Enter'], + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'sendKeys', + { keys: ['Enter'] }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'send-keys', + json: false, + result: { + accepted: ['Enter'], + bytesWritten: 1, + seq: 12, + }, + lines: ['Sent 1 key(s) (1 byte(s), seq 12).'], + }); + }); + + it('rejects PROTOCOL_ERROR responses without sending success output', async () => { + mocks.sendRpc.mockResolvedValueOnce({ unexpected: true }); + + await expect( + runSendKeysCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + keys: ['Enter'], + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/signal.test.ts b/test/unit/commands/signal.test.ts new file mode 100644 index 0000000..e65adab --- /dev/null +++ b/test/unit/commands/signal.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + resolveCommandTarget: vi.fn(), + sendRpc: vi.fn(), +})); + +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +import { runSignalCommand } from '../../../src/cli/commands/signal.js'; +import { createLogger } from '../../../src/util/logger.js'; + +const TEST_CONTEXT = { + home: '/tmp/agent-tty', + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, +} as const; + +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; + +describe('signal command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); + }); + + it('sends an allowed signal to a command target and emits delivery', async () => { + await runSignalCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + signal: 'SIGUSR1', + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sockets/session-01.sock', + 'signal', + { signal: 'SIGUSR1' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'signal', + json: false, + result: { signal: 'SIGUSR1', delivered: true }, + lines: ['Signal SIGUSR1 delivered to session.'], + }); + }); + + it('rejects invalid signals after resolving the command target', async () => { + await expect( + runSignalCommand({ + context: TEST_CONTEXT, + json: false, + sessionId: 'session-01', + signal: 'BAD', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_SIGNAL, + message: + 'Signal must be one of: SIGTERM, SIGINT, SIGKILL, SIGHUP, SIGUSR1, SIGUSR2.', + }); + + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/type.test.ts b/test/unit/commands/type.test.ts index caab8eb..52bdb07 100644 --- a/test/unit/commands/type.test.ts +++ b/test/unit/commands/type.test.ts @@ -1,39 +1,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ERROR_CODES } from '../../../src/protocol/errors.js'; - const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), - sendRpc: vi.fn(), - readManifestIfExists: vi.fn(), - sessionDir: vi.fn(), - manifestPath: vi.fn(), - socketPath: vi.fn(), + resolveCommandTarget: vi.fn(), resolveCommandInputText: vi.fn(), + sendRpc: vi.fn(), })); -vi.mock('../../../src/cli/output.js', () => ({ - emitSuccess: mocks.emitSuccess, -})); - -vi.mock('../../../src/host/rpcClient.js', () => ({ - sendRpc: mocks.sendRpc, -})); - -vi.mock('../../../src/storage/manifests.js', () => ({ - readManifestIfExists: mocks.readManifestIfExists, +vi.mock('../../../src/cli/commandTarget.js', () => ({ + resolveCommandTarget: mocks.resolveCommandTarget, })); -vi.mock('../../../src/storage/sessionPaths.js', () => ({ - sessionDir: mocks.sessionDir, - manifestPath: mocks.manifestPath, - socketPath: mocks.socketPath, +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, })); vi.mock('../../../src/cli/commands/inputSource.js', () => ({ resolveCommandInputText: mocks.resolveCommandInputText, })); +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + import { runTypeCommand } from '../../../src/cli/commands/type.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -48,42 +37,18 @@ const TEST_CONTEXT = { configFile: null, } as const; -function createSessionRecord( - status: 'running' | 'exiting' | 'exited' | 'destroyed' = 'running', -) { - return { - version: 1, - sessionId: 'session-01', - createdAt: '2026-03-19T12:00:00.000Z', - updatedAt: '2026-03-19T12:00:01.000Z', - status, - command: ['/bin/sh'], - cwd: '/tmp/workspace', - cols: 80, - rows: 24, - hostPid: status === 'running' ? 123 : null, - childPid: status === 'running' ? 456 : null, - exitCode: status === 'exited' ? 0 : null, - exitSignal: null, - }; -} +const COMMAND_TARGET = { + sessionId: 'session-01', + sessionDirectory: '/tmp/agent-tty/sessions/session-01', + manifestPath: '/tmp/agent-tty/sessions/session-01/session.json', + socketPath: '/tmp/agent-tty/sockets/session-01.sock', + manifest: { status: 'running' }, +}; describe('type command', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.sessionDir.mockImplementation( - (_home: string, sessionId: string) => - `/tmp/agent-tty/sessions/${sessionId}`, - ); - mocks.manifestPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/session.json`, - ); - mocks.socketPath.mockImplementation( - (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, - ); - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('running'), - ); + mocks.resolveCommandTarget.mockResolvedValue(COMMAND_TARGET); mocks.resolveCommandInputText.mockResolvedValue('hello'); }); @@ -101,11 +66,21 @@ describe('type command', () => { text: 'hello', file: undefined, }); + expect(mocks.resolveCommandTarget).toHaveBeenCalledWith({ + home: '/tmp/agent-tty', + sessionId: 'session-01', + }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'hello\n' }, ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'type', + json: false, + result: {}, + lines: ['Typed text into session.'], + }); }); it('appends exactly one newline to file-backed text when requested', async () => { @@ -126,7 +101,7 @@ describe('type command', () => { file: '/tmp/input.txt', }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'from-file\n' }, ); @@ -144,7 +119,7 @@ describe('type command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'hello\n\n' }, ); @@ -159,13 +134,13 @@ describe('type command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: 'hello' }, ); }); - it('allows empty resolved text when appendNewline is enabled', async () => { + it('forwards an empty resolved string when appendNewline is enabled', async () => { mocks.resolveCommandInputText.mockResolvedValueOnce(''); await runTypeCommand({ @@ -177,32 +152,25 @@ describe('type command', () => { }); expect(mocks.sendRpc).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01/rpc.sock', + '/tmp/agent-tty/sockets/session-01.sock', 'type', { text: '\n' }, ); }); - it('throws SESSION_ALREADY_DESTROYED when the session is destroyed', async () => { - mocks.readManifestIfExists.mockResolvedValue( - createSessionRecord('destroyed'), - ); + it('preserves JSON mode in the success envelope', async () => { + await runTypeCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + text: 'hello', + }); - await expect( - runTypeCommand({ - context: TEST_CONTEXT, - json: false, - sessionId: 'session-01', - text: 'hello', - }), - ).rejects.toMatchObject({ - code: ERROR_CODES.SESSION_ALREADY_DESTROYED, - message: 'Session "session-01" is already destroyed.', - details: { - sessionId: 'session-01', - status: 'destroyed', - }, + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'type', + json: true, + result: {}, + lines: ['Typed text into session.'], }); - expect(mocks.sendRpc).not.toHaveBeenCalled(); }); });