Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions cli/src/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,38 @@ describe("send/reply commands", () => {
expect(mockSend.mock.calls[0][2]).toEqual({ idempotencyKey: "evt-42" });
});

it("treats any non-'sent' status as HELD (open-set status contract)", async () => {
it("treats an unknown status as non-retryable without calling it a review hold", async () => {
mockSend.mockResolvedValue({ messageId: "msg_u", status: "some_future_hold" });
const { send } = await import("../commands/send.js");
await send({ to: ["you@example.com"], subject: "s", body: "b" });

expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("some_future_hold"));
expect(process.exitCode).toBe(3);
expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("do NOT retry"));
expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("msg_u"));
expect(process.exitCode).toBe(7);
});

it("reports a failed status as terminal and non-retryable", async () => {
mockSend.mockResolvedValue({ messageId: "msg_failed", status: "failed" });
const { send } = await import("../commands/send.js");
await send({ to: ["you@example.com"], subject: "s", body: "b" });

expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("terminal status \"failed\""));
expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("do NOT retry"));
expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("msg_failed"));
expect(mockStderr).not.toHaveBeenCalledWith(expect.stringContaining("unrecognized"));
expect(process.exitCode).toBe(7);
});

it("exits OK when an async send is durably accepted", async () => {
mockSend.mockResolvedValue({ messageId: "msg_accepted", status: "accepted" });
const { send } = await import("../commands/send.js");

await send({ to: ["you@example.com"], subject: "hi", body: "hello" });

expect(mockStdout).toHaveBeenCalledWith("msg_accepted\n");
expect(mockStderr).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});

it("sets exit code HELD (3) with a warning when the send is pending_review", async () => {
Expand Down Expand Up @@ -149,6 +174,17 @@ describe("send/reply commands", () => {
expect(process.exitCode).toBe(3);
});

it("exits OK when an async reply is durably accepted", async () => {
mockReply.mockResolvedValue({ messageId: "msg_reply_accepted", status: "accepted" });
const { reply } = await import("../commands/send.js");

await reply("msg_orig", { body: "answer" });

expect(mockStdout).toHaveBeenCalledWith("msg_reply_accepted\n");
expect(mockStderr).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});

it("sends markup-only HTML whose derived text fallback is empty", async () => {
mockReadFileSync.mockReturnValue('<img src="cid:logo"><table><tr><td></td></tr></table>');
mockSend.mockResolvedValue({ messageId: "msg_img", status: "sent" });
Expand Down
3 changes: 2 additions & 1 deletion cli/src/bin/e2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ Exit codes (stable scripting contract):
0 success
1 transient error (network / 5xx / rate limit) — retry may help
2 usage error (bad flags or arguments)
3 send accepted but HELD for review (pending_review) — not delivered
3 send HELD for review (pending_review) — not delivered
4 bad credentials or wrong key scope
5 permanent request error (not found / invalid / conflict) — do NOT retry
6 bounded wait (listen --once --until) expired with no matching message
7 failed or unrecognized persisted send outcome — do NOT retry; inspect its id
`;

function parseArgs(argv: string[]): { command: string; args: string[] } {
Expand Down
28 changes: 21 additions & 7 deletions cli/src/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,34 @@ function emitSendResult(result: SendResultView, json?: boolean): void {
} else {
process.stdout.write(result.messageId + "\n");
}
// `status` is an OPEN SET per the spec ("tolerate unknown values") — so the
// delivered check is `=== "sent"`, inverted. A future held-variant status
// must fail loud (exit HELD), never slip through as exit 0: an unknown
// outcome is "not known to be delivered".
if (result.status !== "sent") {
if (result.status === "pending_review") {
process.stderr.write(
`WARNING: send returned status "${result.status}" — the message did NOT go out now. ` +
"pending_review means it is held for approval: disable outbound protection on this " +
"WARNING: send returned status \"pending_review\" — the message did NOT go out now. " +
"It is held for approval: disable outbound protection on this " +
"agent or approve it in the review queue.\n",
);
// exitCode + return, NOT process.exit(): a hard exit can truncate piped
// stdout before the message id above flushes, and scripts need that id to
// approve the held message.
process.exitCode = EXIT.HELD;
} else if (result.status === "failed") {
process.stderr.write(
`WARNING: send reached terminal status "failed" for message "${result.messageId}". ` +
`The server persisted the failure outcome; do NOT retry automatically. ` +
`Inspect it with: e2a messages get ${result.messageId}\n`,
);
process.exitCode = EXIT.SEND_OUTCOME;
} else if (result.status !== "sent" && result.status !== "accepted") {
// Response status values are an open set. Do not silently report an
// unfamiliar outcome as success, but do not misclassify it as a known
// review hold or a retryable transport error either: this successful API
// response carries a message id and may already be durably persisted.
process.stderr.write(
`WARNING: send returned unrecognized status "${result.status}" for message "${result.messageId}". ` +
`The message may already be durably persisted; do NOT retry automatically. ` +
`Inspect it with: e2a messages get ${result.messageId}\n`,
);
process.exitCode = EXIT.SEND_OUTCOME;
}
}

Expand Down
20 changes: 12 additions & 8 deletions cli/src/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@
// CI lanes) branch on these instead of parsing output, so the values are
// frozen once published — add new codes, never renumber.
//
// HELD exists because a held send is an HTTP 2xx success (202 Accepted): the API
// accepted the message but parked it in the review queue (`status:
// "pending_review"`), so the recipient got nothing. Scripts that treat "exit 0"
// as "delivered" would silently report into a queue nobody reads — the exact
// failure mode that bit the tether harness twice. The CLI branches on the
// response body's `status`, not the HTTP code, so this holds regardless of
// whether the outcome came back 200 (sent) or 202 (accepted/pending_review).
// HELD exists because a review-held send is an HTTP 2xx success with response
// status `pending_review`, but the recipient got nothing. Scripts that treat
// every 2xx as success would silently report into a queue nobody reads — the
// exact failure mode that bit the tether harness twice. The distinct async
// response status `accepted` means the message is durably queued for delivery
// and exits OK. The CLI branches on the response body's status, not HTTP status.
// A terminal failed or unknown 2xx outcome gets its own non-retry exit: the
// returned message id proves the server created an observable result, so a
// fresh retry could send a duplicate. Callers should inspect that message.
export const EXIT = {
OK: 0,
/** Network, server, or unexpected error. */
ERROR: 1,
/** Bad flags or arguments. */
USAGE: 2,
/** Send accepted but held for review (pending_review) — NOT delivered. */
/** Send held for review (pending_review) — NOT delivered. */
HELD: 3,
/** Bad credentials or wrong key scope for the operation. */
AUTH: 4,
Expand All @@ -27,6 +29,8 @@ export const EXIT = {
REQUEST: 5,
/** A deadline-bounded wait (`listen --once --until`) expired unmatched. */
TIMEOUT: 6,
/** A persisted send failed or returned an unknown outcome; do not retry. */
SEND_OUTCOME: 7,
} as const;

/** Write a message to stderr and exit with the given code. */
Expand Down
Loading