From 7b08aacb3b8e88996442cb1bea8547f68a12891a Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Thu, 30 Jul 2026 15:44:36 -0500 Subject: [PATCH] Structure plan push failures for agents The authoring Skill needs to distinguish damaged local input from an ambiguous remote outcome without matching English prose. Give every handled plan push failure a stable JSON discriminator while preserving the existing shell exit classes.\n\nWrap validated server diagnostics and problems in a bounded response projection, retain private recovery state only after an accepted Plan cannot be saved locally, and keep raw input and transport details out of stderr. Exercise the contract through the runner and installed package. --- README.md | 17 ++++ scripts/smoke-package.js | 53 +++++++++- src/cli.js | 186 ++++++++++++++++++++++++++++++----- src/commands/plan-push.js | 47 +++++++-- test/plan-push.test.js | 202 ++++++++++++++++++++++++++++++++++---- 5 files changed, 447 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index e70790b..9fb2b29 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,23 @@ fully verify. Until First Draft has a read or reconciliation endpoint, an accept verified may require manual recovery. If a verified response cannot replace local state, preserve the printed recovery state; an adjacent `.tmp` file may contain the same private recovery copy. +Every handled `plan push` failure writes exactly one JSON object to standard error. Agents should branch on its +stable `error` value, not on the human-readable `detail`: + +| `error` | Exit | Meaning | +| ------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `invalid_arguments` | 2 | The command syntax is invalid; no request was made. | +| `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. | +| `local_input_unreadable` | 1 | The local Plan or state could not be read; no request was made. | +| `request_outcome_unknown` | 1 | A sent request or its response could not be verified. Stop and reconcile before pushing again. The object includes `status` when one was received. | +| `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | +| `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. | + +Failure output never includes command arguments, local Plan bytes, raw network errors, or unvalidated response +bodies. Optional fields inside a validated diagnostic are omitted when absent or when the CLI cannot validate their +complete shape. Exit status remains a broad shell-level class; the `error` value is the machine-readable recovery +contract. + ## Trust model - The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 5d2d548..fb6634b 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -16,6 +16,13 @@ const npmCli = requiredEnvironmentVariable("npm_execpath"); const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")); const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "firstdraft-cli-")); const installationDirectory = path.join(temporaryDirectory, "installation"); +const packedExecutable = path.join( + installationDirectory, + "node_modules", + "firstdraft", + "bin", + "firstdraft.js", +); try { const packResult = runNpm([ @@ -67,6 +74,25 @@ try { /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/, ); assert.equal(subjectId.stderr, ""); + + const invalidPush = spawnPackedCli( + ["plan", "push", "--canary-secret-option"], + installationDirectory, + ); + assert.equal(invalidPush.status, 2); + assert.deepEqual(JSON.parse(invalidPush.stderr), { + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan push --help' for usage.", + }); + assert.doesNotMatch(invalidPush.stderr, /canary-secret/); + + const localPush = spawnPackedCli(["plan", "push"], installationDirectory); + assert.equal(localPush.status, 1); + assert.deepEqual(JSON.parse(localPush.stderr), { + error: "local_input_unreadable", + detail: + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.", + }); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); } @@ -76,10 +102,7 @@ try { * @param {string} [cwd] */ function runNpm(arguments_, cwd = process.cwd()) { - const result = spawnSync(process.execPath, [npmCli, ...arguments_], { - cwd, - encoding: "utf8", - }); + const result = spawnNpm(arguments_, cwd); assert.equal( result.status, @@ -90,6 +113,28 @@ function runNpm(arguments_, cwd = process.cwd()) { return result; } +/** + * @param {string[]} arguments_ + * @param {string} [cwd] + */ +function spawnNpm(arguments_, cwd = process.cwd()) { + return spawnSync(process.execPath, [npmCli, ...arguments_], { + cwd, + encoding: "utf8", + }); +} + +/** + * @param {string[]} arguments_ + * @param {string} [cwd] + */ +function spawnPackedCli(arguments_, cwd = process.cwd()) { + return spawnSync(process.execPath, [packedExecutable, ...arguments_], { + cwd, + encoding: "utf8", + }); +} + /** @param {string} name */ function requiredEnvironmentVariable(name) { const value = process.env[name]; diff --git a/src/cli.js b/src/cli.js index dc74945..76bc4b7 100644 --- a/src/cli.js +++ b/src/cli.js @@ -92,16 +92,15 @@ const PLAN_INIT_USAGE_ERROR = const PLAN_INIT_ERROR = "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n"; const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; -const PLAN_PUSH_USAGE_ERROR = - "Invalid arguments.\nRun 'firstdraft plan push --help' for usage.\n"; -const PLAN_PUSH_CONFIGURATION_ERROR = - "Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n"; -const PLAN_PUSH_LOCAL_ERROR = - "Could not read the local First Draft Plan or state. No network request was made.\n"; -const PLAN_PUSH_NETWORK_ERROR = - "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; -const PLAN_PUSH_PROTOCOL_ERROR = - "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_PUSH_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft plan push --help' for usage."; +const PLAN_PUSH_INVALID_CONFIGURATION_DETAIL = + "Invalid First Draft API configuration. Run 'firstdraft plan push --help' for usage."; +const PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL = + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery."; +const PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL = + "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed."; +const PLAN_PUSH_SERVER_REJECTED_DETAIL = "First Draft rejected the Plan."; const PLAN_SUBJECT_ID_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; @@ -357,7 +356,10 @@ async function runPlanPush({ ); if (!parsed || repeatedValueOption(parsed.tokens)) { - stderr.write(PLAN_PUSH_USAGE_ERROR); + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_PUSH_INVALID_ARGUMENTS_DETAIL, + }); return 2; } @@ -378,22 +380,30 @@ async function runPlanPush({ }); } catch (error) { if (error instanceof PlanPushConfigurationError) { - stderr.write(PLAN_PUSH_CONFIGURATION_ERROR); + writeJson(stderr, { + error: "invalid_configuration", + detail: PLAN_PUSH_INVALID_CONFIGURATION_DETAIL, + }); return 2; } if (error instanceof PlanPushLocalError) { - stderr.write(PLAN_PUSH_LOCAL_ERROR); - return 1; - } - - if (error instanceof PlanPushNetworkError) { - stderr.write(PLAN_PUSH_NETWORK_ERROR); + writeJson(stderr, { + error: "local_input_unreadable", + detail: PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL, + }); return 1; } - if (error instanceof PlanPushProtocolError) { - stderr.write(PLAN_PUSH_PROTOCOL_ERROR); + if ( + error instanceof PlanPushNetworkError || + error instanceof PlanPushProtocolError + ) { + writeJson(stderr, { + error: "request_outcome_unknown", + detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + }); return 1; } @@ -411,11 +421,22 @@ async function runPlanPush({ } if (!("etag" in result)) { - if (result.body === null) { - stderr.write(`First Draft rejected the Plan (HTTP ${result.status}).\n`); - } else { - writeJson(stderr, result.body); + if (result.responseKind === null) { + writeJson(stderr, { + error: "request_outcome_unknown", + detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, + status: result.status, + }); + return 1; } + + const response = safeRejectedResponse(result.responseKind, result.body); + writeJson(stderr, { + error: "server_rejected", + detail: PLAN_PUSH_SERVER_REJECTED_DETAIL, + status: result.status, + ...(response ? { response } : {}), + }); return 1; } @@ -578,3 +599,120 @@ function isParseArgsError(error) { function writeJson(writer, value) { writer.write(`${JSON.stringify(value, null, 2)}\n`); } + +/** + * @param {"diagnostics" | "problem" | null} responseKind + * @param {Record | null} body + */ +function safeRejectedResponse(responseKind, body) { + if ( + responseKind === "diagnostics" && + body && + Array.isArray(body.diagnostics) + ) { + return { + source_sha256: body.source_sha256, + diagnostics: body.diagnostics.map(safeDiagnostic), + }; + } + + if (responseKind === "problem" && body) { + return { + ...(body.type === "about:blank" ? { type: body.type } : {}), + title: body.title, + status: body.status, + code: body.code, + detail: body.detail, + }; + } + + return null; +} + +/** @param {unknown} value */ +function safeDiagnostic(value) { + if (!isRecord(value)) return {}; + + const location = safeSourceLocation(value.location); + const subject = safeDiagnosticSubject(value.subject); + const relatedLocations = safeSourceLocations(value.related_locations); + const suggestions = safeSuggestions(value.suggestions); + + return { + ...(typeof value.code === "string" ? { code: value.code } : {}), + ...(value.severity === "error" || value.severity === "warning" + ? { severity: value.severity } + : {}), + ...(typeof value.message === "string" ? { message: value.message } : {}), + ...(location ? { location } : {}), + ...(value.subject === null ? { subject: null } : {}), + ...(subject ? { subject } : {}), + ...(relatedLocations ? { related_locations: relatedLocations } : {}), + ...(suggestions ? { suggestions } : {}), + }; +} + +/** @param {unknown} value */ +function safeSourceLocations(value) { + if (!Array.isArray(value)) return null; + + const locations = value.map(safeSourceLocation); + return locations.every(Boolean) ? locations : null; +} + +/** @param {unknown} value */ +function safeSuggestions(value) { + if ( + !Array.isArray(value) || + !value.every((suggestion) => typeof suggestion === "string") + ) { + return null; + } + + return value; +} + +/** @param {unknown} value */ +function safeSourceLocation(value) { + if (!isRecord(value)) return null; + + if (typeof value.source_pointer === "string") { + return { source_pointer: value.source_pointer }; + } + + if ( + Number.isSafeInteger(value.line) && + Number(value.line) > 0 && + Number.isSafeInteger(value.column) && + Number(value.column) > 0 + ) { + return { line: value.line, column: value.column }; + } + + return null; +} + +/** @param {unknown} value */ +function safeDiagnosticSubject(value) { + if ( + !isRecord(value) || + typeof value.kind !== "string" || + typeof value.readable_path !== "string" || + (value.subject_uuid !== undefined && typeof value.subject_uuid !== "string") + ) { + return null; + } + + return { + kind: value.kind, + readable_path: value.readable_path, + ...(typeof value.subject_uuid === "string" + ? { subject_uuid: value.subject_uuid } + : {}), + }; +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/commands/plan-push.js b/src/commands/plan-push.js index c02ffee..073394e 100644 --- a/src/commands/plan-push.js +++ b/src/commands/plan-push.js @@ -36,8 +36,25 @@ const DEFAULT_FILE_SYSTEM = { export class PlanPushConfigurationError extends Error {} export class PlanPushLocalError extends Error {} -export class PlanPushNetworkError extends Error {} -export class PlanPushProtocolError extends Error {} + +export class PlanPushNetworkError extends Error { + /** + * @param {string} message + * @param {{cause?: Error, status?: number}} [options] + */ + constructor(message, options = {}) { + super(message, { cause: options.cause }); + this.status = options.status; + } +} + +export class PlanPushProtocolError extends Error { + /** @param {string} message @param {{status: number}} options */ + constructor(message, options) { + super(message); + this.status = options.status; + } +} export class PlanPushStateWriteError extends Error { /** @@ -73,7 +90,8 @@ export class PlanPushStateWriteError extends Error { /** * @typedef {object} RejectedPushResult * @property {number} status - * @property {unknown} body + * @property {"diagnostics" | "problem" | null} responseKind + * @property {Record | null} body */ /** @@ -125,6 +143,7 @@ export async function pushPlan({ ) { throw new PlanPushProtocolError( "First Draft returned invalid diagnostics.", + { status: response.status }, ); } @@ -132,13 +151,18 @@ export async function pushPlan({ if (response.ok) { throw new PlanPushProtocolError( "First Draft returned an unexpected success status.", + { status: response.status }, ); } - return { - status: response.status, - body: - response.status === 422 || isProblemBody(response, body) ? body : null, - }; + if (response.status === 422) { + return { status: response.status, responseKind: "diagnostics", body }; + } + + if (isProblemBody(response, body)) { + return { status: response.status, responseKind: "problem", body }; + } + + return { status: response.status, responseKind: null, body: null }; } const etag = response.headers.get("etag"); @@ -151,6 +175,7 @@ export async function pushPlan({ ) { throw new PlanPushProtocolError( "First Draft returned an invalid success response.", + { status: response.status }, ); } @@ -385,7 +410,9 @@ async function readResponseBytes(response) { if (response.body !== null) { await response.body.cancel().catch(() => undefined); } - throw new PlanPushProtocolError("The First Draft response is too large."); + throw new PlanPushProtocolError("The First Draft response is too large.", { + status: response.status, + }); } if (response.body === null) return Buffer.alloc(0); @@ -404,6 +431,7 @@ async function readResponseBytes(response) { await reader.cancel().catch(() => undefined); throw new PlanPushProtocolError( "The First Draft response is too large.", + { status: response.status }, ); } chunks.push(Buffer.from(value)); @@ -414,6 +442,7 @@ async function readResponseBytes(response) { throw new PlanPushNetworkError("The First Draft response failed.", { cause: error, + status: response.status, }); } diff --git a/test/plan-push.test.js b/test/plan-push.test.js index beac60c..857a551 100644 --- a/test/plan-push.test.js +++ b/test/plan-push.test.js @@ -46,14 +46,21 @@ Environment: The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. `; -const PLAN_PUSH_CONFIGURATION_ERROR = - "Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n"; -const PLAN_PUSH_LOCAL_ERROR = - "Could not read the local First Draft Plan or state. No network request was made.\n"; -const PLAN_PUSH_NETWORK_ERROR = - "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; -const PLAN_PUSH_PROTOCOL_ERROR = - "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_PUSH_INVALID_ARGUMENTS_ERROR = jsonOutput({ + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan push --help' for usage.", +}); +const PLAN_PUSH_CONFIGURATION_ERROR = jsonOutput({ + error: "invalid_configuration", + detail: + "Invalid First Draft API configuration. Run 'firstdraft plan push --help' for usage.", +}); +const PLAN_PUSH_LOCAL_ERROR = jsonOutput({ + error: "local_input_unreadable", + detail: + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.", +}); +const PLAN_PUSH_OUTCOME_UNKNOWN_ERROR = requestOutcomeUnknownOutput(); test("plan push help has no local or network prerequisites", async () => { const inaccessible = () => { @@ -275,6 +282,7 @@ test("usage errors happen before local or network access", async () => { assert.equal(result.status, 2); assert.equal(result.stdout, ""); + assert.equal(result.stderr, PLAN_PUSH_INVALID_ARGUMENTS_ERROR); assert.doesNotMatch(result.stderr, /canary-secret/); } }); @@ -335,12 +343,107 @@ test("HTTP diagnostics and problems leave local state byte-for-byte unchanged", assert.deepEqual(result, { status: 1, stdout: "", - stderr: `${JSON.stringify(responseBody, null, 2)}\n`, + stderr: serverRejectedOutput(status, responseBody), }); assert.deepEqual(stateSource(cwd), before); } }); +test("server rejection envelopes expose only validated response fields", async (context) => { + const cwd = await initializedDirectory(context); + const sourceSha256 = sha256(planSource(cwd)); + const diagnostic = { + code: "foundation_plan.example", + severity: "error", + message: "The Plan needs a supported value.", + location: { + source_pointer: "/application/entities/0", + canary: "canary-secret-location", + }, + subject: { + kind: "entity", + readable_path: "movie", + subject_uuid: PROJECT_ID, + canary: "canary-secret-subject", + }, + related_locations: [ + { line: 1, column: 2, canary: "canary-secret-related" }, + ], + suggestions: ["Choose a supported value.", 7], + canary: "canary-secret-diagnostic", + }; + const body = { + source_sha256: sourceSha256, + diagnostics: [diagnostic], + canary: "canary-secret-response", + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(jsonResponse(body, 422), []), + }); + + assert.deepEqual(JSON.parse(result.stderr), { + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status: 422, + response: { + source_sha256: sourceSha256, + diagnostics: [ + { + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + location: { source_pointer: "/application/entities/0" }, + subject: { + kind: "entity", + readable_path: "movie", + subject_uuid: PROJECT_ID, + }, + related_locations: [{ line: 1, column: 2 }], + }, + ], + }, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + + const problem = { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The Foundation Plan has changed.", + source_sha256: "canary-secret-unverified-digest", + diagnostics: [ + { + code: "canary-secret-code", + severity: "error", + message: "canary-secret-message", + }, + ], + canary: "canary-secret-problem", + }; + const problemResult = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(problemResponse(problem, 412), []), + }); + + assert.deepEqual(JSON.parse(problemResult.stderr), { + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status: 412, + response: { + type: "about:blank", + title: problem.title, + status: 412, + code: problem.code, + detail: problem.detail, + }, + }); + assert.doesNotMatch(problemResult.stderr, /canary-secret/); +}); + test("a stale update preserves the prior ETag and exact local state", async (context) => { const cwd = await initializedDirectory(context); await successfulInitialPush(cwd); @@ -369,7 +472,7 @@ test("a stale update preserves the prior ETag and exact local state", async (con assert.deepEqual(result, { status: 1, stdout: "", - stderr: `${JSON.stringify(problem, null, 2)}\n`, + stderr: serverRejectedOutput(412, problem), }); assert.deepEqual(stateSource(cwd), before); assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); @@ -391,7 +494,7 @@ test("an update rejects a create status before changing local state", async (con assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(201), }); assert.deepEqual(stateSource(cwd), before); assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); @@ -429,7 +532,7 @@ test("422 diagnostics must identify the exact submitted bytes", async (context) assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(422), }); assert.deepEqual(stateSource(cwd), before); } @@ -456,12 +559,12 @@ test("422 diagnostics must identify the exact submitted bytes", async (context) assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(422), }); assert.deepEqual(stateSource(cwd), before); }); -test("non-JSON HTTP failures are reported without echoing their body", async (context) => { +test("unverified HTTP failures have an ambiguous outcome without echoing their body", async (context) => { const cwd = await initializedDirectory(context); const before = stateSource(cwd); const result = await invoke(["plan", "push"], { @@ -476,7 +579,7 @@ test("non-JSON HTTP failures are reported without echoing their body", async (co assert.deepEqual(result, { status: 1, stdout: "", - stderr: "First Draft rejected the Plan (HTTP 503).\n", + stderr: requestOutcomeUnknownOutput(503), }); assert.doesNotMatch(result.stderr, /canary-secret/); assert.deepEqual(stateSource(cwd), before); @@ -496,7 +599,33 @@ test("transport failures disclose the ambiguous outcome without leaking errors", assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_NETWORK_ERROR, + stderr: PLAN_PUSH_OUTCOME_UNKNOWN_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); +}); + +test("response stream failures retain the received status without leaking errors", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("canary-secret-stream-detail")); + }, + }), + { status: 201 }, + ); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: requestOutcomeUnknownOutput(201), }); assert.doesNotMatch(result.stderr, /canary-secret/); assert.deepEqual(stateSource(cwd), before); @@ -541,16 +670,17 @@ test("success responses are bound to the request before state changes", async (c for (const makeResponse of sourceMutators) { const cwd = await initializedDirectory(context); const before = stateSource(cwd); + const response = makeResponse(planSource(cwd)); const result = await invoke(["plan", "push"], { cwd, apiUrl: API_URL, - fetchFunction: recordingFetch(makeResponse(planSource(cwd)), []), + fetchFunction: recordingFetch(response, []), }); assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(response.status), }); assert.doesNotMatch(result.stderr, /canary-secret/); assert.deepEqual(stateSource(cwd), before); @@ -755,7 +885,12 @@ test("the Plan is never parsed or reserialized locally", async (context) => { assert(Buffer.isBuffer(call.init?.body)); assert.deepEqual(call.init.body, invalidUtf8); assert.equal(result.status, 1); - assert.deepEqual(JSON.parse(result.stderr), diagnostic); + assert.deepEqual(JSON.parse(result.stderr), { + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status: 422, + response: diagnostic, + }); }); test("failed atomic state replacements report the accepted ETag", async (context) => { @@ -876,7 +1011,7 @@ test("oversized success responses stop before local state changes", async (conte assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(201), }); assert.deepEqual(stateSource(cwd), before); assert.equal(cancelled, true); @@ -911,7 +1046,7 @@ test("streamed oversized responses are cancelled at the byte cap", async (contex assert.deepEqual(result, { status: 1, stdout: "", - stderr: PLAN_PUSH_PROTOCOL_ERROR, + stderr: requestOutcomeUnknownOutput(201), }); assert.deepEqual(stateSource(cwd), before); assert.equal(cancelled, true); @@ -1131,6 +1266,31 @@ function stateJson(state) { return Buffer.from(`${JSON.stringify(state, null, 2)}\n`); } +/** @param {unknown} value */ +function jsonOutput(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** @param {number} status @param {unknown} [response] */ +function serverRejectedOutput(status, response) { + return jsonOutput({ + error: "server_rejected", + detail: "First Draft rejected the Plan.", + status, + ...(response === undefined ? {} : { response }), + }); +} + +/** @param {number} [status] */ +function requestOutcomeUnknownOutput(status) { + return jsonOutput({ + error: "request_outcome_unknown", + detail: + "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed.", + ...(status === undefined ? {} : { status }), + }); +} + /** @param {import("node:test").TestContext} context */ function temporaryDirectory(context) { const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-plan-push-"));