fix(daemon): deliver a deliberate override when the input can't be read (#2800)

An explicit --dangerously-interact send no longer stops on an unreadable input box. The answer is pasted and Enter is sent, with a warning that the box couldn't be read or wasn't recognized, so the staged answer wasn't confirmed. Enter is still withheld when the screen positively shows a recognized prompt, a numbered choice, a box holding something else, or a menu whose key-hint row (an Enter that selects or confirms and an Esc that cancels or closes) is the screen's last row. Fan-out sends print each seat's own advisory on the sent, retained and failed lines. The help, long help and capability map say when Enter is added.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-11-08 21:03:50 -08:00
1 parent a2355b7ffe
commit b29a70f14f
11 files changed
+167 -41

No files matched your search

+6 -2
View File
@@ -153,12 +153,16 @@ drive a recognized native menu as before. Otherwise, with the delivery guard
attached, it reads the visible composer under the seat's lease immediately before
pasting, after buffer preparation. Empty input proceeds. A recognized dim
suggestion is displayed separately and counts as empty; typed or pasted text
(author unknown) stops the send with `override_input_present`. An unreadable input
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
(author unknown) stops the send with `override_input_present`: single-send HTTP
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
not the hidden contents. OpenRig neither holds nor retries this stopped override.
An input that can't be read doesn't stop the override. The answer is typed and
nothing is cleared. The result reports `existingInput` with classification
`unreadable`, never empty, and a warning that the answer may join or submit
existing text.
Inspect the shown input in the native terminal, deliberately clear the prompt with
native/tmux controls, then issue the send again. Manual clearing runs outside
OpenRig's lease and observation checks and is not recorded. For a deliberate
+13 -6
View File
@@ -246,7 +246,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending. If it's still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1) and nothing is kept to send later; put separate work in a queue row (rig queue create)")
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added only for the complete visibly staged answer.")
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added for the complete visibly staged answer, or away from a recognized prompt when the input box can't be found or read.")
.option("--type-without-checking", "With --dangerously-interact: type without reading or clearing existing input; may join or submit that text")
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
.option("--urgent", "Mark this message urgent: it types at seats whose interruption level holds other messages. Anyone may mark one; the recipient sees who did")
@@ -322,8 +322,10 @@ would run the envelope lines; the output says so. Use
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
option or approve a permission) — the only override of the prompt guard; it
implies --raw and is audit-logged. The answer is typed as keystrokes: letters may
be menu shortcuts, and CR/ESC act as keys. An Enter is added only when the complete
answer is visibly staged in a recognized text input. Combining --verify with
be menu shortcuts, and CR/ESC act as keys. An Enter is added when the complete
answer is visibly staged in a recognized text input, and, away from a recognized
prompt or menu, when the input box can't be found or read (the result says so).
Combining --verify with
--dangerously-interact never adds an automatic submit retry, even when the daemon
did not detect a prompt or did not report its disposition. Unknown consumption
is not a send failure. On a managed seat with the delivery guard attached, a
@@ -332,7 +334,10 @@ pasted text stops the send and is shown only to you, separately from recognized
dim suggestions. Collapsed paste contents and unrecognized inputs are not known.
Inspect the native terminal, deliberately clear its prompt with native/tmux
controls, then retry. Manual clearing runs outside OpenRig's lease and observation
checks and is not recorded. --type-without-checking explicitly skips this read;
checks and is not recorded. An input that can't be read doesn't stop the send:
the answer is typed, nothing is cleared, the result says the input couldn't be
read, and the answer may join or submit what is there.
--type-without-checking explicitly skips this read;
it does not clear anything and may join or submit existing text. Human typing
can still race a capture and paste; the check is not atomic.
@@ -1134,9 +1139,11 @@ async function runFanOutSend(params: {
}
const data = res.data;
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
// Context beside a recipient's own result: it was in a managed compaction or restore when the send ran.
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown; warning?: string }>) ?? [];
// Context beside a recipient's own result: its own advisory (such as unreadable input), and whether
// it was in a managed compaction or restore when the send ran.
const printNotice = (r: (typeof results)[number]) => {
if (r.warning) console.log(`${r.sessionName}: Advisory: ${r.warning}`);
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
};
// Round-2 F1 (desk-binding): ONE verdict line per recipient. A
+25
View File
@@ -402,6 +402,31 @@ describe("Send CLI", () => {
expect(logs.join("\n")).not.toContain("typed, never submitted");
});
it("fan-out text output keeps each recipient's own advisory, such as unreadable input", async () => {
const unreadable = "Input could not be read and was not cleared; this send may join or submit existing text.";
const client = {
post: async (path: string) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 2, sent: 2, failed: 0, results: [
{ sessionName: "worker@test", ok: true, existingInput: { classification: "unreadable", fullContentsVisible: false }, warning: unreadable },
{ sessionName: "sibling@test", ok: true, existingInput: { classification: "unreadable", fullContentsVisible: false }, warning: `${unreadable} No trailing Enter.` },
],
} };
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "worker@test,sibling@test", "1", "--dangerously-interact", "--reason", "fixture",
]);
});
const output = logs.join("\n");
expect(output).toContain(`worker@test: Advisory: ${unreadable}`);
expect(output).toContain(`sibling@test: Advisory: ${unreadable} No trailing Enter.`);
expect(output).toContain("worker@test: Existing input: unreadable");
expect(output).toContain("2/2 delivered");
});
it("reports a fan-out refusal as unconfirmed alongside a successful recipient", async () => {
let submits = 0;
const client = {
@@ -851,7 +851,7 @@ rig send <session> "message" --json
- `--wait-for-idle <seconds>` — wait until the target is explicitly idle before sending. **Cannot be combined with `--force`** (that pairing is rejected: exit 1, nothing sent).
- `--raw` — send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts).
- `--dangerously-interact --reason "<why>"` — the ONLY override of the prompt/permission guard: deliberately drive an interactive prompt/permission block (implies `--raw`, requires `--reason`, audit-logged).
On a guard-attached managed seat, a non-menu override stops and shows existing typed/pasted input (author unknown) with `override_input_present`; unreadable input stops with `override_input_unknown`. Recognized dim suggestions are shown separately and count as empty; a collapsed paste shows only its label, not hidden contents.
On a guard-attached managed seat, a non-menu override stops and shows existing typed/pasted input (author unknown) with `override_input_present`. Input it can't read doesn't stop the override: the answer is typed, nothing is cleared, and the result reports the input as unreadable (never empty) with a warning that the answer may join or submit existing text. Recognized dim suggestions are shown separately and count as empty; a collapsed paste shows only its label, not hidden contents.
Inspect the input, deliberately clear the native prompt with native/tmux controls, then retry. Manual clearing is outside OpenRig's lease and observation checks and is not recorded; human typing can still race a read and paste.
When you deliberately need to type without that composer check (for example an unsupported input you inspected yourself), add `--type-without-checking` with `--dangerously-interact --reason "<why>"`. It does not clear input and may join or submit existing text. Ordinary sends and wakes keep their current rules.
- `--host <id>` — send on a remote host declared in `~/.openrig/hosts.yaml` (ssh hosts shell out; http hosts go CLI-direct to the remote daemon).
@@ -144,13 +144,15 @@ unseen — which makes messages the one delivery channel that never gets skipped
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
Inspect the prompt and establish authority for its effect before using that override. It types
exactly the bytes and keys you give it: Enter is added only for a complete answer
staged in a recognised Claude Code or Codex input box, so anywhere else put CR in the payload.
exactly the bytes and keys you give it: Enter is added for a complete answer staged in a
recognised Claude Code or Codex input box and, away from a recognised prompt or menu, when the
input box can't be found or read (the result says so). Anywhere else, put CR in the payload.
**On a managed seat it stops before typing into an input box that already holds typed or pasted
text**, and shows you that text, apart from a recognised dim suggestion; it types nothing. Clear
the box and retry, or add `--type-without-checking`, which reads and clears nothing and may join
or submit the existing text. Keys for a recognised native menu are sent as before, and collapsed
paste contents or an unrecognised input can't be read. Not yet checked on a live seat whose box
paste contents or an unrecognised input can't be read. An input it can't read doesn't stop it: it
types, clears nothing, and says the input couldn't be read. Not yet checked on a live seat whose box
holds only a suggestion, or typed text beside one. Don't use it to type over a half-written box:
to reach a seat with something that can't wait, use `--urgent` where its typing-guard level lets
urgent input through.
+48 -19
View File
@@ -1386,27 +1386,43 @@ export function hasExpectedStagedText(pane: string | null, expected: string, run
return stagedEvidence;
}
/** An interaction may consume its answer immediately. Only add Enter when the
* complete answer is still visible in a bounded current text input, never a
* numbered choice or a partial prefix. Unknown rendering is not consumption. */
function promptAnswerStaged(pane: string | null, answer: string, runtime: string | null): boolean {
if (!pane || !answer.trim() || /[\r\n\x1b]/.test(answer)) return false;
// Fail closed for an unknown runtime: with no scoped glyph set there is no way to
// say which prompt line is the live input (the pre-shared-matcher check did the same).
if (runtime !== "codex" && runtime !== "claude-code") return false;
/** An interaction may consume its answer immediately, so Enter is added for the complete answer still visible in a
* bounded current text input (`staged`), never a numbered choice or a partial prefix. `not_staged` is positive
* evidence against it: a recognized box holding something else, a numbered choice, a menu whose key-hint row ends
* the current screen, or no Enter by contract. A screen that couldn't be read (`unreadable`), or one with no input box
* the parser recognizes (`unrecognized`), is unknown, not evidence: unknown rendering is not consumption. */
function promptAnswerStaged(pane: string | null, answer: string, runtime: string | null): "staged" | "not_staged" | "unreadable" | "unrecognized" {
// The caller's own keys (CR, LF, Esc) or an empty answer never get an added Enter.
if (!answer.trim() || /[\r\n\x1b]/.test(answer)) return "not_staged";
// A runtime with no scoped glyph set has no known input line; there the caller puts CR in the payload.
if (runtime !== "codex" && runtime !== "claude-code") return "not_staged";
if (!pane) return "unreadable";
const promptClass = composerPromptClassForRuntime(runtime);
const lines = pane.split("\n");
// Only the LAST prompt line of this runtime's glyph set can be the current input;
// everything above it is transcript history, and another harness's glyph is not ours.
const inputIndex = findComposerInputLineIndex(lines, promptClass);
if (inputIndex < 0) return false;
const input = lines[inputIndex]!.trimStart();
// A numbered choice (`.` or `)`), e.g. `» 1)` or `❯ 1.`, is a menu selection, never the answer.
if (composerNumberedChoicePattern(promptClass).test(input)) return false;
if (inputIndex >= 0 && composerNumberedChoicePattern(promptClass).test(lines[inputIndex]!.trimStart())) return "not_staged";
// Where the input ends: Codex closes the composer on a blank line, Claude Code on a rule line.
const end = lines.findIndex((line, index) => index > inputIndex && (runtime === "codex"
const end = inputIndex < 0 ? -1 : lines.findIndex((line, index) => index > inputIndex && (runtime === "codex"
? line.trim() === "" : /^[─═-]{10,}$/.test(line.trim())));
return end > inputIndex && lines.slice(inputIndex, end).join("\n").trimStart().slice(1).trim() === answer.trim();
if (end <= inputIndex) return currentMenuKeyHint(lines) ? "not_staged" : "unrecognized";
return lines.slice(inputIndex, end).join("\n").trimStart().slice(1).trim() === answer.trim() ? "staged" : "not_staged";
}
/** Whether the current screen ends in a list or menu's key-hint row, where Enter acts on the focused item and Esc
* dismisses it: `Enter to select · Esc to cancel`, `Space to select · Enter to confirm · Esc to reject all`, or
* `t trust all · enter review · esc close`. Like the native menu recognizers, it reads only the last row (below
* OpenRig's seat rule, if drawn), so an old hint in the transcript holds nothing. A send or newline hint, or Esc to
* interrupt work, is a composer's or a busy seat's, not a menu's. */
function currentMenuKeyHint(lines: string[]): boolean {
const rows = lines.map((line) => line.trim()).filter(Boolean);
if (/^─{3,} [\w.-]+@[\w.-]+ ─+$/.test(rows.at(-1) ?? "")) rows.pop();
const segments = (rows.at(-1) ?? "").split(/\s+·\s+/);
return segments.length > 1
&& segments.some((segment) => /^(?:enter\b|⏎|↵)\s*(?:to\s+)?(?:select|confirm|choose|review|open|accept)\b/i.test(segment))
&& segments.some((segment) => /^esc\b\s*(?:to\s+)?(?:cancel|close|reject|dismiss|exit)\b/i.test(segment));
}
// Codex 0.160 input box: the last prompt line plus indented continuation rows, one blank row, then a footer
@@ -3377,6 +3393,8 @@ export class SessionTransport {
let draftAdvised = false;
// Broadcast can carry wait + override: after waiting, it still uses the same deliberate-input boundary.
const promptOverride = Boolean(opts?.dangerouslyInteract);
// Positive evidence of an interactive prompt: there an Enter after a consumed answer lands in the next prompt.
let overrideAtPrompt = false;
if (waitForIdleMs === undefined || promptOverride) {
const readiness = await this.classifySendReadiness({
sessionName,
@@ -3388,6 +3406,7 @@ export class SessionTransport {
// Explicit interaction selects key input even when readiness cannot recognize the prompt.
// Only bypassing a positive picker requires a successful audit; other states already sent.
if (opts?.dangerouslyInteract) {
overrideAtPrompt = readiness.state === "needs_input";
if (readiness.state === "needs_input" && (!opts.reason || opts.reason.trim().length === 0)) {
return {
ok: false,
@@ -3411,14 +3430,16 @@ export class SessionTransport {
existingInput = { target, classification, rows: input?.rows ?? [], suggestionRows: input?.suggestionRows ?? [],
cursor: input?.cursor ?? null, fullContentsVisible: !!input && input.state !== "unknown" && !input.collapsedPaste,
...(input?.unknownReason ? { reason: input.unknownReason } : {}), cleared: false };
const stopped = classification === "typed_or_pasted" || classification === "unreadable";
// Only seen typed or pasted text stops the override. An input that can't be read is not
// evidence of text, so the override delivers and says it couldn't read the input.
const stopped = classification === "typed_or_pasted";
const audit = this.recordPromptOverride({ ...auditInput, input: { classification, visibleRows: existingInput.rows.length,
suggestionRows: existingInput.suggestionRows.length, choice: unchecked ? "type_without_checking" : "inspect",
outcome: stopped ? "stopped" : "proceed", clearAttempted: false } });
if (!audit.ok) sendAdvisory = `prompt-override: audit unavailable (${audit.reason}).`;
if (stopped) throw new DeliveryGuardError(classification === "unreadable" ? "override_input_unknown" : "override_input_present",
classification === "unreadable" ? "Input could not be read. Nothing was typed. Inspect the native terminal, deliberately clear its prompt with native/tmux controls, then retry; or explicitly choose --type-without-checking (does not clear)."
: "Existing typed or pasted input (author unknown). Nothing was typed. Inspect the shown input, deliberately clear the prompt with native/tmux controls, then retry. Manual clearing is outside OpenRig's lease and observation checks and is not recorded.");
if (stopped) throw new DeliveryGuardError("override_input_present",
"Existing typed or pasted input (author unknown). Nothing was typed. Inspect the shown input, deliberately clear the prompt with native/tmux controls, then retry. Manual clearing is outside OpenRig's lease and observation checks and is not recorded.");
if (classification === "unreadable") sendAdvisory = [sendAdvisory, "Input could not be read and was not cleared; this send may join or submit existing text."].filter(Boolean).join(" ");
if (unchecked) sendAdvisory = [sendAdvisory, "Input was not checked or cleared; this send may join or submit existing text."].filter(Boolean).join(" ");
};
}
@@ -3627,11 +3648,19 @@ export class SessionTransport {
const pane = await this.runStage("session_transport.prompt_override_pre_submit_capture",
() => this.tmuxAdapter.capturePaneContent(readTarget, 50)).catch(() => null);
if (runtime === "claude-code" && bindingChanged()) return observe(changedRecipient(true));
if (!promptAnswerStaged(pane, text, runtime)) {
const staged = promptAnswerStaged(pane, text, runtime);
// A screen that couldn't be read, or one with no input box the parser recognizes, is not evidence the answer
// was consumed or joined: outside a recognized prompt, submit it and say so. Visible evidence, or a
// recognized prompt, still withholds Enter.
if (staged === "not_staged" || (staged !== "staged" && overrideAtPrompt)) {
return observe({ ok: true, sessionName, sent: true, verified: false, outcome: "rendered-unconfirmed", promptInteraction: "unverified",
warning: [sendAdvisory, "prompt-override: answer sent as unbracketed input; submission unverified. No trailing Enter: the complete answer is not visibly staged (it may have been consumed, the prompt changed, or observation is unavailable)."].filter(Boolean).join(" ") });
}
sendAdvisory = [sendAdvisory, "prompt-override: answer sent as unbracketed input; Enter submitted the complete still-staged answer."].filter(Boolean).join(" ");
sendAdvisory = [sendAdvisory, staged === "staged"
? "prompt-override: answer sent as unbracketed input; Enter submitted the complete still-staged answer."
: staged === "unreadable"
? "prompt-override: answer sent as unbracketed input; the screen couldn't be read, so Enter was sent without confirming the staged answer."
: "prompt-override: answer sent as unbracketed input; no input box was recognized on the screen, so Enter was sent without confirming the staged answer."].filter(Boolean).join(" ");
}
// 5. Submit (Enter)
-1
View File
@@ -186,7 +186,6 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
delivery_in_progress: 409,
delivery_indeterminate: 409,
override_input_present: 409,
override_input_unknown: 409,
draft_input_busy: 409,
draft_input_unknown: 409,
draft_input_changed: 409,
@@ -1032,13 +1032,22 @@ describe("deliberate override preserves existing input", () => {
expect(f.observations).toEqual([]);
});
it.each([null, "[Pasted text #4 +18 lines]"])("shows unreadable or collapsed input honestly: %s", async body => {
const f = fixture(); f.bodies.a = body;
it("stops on a collapsed paste and shows only its label", async () => {
const f = fixture(); f.bodies.a = "[Pasted text #4 +18 lines]";
const result = await f.send("override", "incoming", override);
expect(result).toMatchObject({ ok: false, sent: false, existingInput: { classification: body === null ? "unreadable" : "typed_or_pasted", fullContentsVisible: false } });
expect(result).toMatchObject({ ok: false, sent: false, existingInput: { classification: "typed_or_pasted", fullContentsVisible: false } });
expect(f.writes).toEqual([]);
});
it("delivers on unreadable input and reports it as unreadable, never empty", async () => {
const f = fixture(); f.bodies.a = null;
const result = await f.send("override", "incoming", override);
expect(result).toMatchObject({ ok: true, sent: true, existingInput: { classification: "unreadable", fullContentsVisible: false, cleared: false } });
expect(result.warning).toContain("Input could not be read and was not cleared; this send may join or submit existing text.");
expect(f.writes).toEqual(["paste:a:incoming"]);
expect(f.submissions).toEqual([]); // Unreadable input cannot prove the answer staged for Enter.
});
it.each(["", "typed"])("separates the suggestion from %j and never stores either", async typed => {
const f = fixture(); const suggestion = " U3 suggestion private";
vi.mocked(f.tmux.captureComposerSnapshot).mockResolvedValue({ ...frame(`${typed}\x1b[2m${suggestion}\x1b[22m`),
@@ -151,6 +151,55 @@ describe("explicit prompt answer delivery", () => {
expect(f.state.commands.some(a => a[1] === "send-keys")).toBe(false);
});
it("submits outside a recognized prompt when the screen can't be read, and says so", async () => {
const f = fixture("ordinary");
f.tmux.capturePaneContent = async () => { throw new Error("capture unavailable"); };
const result = await f.transport.send(f.name, "green", { dangerouslyInteract: true, reason: "answer an ordinary input" });
expect(result).toMatchObject({ ok: true, promptInteraction: "enter-sent" });
expect(result.warning).toContain("the screen couldn't be read, so Enter was sent without confirming the staged answer.");
expect(f.state.submitted).toEqual(["green"]);
expect(f.state.commands.filter(a => a[1] === "send-keys")).toEqual([["tmux", "send-keys", "-t", f.name, "Enter"]]);
});
it.each(["green", "❯ green"])("submits outside a recognized prompt when no input box is recognized (%j), and says so", async capture => {
const f = fixture("ordinary"); f.state.capture = capture;
const result = await f.transport.send(f.name, "green", { dangerouslyInteract: true, reason: "answer an ordinary input" });
expect(result).toMatchObject({ ok: true, promptInteraction: "enter-sent" });
expect(result.warning).toContain("no input box was recognized on the screen, so Enter was sent without confirming the staged answer.");
expect(f.state.received).toEqual(["green"]);
expect(f.state.commands.filter(a => a[1] === "send-keys")).toEqual([["tmux", "send-keys", "-t", f.name, "Enter"]]);
});
it.each(["green", "❯ green"])("withholds Enter at a recognized prompt when no input box is recognized (%j)", async capture => {
const f = fixture("text"); f.state.capture = capture;
const result = await f.transport.send(f.name, "green", { dangerouslyInteract: true, reason: "answer the text field" });
expect(result).toMatchObject({ ok: true, sent: true, verified: false, outcome: "rendered-unconfirmed", promptInteraction: "unverified" });
expect(f.state.received).toEqual(["green"]);
expect(f.state.commands.some(a => a[1] === "send-keys")).toBe(false);
});
it.each(["Enter to select · Esc to cancel", "Space to select · Enter to confirm · Esc to reject all",
"t trust all · enter review · esc close", "⏎ to select · esc to cancel"])("withholds Enter on a list whose key-hint row ends the current screen (%j)", async hints => {
const f = fixture("ordinary"); f.state.capture = `Pick one\n❯ Focused item\n Other item\n${hints}`;
const result = await f.transport.send(f.name, "green", { dangerouslyInteract: true, reason: "answer an ordinary input" });
expect(result).toMatchObject({ ok: true, sent: true, promptInteraction: "unverified" });
expect(f.state.commands.some(a => a[1] === "send-keys")).toBe(false);
});
it("submits under an old menu hint left in the transcript above the current box", async () => {
const f = fixture("ordinary"); f.state.capture = "Pick one\n Earlier item\nEnter to select · Esc to cancel\n\n❯ green";
const result = await f.transport.send(f.name, "green", { dangerouslyInteract: true, reason: "answer an ordinary input" });
expect(result).toMatchObject({ ok: true, promptInteraction: "enter-sent" });
expect(f.state.commands.filter(a => a[1] === "send-keys")).toEqual([["tmux", "send-keys", "-t", f.name, "Enter"]]);
});
it.each(["✻ Working… (esc to interrupt)", "⏎ send · esc to interrupt", "enter to submit · esc to interrupt"])("submits under a working or idle footer, not a menu (%j)", async footer => {
const f = fixture("ordinary"); f.state.capture = `❯ green\n${footer}`;
const result = await f.transport.send(f.name, "green", { dangerouslyInteract: true, reason: "answer an ordinary input" });
expect(result).toMatchObject({ ok: true, promptInteraction: "enter-sent" });
expect(f.state.commands.filter(a => a[1] === "send-keys")).toEqual([["tmux", "send-keys", "-t", f.name, "Enter"]]);
});
it("keeps ordinary bracketed delivery and its Enter unchanged", async () => {
const f = fixture("ordinary");
const result = await f.transport.send(f.name, "ordinary message");
@@ -153,7 +153,7 @@ describe("real adapter/transport guard boundaries with no external effects", ()
expect(f.commands).toEqual([]);expect(f.files).toEqual([]);expect(f.tmux.listPanes).not.toHaveBeenCalled();
expect(f.outbox.heldForNode("a").total).toBe(4);f.db.close();
});
it("an explicit override under hold reads the composer and stops on unreadable input without typing or retaining", async () => {
it("an explicit override under hold reads the composer and delivers on unreadable input, saying it couldn't read it", async () => {
// This send reaches session metadata; use the existing migrated fixture, not the hold-only schema.
const f = queueFixture(); await f.guard.set("a", true, "person", "draft");
f.db.prepare("UPDATE nodes SET runtime = 'terminal' WHERE id = 'a'").run();
@@ -162,11 +162,13 @@ describe("real adapter/transport guard boundaries with no external effects", ()
sleep: async () => {}, captureObserver: observer });
const capture = vi.spyOn(f.tmux, "captureComposerSnapshot").mockResolvedValue(null);
const result = await transport.send("worker@test", "message", { dangerouslyInteract: true, reason: "explicit" });
expect(result).toMatchObject({ ok: false, reason: "override_input_unknown", sent: false,
existingInput: { classification: "unreadable", reason: expect.any(String) } });
// Unknown is reported as unknown, never as empty, and it doesn't stop the explicit override.
expect(result).toMatchObject({ ok: true,
existingInput: { classification: "unreadable", fullContentsVisible: false, reason: expect.any(String) } });
expect(result.warning).toContain("Input could not be read and was not cleared; this send may join or submit existing text.");
expect(capture).toHaveBeenCalledTimes(1);
// Preparing the inert buffer precedes inspection; no pane input occurs.
expect(f.writes.some(command => /paste-buffer|send-keys/.test(command))).toBe(false);
expect(f.writes.some(command => /paste-buffer/.test(command))).toBe(true);
// The override is not retained behind the hold, and the capture observer stays off for overrides.
expect(f.outbox.heldForNode("a").total).toBe(0);
expect(f.db.prepare("SELECT count(*) AS n FROM outbox_entries").get()).toEqual({ n: 0 });
expect(observer.record).not.toHaveBeenCalled(); f.db.close();
+1 -1
View File
@@ -93,7 +93,7 @@
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
"openrig-skills/SKILL.md": "6d7bf933ac40531f11757a31a93d26dcbda617f1b85f48a21865b3a5c5e2ddaa",
"openrig-user/SKILL.md": "7ae6d60263ccf570511666e3b10cd577ae2ab4e8ae636566a5e6b93faac06201",
"openrig-user/SKILL.md": "6176761014c9ecd61675cb47d4de991de6dd126a92be7e06c3ef31aa0573f82e",
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
"orienting-to-an-inherited-seat/SKILL.md": "252fed402f19027056bbba241df3305be88de3175b67dc8fe051143bb0d4412b",
"queue-handoff/SKILL.md": "96251f6830006bb9d8ede2e5826ab936df4a9fb91090187a23ccaf860ab76336",