mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Merge OPR.99.0.6.3 send verify outcomes
This commit is contained in:
commit
5f5bb4f363
5 files changed
+210
-3
No files matched your search
@@ -139,8 +139,18 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
|
||||
console.log(`Sent to ${session}`);
|
||||
if (opts.verify) {
|
||||
// Legacy line preserved verbatim (existing scripts grep `Verified:`);
|
||||
// the Delivery line below carries the honest three-outcome vocabulary
|
||||
// (OPR.99.0.6.3): `Verified: no` alone collapsed a landed-but-redraw-
|
||||
// raced send into the same line as a miss.
|
||||
const verified = res.data["verified"] as boolean | undefined;
|
||||
console.log(`Verified: ${verified ? "yes" : "no"}`);
|
||||
const outcome = res.data["outcome"] as string | undefined;
|
||||
if (outcome === "delivered") {
|
||||
console.log("Delivery: delivered (message landed; render confirmed)");
|
||||
} else if (outcome === "rendered-unconfirmed") {
|
||||
console.log(`Delivery: rendered-unconfirmed (landed; pane re-render not confirmed - confirm with: rig capture ${session})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@ describe("Send CLI", () => {
|
||||
if (parsed.session === "dev-impl@my-rig") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: "dev-impl@my-rig" }));
|
||||
} else if (parsed.session === "verified-session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: "verified-session", verified: true, outcome: "delivered" }));
|
||||
} else if (parsed.session === "racy-session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: "racy-session", verified: false, outcome: "rendered-unconfirmed" }));
|
||||
} else if (parsed.session === "dead-session") {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, sessionName: "dead-session", reason: "submit_failed", outcome: "failed", error: "Text is visible in 'dead-session' but was not submitted (Enter failed)." }));
|
||||
} else if (parsed.session === "busy-session") {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, sessionName: "busy-session", reason: "mid_work", error: "Target pane appears mid-task. Use force: true to send anyway." }));
|
||||
@@ -113,6 +122,52 @@ describe("Send CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// OPR.99.0.6.3 — honest delivery-outcome vocabulary; legacy Verified: line preserved.
|
||||
it("verify confirmed prints Delivery: delivered AND the legacy Verified: yes", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "verified-session", "hello", "--verify"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Sent to verified-session");
|
||||
expect(output).toContain("Verified: yes");
|
||||
expect(output).toContain("Delivery: delivered");
|
||||
});
|
||||
|
||||
it("verify redraw-race prints Delivery: rendered-unconfirmed (landed, with capture guidance) AND legacy Verified: no", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "racy-session", "hello", "--verify"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Sent to racy-session");
|
||||
expect(output).toContain("Verified: no");
|
||||
expect(output).toContain("Delivery: rendered-unconfirmed");
|
||||
expect(output).toContain("landed");
|
||||
expect(output).toContain("rig capture racy-session");
|
||||
// The middle is NOT dressed as failure: exit stays clean.
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("verify genuine transport failure stays an error path, distinct from the middle (discriminator)", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "dead-session", "hello", "--verify"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
// HTTP 502 -> error branch; no success lines, exit non-zero.
|
||||
expect(output).not.toContain("Sent to dead-session");
|
||||
expect(output).not.toContain("Delivery: rendered-unconfirmed");
|
||||
expect(output).toContain("not submitted");
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("verify --json passes the additive outcome field through", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "racy-session", "hello", "--verify", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.verified).toBe(false);
|
||||
expect(parsed.outcome).toBe("rendered-unconfirmed");
|
||||
});
|
||||
|
||||
it("send --json prints raw JSON", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello", "--json"]);
|
||||
|
||||
@@ -311,6 +311,21 @@ export interface SendResult {
|
||||
ok: boolean;
|
||||
sessionName: string;
|
||||
verified?: boolean;
|
||||
/**
|
||||
* OPR.99.0.6.3 — honest delivery-outcome vocabulary (additive; `verified`
|
||||
* keeps its exact semantics for existing parsers). Three distinguishable
|
||||
* states, mirroring the restore honest-outcome style:
|
||||
* - `delivered`: text + Enter landed AND the post-send capture re-confirmed
|
||||
* the snippet (the strong positive; was `Verified: yes`).
|
||||
* - `rendered-unconfirmed`: text + Enter BOTH succeeded (the message landed)
|
||||
* but the post-send capture raced a TUI redraw and could not re-confirm
|
||||
* the snippet. Landed-but-unconfirmable, NOT a failure — confirm with
|
||||
* `rig capture` if it matters. (Was collapsed into `Verified: no`.)
|
||||
* - `failed`: the transport itself failed (paste or Enter did not land) —
|
||||
* set on the send_failed / submit_failed returns for vocabulary symmetry;
|
||||
* their `ok:false` + HTTP mapping is unchanged.
|
||||
*/
|
||||
outcome?: "delivered" | "rendered-unconfirmed" | "failed";
|
||||
warning?: string;
|
||||
error?: string;
|
||||
reason?: string;
|
||||
@@ -706,6 +721,7 @@ export class SessionTransport {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: "send_failed",
|
||||
outcome: "failed",
|
||||
error: `Failed to send text to '${sessionName}': ${textResult.message}`,
|
||||
...(waitMode ? { sent: false, ...waitEvidence } : {}),
|
||||
};
|
||||
@@ -721,12 +737,16 @@ export class SessionTransport {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: "submit_failed",
|
||||
outcome: "failed",
|
||||
error: `Text is visible in '${sessionName}' but was not submitted (Enter failed). The agent may need manual attention.`,
|
||||
...(waitMode ? { sent: true, ...waitEvidence } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Verify if requested
|
||||
// 6. Verify if requested. At this point text + Enter BOTH succeeded, so the
|
||||
// message LANDED; the capture only re-confirms the render. Not re-confirming
|
||||
// (a TUI redraw race, or the capture throwing) is therefore the honest
|
||||
// middle outcome `rendered-unconfirmed` — never a failure (OPR.99.0.6.3).
|
||||
if (opts?.verify) {
|
||||
await this.sleep(500);
|
||||
try {
|
||||
@@ -735,9 +755,9 @@ export class SessionTransport {
|
||||
const preCount = countOccurrences(preVerifyContent ?? "", snippet);
|
||||
const postCount = countOccurrences(content ?? "", snippet);
|
||||
const verified = postCount > preCount;
|
||||
return { ok: true, sessionName, verified, ...(waitMode ? { sent: true, ...waitEvidence } : {}) };
|
||||
return { ok: true, sessionName, verified, outcome: verified ? "delivered" : "rendered-unconfirmed", ...(waitMode ? { sent: true, ...waitEvidence } : {}) };
|
||||
} catch {
|
||||
return { ok: true, sessionName, verified: false, ...(waitMode ? { sent: true, ...waitEvidence } : {}) };
|
||||
return { ok: true, sessionName, verified: false, outcome: "rendered-unconfirmed", ...(waitMode ? { sent: true, ...waitEvidence } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,8 @@ describe("SessionTransport", () => {
|
||||
const result = await transport.send("dev-impl@my-rig", "hello", { verify: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.verified).toBe(true);
|
||||
// OPR.99.0.6.3: a confirmed render is the strong positive outcome.
|
||||
expect(result.outcome).toBe("delivered");
|
||||
});
|
||||
|
||||
it("send with verify does not false-positive on pre-existing pane content", async () => {
|
||||
@@ -347,6 +349,79 @@ describe("SessionTransport", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.verified).toBe(false);
|
||||
// OPR.99.0.6.3: text + Enter both succeeded, only the render re-confirm
|
||||
// missed — the honest middle, NOT a failure.
|
||||
expect(result.outcome).toBe("rendered-unconfirmed");
|
||||
});
|
||||
|
||||
// OPR.99.0.6.3 — honest delivery-outcome vocabulary
|
||||
it("verify capture throwing after a successful send is the middle outcome, not a failure", async () => {
|
||||
seedCanonicalRig();
|
||||
let captureCount = 0;
|
||||
const tmux = mockTmux({
|
||||
capturePaneContent: async () => {
|
||||
captureCount++;
|
||||
// Pre-verify + mid-work captures succeed; the post-send verify capture throws
|
||||
// (e.g. pane busy mid-redraw).
|
||||
if (captureCount >= 3) throw new Error("pane busy");
|
||||
return "some output\n❯ ";
|
||||
},
|
||||
});
|
||||
const transport = createTransport(tmux);
|
||||
|
||||
const result = await transport.send("dev-impl@my-rig", "hello", { verify: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.outcome).toBe("rendered-unconfirmed");
|
||||
});
|
||||
|
||||
it("DISCRIMINATOR: a redraw-race send and a genuine transport failure surface differently", async () => {
|
||||
seedCanonicalRig();
|
||||
// Redraw-race: send + submit succeed, post-capture cannot re-confirm.
|
||||
const racyTmux = mockTmux({
|
||||
capturePaneContent: async () => "prior output\nhello\n❯ ",
|
||||
});
|
||||
const middle = await createTransport(racyTmux).send("dev-impl@my-rig", "hello", { verify: true });
|
||||
|
||||
// Genuine transport failure: Enter does not land.
|
||||
const brokenTmux = mockTmux({
|
||||
sendKeys: async () => ({ ok: false, code: "session_not_found", message: "session died" }),
|
||||
});
|
||||
const failure = await createTransport(brokenTmux).send("dev-impl@my-rig", "hello", { verify: true });
|
||||
|
||||
// The acceptance criterion: the two states are NOT equal in surfaced outcome.
|
||||
expect(middle.ok).toBe(true);
|
||||
expect(middle.outcome).toBe("rendered-unconfirmed");
|
||||
expect(failure.ok).toBe(false);
|
||||
expect(failure.outcome).toBe("failed");
|
||||
expect(middle.outcome).not.toBe(failure.outcome);
|
||||
});
|
||||
|
||||
it("send_failed and submit_failed carry outcome 'failed' (vocabulary symmetry, ok:false unchanged)", async () => {
|
||||
seedCanonicalRig();
|
||||
const noPaste = mockTmux({
|
||||
sendText: async () => ({ ok: false, code: "session_not_found", message: "gone" }),
|
||||
});
|
||||
const sendFailed = await createTransport(noPaste).send("dev-impl@my-rig", "hello");
|
||||
expect(sendFailed.ok).toBe(false);
|
||||
expect(sendFailed.reason).toBe("send_failed");
|
||||
expect(sendFailed.outcome).toBe("failed");
|
||||
|
||||
const noEnter = mockTmux({
|
||||
sendKeys: async () => ({ ok: false, code: "session_not_found", message: "gone" }),
|
||||
});
|
||||
const submitFailed = await createTransport(noEnter).send("dev-impl@my-rig", "hello");
|
||||
expect(submitFailed.ok).toBe(false);
|
||||
expect(submitFailed.reason).toBe("submit_failed");
|
||||
expect(submitFailed.outcome).toBe("failed");
|
||||
});
|
||||
|
||||
it("send without verify carries no outcome field (additive, verify-scoped)", async () => {
|
||||
seedCanonicalRig();
|
||||
const transport = createTransport(mockTmux());
|
||||
const result = await transport.send("dev-impl@my-rig", "hello");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.outcome).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test 7: send with mid-work detected → refusal
|
||||
|
||||
@@ -116,6 +116,53 @@ describe("transport routes", () => {
|
||||
expect(body.sessionName).toBe("dev-impl@my-rig");
|
||||
});
|
||||
|
||||
// OPR.99.0.6.3 — the additive outcome field auto-surfaces through the
|
||||
// c.json(result) passthrough; failure HTTP mapping unchanged.
|
||||
it("POST /send with verify surfaces the outcome field in the JSON response (passthrough)", async () => {
|
||||
seedRig();
|
||||
// Pre-existing pane content means the post-capture cannot re-confirm:
|
||||
// the redraw-race middle outcome.
|
||||
const tmux = {
|
||||
...mockTmux(),
|
||||
capturePaneContent: async () => "idle\nhello\n❯ ",
|
||||
} as unknown as TmuxAdapter;
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux });
|
||||
const app = createApp({ sessionTransport: transport });
|
||||
|
||||
const res = await app.request("/api/transport/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig", text: "hello", verify: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.verified).toBe(false);
|
||||
expect(body.outcome).toBe("rendered-unconfirmed");
|
||||
});
|
||||
|
||||
it("POST /send verify failure stays HTTP-error-distinct from the middle outcome (discriminator)", async () => {
|
||||
seedRig();
|
||||
const tmux = {
|
||||
...mockTmux(),
|
||||
sendKeys: async () => ({ ok: false as const, code: "session_not_found", message: "session died" }),
|
||||
} as unknown as TmuxAdapter;
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux });
|
||||
const app = createApp({ sessionTransport: transport });
|
||||
|
||||
const res = await app.request("/api/transport/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig", text: "hello", verify: true }),
|
||||
});
|
||||
// submit_failed maps to 502 (unchanged); the middle outcome above is 200.
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.reason).toBe("submit_failed");
|
||||
expect(body.outcome).toBe("failed");
|
||||
});
|
||||
|
||||
it("POST /send with mid-work refusal returns 409", async () => {
|
||||
seedRig();
|
||||
const tmux = {
|
||||
|
||||
Reference in new issue
Block a user