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
35 changes: 12 additions & 23 deletions actions/setup/js/pi_provider.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
"use strict";

const { fetchAWFReflect, AWF_API_PROXY_REFLECT_URL, AWF_REFLECT_OUTPUT_PATH, AWF_REFLECT_TIMEOUT_MS, AWF_MODELS_URL_TIMEOUT_MS } = require("./awf_reflect.cjs");
const { emitInfrastructureIncomplete } = require("./safeoutputs_cli.cjs");
const fs = require("fs");
const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");

// Default logger: prefixed with "[gh-aw/pi-provider]" for easy grepping.
Expand Down Expand Up @@ -142,23 +142,16 @@ function formatResponseHeaderNames(headers) {
}

/**
* Build a structured report_incomplete payload for infrastructure failures.
*
* @param {string} details
* @returns {string}
*/
function buildInfrastructureIncompletePayload(details) {
return JSON.stringify({
type: "report_incomplete",
reason: "infrastructure_error",
details,
});
}

/**
* Append a report_incomplete safe output when provider infrastructure fails
* Emit a report_incomplete safe output when provider infrastructure fails
* before any safe outputs have been recorded.
*
* This Pi extension runs inside the AWF agent sandbox, where the directory
* backing GH_AW_SAFE_OUTPUTS is mounted read-only (writes fail with EROFS).
* Emission is therefore delegated to the `safeoutputs` CLI (see
* safeoutputs_cli.cjs), which forwards the call to the MCP gateway process
* that owns write access to the real outputs file — the same channel used
* by every other safe-output tool call the agent makes.
*
* @param {string} details
* @param {(msg: string) => void} logger
* @returns {void}
Expand All @@ -176,14 +169,11 @@ function emitInfrastructureIncompleteIfNoSafeOutputs(details, logger) {
logger(`report_incomplete skipped: safe outputs already recorded at ${safeOutputsPath}`);
return;
}

fs.mkdirSync(path.dirname(safeOutputsPath), { recursive: true });
fs.appendFileSync(safeOutputsPath, buildInfrastructureIncompletePayload(details) + "\n", { encoding: "utf8" });
logger(`report_incomplete emitted: ${safeOutputsPath}`);
} catch (error) {
const message = getErrorMessage(error);
logger(`report_incomplete emission failed: ${message}`);
logger(`report_incomplete pre-check failed, proceeding with emission: ${getErrorMessage(error)}`);
}

emitInfrastructureIncomplete(details, { safeOutputsPath, logger });
}

/**
Expand Down Expand Up @@ -411,6 +401,5 @@ _piExports.resolveGatewayUrl = resolveGatewayUrl;
_piExports.registerConfiguredProviders = registerConfiguredProviders;
_piExports.resolveProviderRequestTarget = resolveProviderRequestTarget;
_piExports.formatResponseHeaderNames = formatResponseHeaderNames;
_piExports.buildInfrastructureIncompletePayload = buildInfrastructureIncompletePayload;
_piExports.emitInfrastructureIncompleteIfNoSafeOutputs = emitInfrastructureIncompleteIfNoSafeOutputs;
_piExports.logReflectFailure = logReflectFailure;
30 changes: 26 additions & 4 deletions actions/setup/js/pi_provider.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,14 @@ describe("pi_provider.cjs", () => {
expect(stderrOutput.some(line => line.includes("provider_response provider=copilot model=claude-sonnet-4 status=503 method=POST url=http://api-proxy:10002/v1/chat/completions response_headers=content-type,x-request-id"))).toBe(true);
});

it("logs assistant inference errors with the last request target", async () => {
// Triggers the message_end infrastructure-error handler with a given stand-in
// GH_AW_SAFEOUTPUTS_CLI override ('true' simulates a successful CLI call, 'false'
// simulates a failed one) and returns the handlers/stderr output for assertions.
async function triggerConnectionError(cliOverride) {
process.env.GH_AW_PI_MODEL = "copilot/claude-sonnet-4";
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-provider-"));
process.env.GH_AW_SAFE_OUTPUTS = path.join(tempDir, "outputs.jsonl");
process.env.GH_AW_SAFEOUTPUTS_CLI = cliOverride;

const handlers = {};
const pi = {
Expand Down Expand Up @@ -150,14 +154,30 @@ describe("pi_provider.cjs", () => {
errorMessage: "Connection error.",
},
});
}

it("logs assistant inference errors with the last request target", async () => {
// 'true' is a stand-in safeoutputs CLI binary that always exits 0, simulating a
// successful emission through the CLI channel instead of a direct fs append.
await triggerConnectionError("true");

expect(
stderrOutput.some(line =>
line.includes('provider_error provider=aw-gateway model=claude-sonnet-4 api=openai-completions status=no-response method=POST url=http://api-proxy:10002/v1/chat/completions response_headers=none error="Connection error."')
)
).toBe(true);
expect(fs.readFileSync(process.env.GH_AW_SAFE_OUTPUTS, "utf8")).toContain('"type":"report_incomplete"');
expect(fs.readFileSync(process.env.GH_AW_SAFE_OUTPUTS, "utf8")).toContain("Pi provider request failed before safe outputs were emitted");
// Emission goes through the safeoutputs CLI channel (not a direct fs append), so it
// survives the read-only sandbox mount that backs GH_AW_SAFE_OUTPUTS in production.
expect(stderrOutput.some(line => line.includes("report_incomplete emitted via safeoutputs CLI"))).toBe(true);
});

it("logs a failure when the safeoutputs CLI channel is unavailable (e.g. read-only sandbox)", async () => {
// 'false' is a stand-in safeoutputs CLI binary that always exits 1, simulating a
// failed CLI invocation without ever touching the filesystem directly.
await triggerConnectionError("false");

expect(stderrOutput.some(line => line.includes("report_incomplete emission failed"))).toBe(true);
expect(fs.existsSync(process.env.GH_AW_SAFE_OUTPUTS)).toBe(false);
});

it("skips synthetic report_incomplete emission when safe outputs already exist", () => {
Expand All @@ -166,9 +186,11 @@ describe("pi_provider.cjs", () => {
process.env.GH_AW_SAFE_OUTPUTS = safeOutputsPath;
fs.writeFileSync(safeOutputsPath, '{"type":"add_comment","body":"done"}\n');

module.emitInfrastructureIncompleteIfNoSafeOutputs("temporary outage", () => {});
const logs = [];
module.emitInfrastructureIncompleteIfNoSafeOutputs("temporary outage", message => logs.push(message));

expect(fs.readFileSync(safeOutputsPath, "utf8")).toBe('{"type":"add_comment","body":"done"}\n');
expect(logs.some(m => m.includes("skipped: safe outputs already recorded"))).toBe(true);
});

it("calls /reflect on the management port (10000) when AWF_REFLECT_ENABLED is set", async () => {
Expand Down
Loading