diff --git a/.github/actions/run-rix-job/action.yml b/.github/actions/run-rix-job/action.yml
index 089daaac..3843df0f 100644
--- a/.github/actions/run-rix-job/action.yml
+++ b/.github/actions/run-rix-job/action.yml
@@ -15,7 +15,7 @@ inputs:
description: GitHub PAT (or equivalent token) with read access to the target repo
required: true
agent:
- description: "Coding agent to run: 'opencode' (default) or 'claude'"
+ description: "Coding agent to run: 'opencode' (default), 'claude', or 'pi'"
required: false
default: opencode
model:
@@ -28,7 +28,8 @@ inputs:
description: >-
Name of the environment variable agent-api-key is exported as. Must end in _API_KEY. Omit
to use a default based on agent: OPENCODE_API_KEY for opencode, ANTHROPIC_API_KEY for
- claude.
+ claude. Required for pi whenever agent-api-key is set, since pi has no single default
+ provider to fall back on.
required: false
max-tokens:
description: Coding agent token budget cap
@@ -87,6 +88,12 @@ runs:
agent_normalized=$(printf '%s' "$RIX_AGENT" | tr '[:upper:]' '[:lower:]' | xargs)
case "$agent_normalized" in
claude) key_env=ANTHROPIC_API_KEY ;;
+ pi)
+ # pi is multi-provider with no single default credential, unlike opencode's
+ # own free-model provider — the caller must say which env var to use.
+ echo "::error::agent-api-key-env is required when agent=pi and agent-api-key is set" >&2
+ exit 1
+ ;;
*) key_env=OPENCODE_API_KEY ;;
esac
fi
diff --git a/.github/workflows/job.yml b/.github/workflows/job.yml
index 9ce8b4d2..9e402cc9 100644
--- a/.github/workflows/job.yml
+++ b/.github/workflows/job.yml
@@ -14,7 +14,7 @@ on:
required: true
type: string
agent:
- description: "Coding agent to run: 'opencode' (default) or 'claude'"
+ description: "Coding agent to run: 'opencode' (default), 'claude', or 'pi'"
required: false
type: string
default: opencode
@@ -30,7 +30,8 @@ on:
Name of the environment variable agent-api-key is exported as (e.g. OPENCODE_API_KEY,
ANTHROPIC_API_KEY, OPENAI_API_KEY) — whatever the selected provider/model expects.
Must end in _API_KEY. Omit to use a default based on agent: OPENCODE_API_KEY for
- opencode, ANTHROPIC_API_KEY for claude.
+ opencode, ANTHROPIC_API_KEY for claude. Required for pi whenever agent-api-key is set,
+ since pi has no single default provider to fall back on.
required: false
type: string
rix-version:
diff --git a/README.md b/README.md
index 6e099946..12262cbf 100644
--- a/README.md
+++ b/README.md
@@ -31,9 +31,9 @@ jobs:
write-token: ${{ secrets.RIX_WRITE_TOKEN }}
```
-Pick the coding agent with the optional `agent` input — `opencode` (default) or `claude`.
-Both install their CLI via npm at run time. With no `model` set, `opencode` picks its own
-free default model, so the example above needs no `agent-api-key` at all.
+Pick the coding agent with the optional `agent` input — `opencode` (default), `claude`, or
+`pi`. All three install their CLI via npm at run time. With no `model` set, `opencode` picks
+its own free default model, so the example above needs no `agent-api-key` at all.
### Using a different provider or model
@@ -58,8 +58,13 @@ opencode supports many model providers beyond the free default. Pick a model wit
`model` (if set) just picks among Claude's models, and `agent-api-key-env` defaults to
`ANTHROPIC_API_KEY` automatically (no need to set it unless using a non-default env var name).
-Local/self-hosted backends (e.g. Ollama, LM Studio) aren't supported yet — opencode reaches
-those through a generated config file rather than a model string + API key, which is a
+`pi` (the open-source Pi coding agent CLI) is multi-provider like opencode — pick a model
+with `model`, forwarded verbatim as `--model` (e.g. `openai/gpt-4o`). Unlike opencode, pi has
+no free default provider, so `agent-api-key-env` must always be set explicitly when
+`agent-api-key` is provided; there is no per-agent default to fall back on.
+
+Local/self-hosted backends (e.g. Ollama, LM Studio) aren't supported yet — opencode and pi
+reach those through a generated config file rather than a model string + API key, which is a
separate mechanism this workflow doesn't build today.
`@main` tracks the latest workflow; once a release is tagged, pin to that tag or a commit
@@ -71,7 +76,7 @@ SHA (e.g. `...job.yml@v1.0.0`) for reproducible, supply-chain-safe runs.
| --- | --- |
| `read-token` | Required. PAT with read access to the target repo; used to clone it during the agent run. |
| `write-token` | Required. PAT with `contents:write` + `pull-requests:write` on the target repo; used to push branches and open PRs. |
-| `agent-api-key` | Optional. API key for the selected agent's model provider, exported as the env var named by `agent-api-key-env` (default `OPENCODE_API_KEY` for opencode, `ANTHROPIC_API_KEY` for claude). Not needed when leaving `model` unset, since opencode then picks its own free default model. |
+| `agent-api-key` | Optional. API key for the selected agent's model provider, exported as the env var named by `agent-api-key-env` (default `OPENCODE_API_KEY` for opencode, `ANTHROPIC_API_KEY` for claude; for pi, `agent-api-key-env` must be set explicitly whenever `agent-api-key` is provided, since pi has no per-agent default). Not needed when leaving `model` unset, since opencode then picks its own free default model. |
The read/write split keeps the agent run (which executes untrusted, model-generated work)
on a read-only token; only the final, deterministic PR-creation step holds write access.
diff --git a/src/Rix/Agents/AgentKind.cs b/src/Rix/Agents/AgentKind.cs
index 41155588..d79a5375 100644
--- a/src/Rix/Agents/AgentKind.cs
+++ b/src/Rix/Agents/AgentKind.cs
@@ -8,6 +8,9 @@ internal enum AgentKind
/// The open-source, multi-provider OpenCode CLI () — the default.
OpenCode,
+
+ /// The open-source, multi-provider Pi coding agent CLI ().
+ Pi,
}
internal static class AgentKindParser
@@ -25,7 +28,8 @@ internal static ParseResult Parse(string value)
{
"claude" => new ParseSuccess(AgentKind.Claude),
"opencode" => new ParseSuccess(AgentKind.OpenCode),
- _ => new ParseError($"unknown agent '{normalized}' (expected 'claude' or 'opencode')"),
+ "pi" => new ParseSuccess(AgentKind.Pi),
+ _ => new ParseError($"unknown agent '{normalized}' (expected 'claude', 'opencode', or 'pi')"),
};
}
}
diff --git a/src/Rix/Agents/PiAgent.cs b/src/Rix/Agents/PiAgent.cs
new file mode 100644
index 00000000..0d343078
--- /dev/null
+++ b/src/Rix/Agents/PiAgent.cs
@@ -0,0 +1,81 @@
+using Rix.Job;
+using Rix.Process;
+using System.Text.Json;
+
+namespace Rix.Agents;
+
+///
+/// backed by the open-source Pi coding agent CLI: installs it via npm,
+/// launches it in non-interactive JSON event mode, and reads cost from that stream.
+///
+///
+/// Like , Pi is multi-provider — is
+/// forwarded verbatim as --model (e.g. openai/gpt-4o), and the caller is responsible
+/// for exporting whatever credential env var that provider expects. Pi has no per-run
+/// output-token cap equivalent to CLAUDE_CODE_MAX_OUTPUT_TOKENS, so
+/// is not forwarded and the invocation carries no environment
+/// overrides. Unlike OpenCode, Pi does support --append-system-prompt, so rix's system
+/// prompt is passed the same way as for .
+///
+internal sealed class PiAgent : ICodingAgent
+{
+ private const string Package = "@earendil-works/pi-coding-agent";
+
+ public Task EnsureInstalledAsync(RunProcessAsync runProcess, CancellationToken cancellationToken)
+ => CodingAgentHelper.EnsureInstalledViaNpmAsync(runProcess, "pi", Package, cancellationToken);
+
+ public AgentInvocation BuildInvocation(JobConfig config, string systemPrompt)
+ {
+ List args =
+ [
+ "--mode", "json", config.Agent.Prompt, "--append-system-prompt", systemPrompt,
+ ];
+ if (!string.IsNullOrWhiteSpace(config.Agent.Model))
+ args.AddRange(["--model", config.Agent.Model]);
+
+ return new
+ (
+ FileName: "pi",
+ Arguments: args,
+ EnvironmentOverrides: new Dictionary()
+ );
+ }
+
+ ///
+ /// Reads cost from Pi's JSON event stream. Pi emits per-message cost (usage.cost.total
+ /// on each assistant message), not a single cumulative total, so this sums every assistant
+ /// message's cost from the agent_end event, which carries the full message list.
+ ///
+ ///
+ /// Only agent_end lines carry the message list; the caller's other stdout lines only
+ /// ever have agent_settled as the final line, so this reliably returns null
+ /// (reported cost 0) in normal operation today.
+ ///
+ public decimal? ParseCost(string outputLine) => CostLine.Read(outputLine, "\"agent_end\"", ReadCost);
+
+ private static decimal? ReadCost(JsonElement root)
+ {
+ if (!root.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
+ return null;
+
+ decimal total = 0m;
+ foreach (var message in messages.EnumerateArray())
+ total += ReadAssistantCost(message);
+ return total;
+ }
+
+ private static decimal ReadAssistantCost(JsonElement message)
+ {
+ if (!message.TryGetProperty("role", out var role) || role.GetString() != "assistant")
+ return 0m;
+ if (!message.TryGetProperty("usage", out var usage) || usage.ValueKind != JsonValueKind.Object)
+ return 0m;
+ if (!usage.TryGetProperty("cost", out var cost) || cost.ValueKind != JsonValueKind.Object)
+ return 0m;
+ if (!cost.TryGetProperty("total", out var totalCost) || totalCost.ValueKind != JsonValueKind.Number)
+ return 0m;
+ if (!totalCost.TryGetDecimal(out var v))
+ return 0m;
+ return v;
+ }
+}
diff --git a/src/Rix/Cli/JobCommand.cs b/src/Rix/Cli/JobCommand.cs
index aca249d4..f1b8523c 100644
--- a/src/Rix/Cli/JobCommand.cs
+++ b/src/Rix/Cli/JobCommand.cs
@@ -57,7 +57,7 @@ internal static class JobCommand
private static readonly Option AgentOption = new
(
name: "--agent",
- description: "Coding agent to run: 'opencode' (default) or 'claude'"
+ description: "Coding agent to run: 'opencode' (default), 'claude', or 'pi'"
)
{ IsRequired = false };
diff --git a/src/Rix/Startup.cs b/src/Rix/Startup.cs
index b67b4562..ed9db4de 100644
--- a/src/Rix/Startup.cs
+++ b/src/Rix/Startup.cs
@@ -31,6 +31,7 @@ private static ICodingAgent SelectAgent(AgentKind agent)
{
AgentKind.Claude => new ClaudeAgent(),
AgentKind.OpenCode => new OpenCodeAgent(),
+ AgentKind.Pi => new PiAgent(),
_ => throw new NotSupportedException($"Unsupported agent: {agent}"),
};
diff --git a/tests/Rix.Tests/AgentKindParserTests.cs b/tests/Rix.Tests/AgentKindParserTests.cs
index 3d53b7e3..00a79beb 100644
--- a/tests/Rix.Tests/AgentKindParserTests.cs
+++ b/tests/Rix.Tests/AgentKindParserTests.cs
@@ -17,6 +17,7 @@ public void Parse_IsCaseInsensitiveAndTrims()
{
Assert.AreEqual(AgentKind.Claude, Unwrap(AgentKindParser.Parse(" Claude ")));
Assert.AreEqual(AgentKind.OpenCode, Unwrap(AgentKindParser.Parse("OPENCODE")));
+ Assert.AreEqual(AgentKind.Pi, Unwrap(AgentKindParser.Parse(" PI ")));
}
[TestMethod]
diff --git a/tests/Rix.Tests/JobConfigTests.cs b/tests/Rix.Tests/JobConfigTests.cs
index 9c386c1d..c3f01a70 100644
--- a/tests/Rix.Tests/JobConfigTests.cs
+++ b/tests/Rix.Tests/JobConfigTests.cs
@@ -189,6 +189,7 @@ public void Create_DefaultsAgentToOpenCode_AndSelectsClaude()
{
Assert.AreEqual(Rix.Agents.AgentKind.OpenCode, Valid(Create()).Agent.Kind);
Assert.AreEqual(Rix.Agents.AgentKind.Claude, Valid(Create(agent: "claude")).Agent.Kind);
+ Assert.AreEqual(Rix.Agents.AgentKind.Pi, Valid(Create(agent: "pi")).Agent.Kind);
}
[TestMethod]
@@ -205,6 +206,7 @@ public void Create_DefaultsModelToNull_WhenAbsentOrBlank()
Assert.IsNull(Valid(Create(agent: "opencode")).Agent.Model);
Assert.IsNull(Valid(Create(agent: "opencode", model: "")).Agent.Model);
Assert.IsNull(Valid(Create(agent: "claude")).Agent.Model);
+ Assert.IsNull(Valid(Create(agent: "pi")).Agent.Model);
}
[TestMethod]
@@ -212,5 +214,6 @@ public void Create_PassesThroughExplicitModel()
{
Assert.AreEqual("openai/gpt-4o", Valid(Create(agent: "opencode", model: "openai/gpt-4o")).Agent.Model);
Assert.AreEqual("claude-opus-4", Valid(Create(agent: "claude", model: "claude-opus-4")).Agent.Model);
+ Assert.AreEqual("openai/gpt-4o", Valid(Create(agent: "pi", model: "openai/gpt-4o")).Agent.Model);
}
}
diff --git a/tests/Rix.Tests/PiAgentCostTests.cs b/tests/Rix.Tests/PiAgentCostTests.cs
new file mode 100644
index 00000000..1569f3f3
--- /dev/null
+++ b/tests/Rix.Tests/PiAgentCostTests.cs
@@ -0,0 +1,73 @@
+using Rix.Agents;
+
+namespace Rix.Tests;
+
+[TestClass]
+public class PiAgentCostTests
+{
+ [TestMethod]
+ public void ParseCost_SumsAssistantMessageCosts_FromAgentEnd()
+ {
+ const string line = """
+ {"type":"agent_end","messages":[
+ {"role":"user","content":"hi"},
+ {"role":"assistant","usage":{"cost":{"input":0.01,"output":0.02,"total":0.03}}},
+ {"role":"toolResult","content":"ok"},
+ {"role":"assistant","usage":{"cost":{"input":0.04,"output":0.05,"total":0.09}}}
+ ]}
+ """;
+
+ Assert.AreEqual(0.12m, new PiAgent().ParseCost(line));
+ }
+
+ [TestMethod]
+ public void ParseCost_ReturnsZero_WhenAgentEndHasNoCostedMessages()
+ {
+ const string line = """{"type":"agent_end","messages":[{"role":"user","content":"hi"}]}""";
+
+ Assert.AreEqual(0m, new PiAgent().ParseCost(line));
+ }
+
+ [TestMethod]
+ [DataRow("not json at all")]
+ [DataRow("""{"type":"message_end","messages":[]}""")]
+ [DataRow("{invalid json}")]
+ [DataRow("""{"type":123}""")]
+ [DataRow("[]")]
+ [DataRow("null")]
+ [DataRow("42")]
+ [DataRow("")]
+ public void ParseCost_ReturnsNull_ForNonAgentEndOrMalformedLines(string line)
+ {
+ Assert.IsNull(new PiAgent().ParseCost(line));
+ }
+
+ [TestMethod]
+ public void ParseCost_ReturnsNull_WhenAgentEndHasNoMessages()
+ {
+ const string line = """{"type":"agent_end"}""";
+
+ Assert.IsNull(new PiAgent().ParseCost(line));
+ }
+
+ [TestMethod]
+ public void ParseCost_IgnoresMessagesWithNonNumericCost()
+ {
+ const string line = """
+ {"type":"agent_end","messages":[
+ {"role":"assistant","usage":{"cost":{"total":"free"}}},
+ {"role":"assistant","usage":{"cost":{"total":0.5}}}
+ ]}
+ """;
+
+ Assert.AreEqual(0.5m, new PiAgent().ParseCost(line));
+ }
+
+ [TestMethod]
+ public void ParseCost_HandlesLeadingWhitespace()
+ {
+ const string line = """ {"type":"agent_end","messages":[{"role":"assistant","usage":{"cost":{"total":1.5}}}]}""";
+
+ Assert.AreEqual(1.5m, new PiAgent().ParseCost(line));
+ }
+}
diff --git a/tests/Rix.Tests/PiAgentTests.cs b/tests/Rix.Tests/PiAgentTests.cs
new file mode 100644
index 00000000..d28d0d01
--- /dev/null
+++ b/tests/Rix.Tests/PiAgentTests.cs
@@ -0,0 +1,158 @@
+using Rix.Agents;
+using Rix.Job;
+using Rix.Process;
+
+namespace Rix.Tests;
+
+[TestClass]
+public class PiAgentTests
+{
+ private static readonly PiAgent Agent = new();
+
+ // Adapts a simple (fileName, args) -> ProcessResult stub to the full RunProcessAsync shape.
+ private static RunProcessAsync Runner(Func, Task> run)
+ => (fileName, args, _, _, _, _) => run(fileName, args);
+
+ // ---- EnsureInstalledAsync ----
+
+ [TestMethod]
+ public async Task EnsureInstalled_ReturnsInstalled_WhenPiAlreadyInstalled()
+ {
+ var result = await Agent.EnsureInstalledAsync(
+ Runner((f, _) => Task.FromResult(f == "pi"
+ ? new ProcessSuccess()
+ : new ProcessFailure("exited with code 1"))),
+ CancellationToken.None);
+
+ Assert.IsInstanceOfType(result);
+ }
+
+ [TestMethod]
+ public async Task EnsureInstalled_ReturnsInstalled_WhenInstallSucceeds()
+ {
+ int piCallCount = 0;
+ var result = await Agent.EnsureInstalledAsync(
+ Runner((f, args) => f switch
+ {
+ "pi" => Task.FromResult(++piCallCount == 1
+ ? new ProcessFailure("exited with code 1")
+ : new ProcessSuccess()),
+ "npm" => Task.FromResult(new ProcessSuccess()),
+ _ => throw new NotSupportedException(f),
+ }),
+ CancellationToken.None);
+
+ Assert.IsInstanceOfType(result);
+ }
+
+ [TestMethod]
+ public async Task EnsureInstalled_Fails_WhenNpmUnavailable()
+ {
+ var result = await Agent.EnsureInstalledAsync(
+ Runner((_, _) => Task.FromResult(new ProcessFailure("exited with code 1"))),
+ CancellationToken.None);
+
+ Assert.IsInstanceOfType(result, out var failed);
+ StringAssert.Contains(failed.Reason, "npm");
+ }
+
+ [TestMethod]
+ public async Task EnsureInstalled_Fails_WhenNpmInstallFails()
+ {
+ var result = await Agent.EnsureInstalledAsync(
+ Runner((f, args) => f switch
+ {
+ "pi" => Task.FromResult(new ProcessFailure("exited with code 1")),
+ "npm" when args.Contains("--version") => Task.FromResult(new ProcessSuccess()),
+ "npm" => Task.FromResult(new ProcessFailure("exited with code 1")),
+ _ => throw new NotSupportedException(f),
+ }),
+ CancellationToken.None);
+
+ Assert.IsInstanceOfType(result, out var failed);
+ StringAssert.Contains(failed.Reason, "npm install");
+ }
+
+ [TestMethod]
+ public async Task EnsureInstalled_InstallsPiPackage()
+ {
+ string? installedPackage = null;
+ int piCallCount = 0;
+
+ await Agent.EnsureInstalledAsync(
+ Runner((f, args) =>
+ {
+ if (f == "npm" && args.Contains("install"))
+ installedPackage = args.FirstOrDefault(a => a.StartsWith("@earendil-works/pi-coding-agent"));
+ return f switch
+ {
+ "pi" => Task.FromResult(++piCallCount == 1
+ ? new ProcessFailure("exited with code 1")
+ : new ProcessSuccess()),
+ "npm" => Task.FromResult(new ProcessSuccess()),
+ _ => throw new NotSupportedException(f),
+ };
+ }),
+ CancellationToken.None);
+
+ Assert.AreEqual("@earendil-works/pi-coding-agent", installedPackage);
+ }
+
+ [TestMethod]
+ public async Task EnsureInstalled_Fails_WhenPostInstallPiCheckFails()
+ {
+ var result = await Agent.EnsureInstalledAsync(
+ Runner((f, args) => f switch
+ {
+ "pi" => Task.FromResult(new ProcessFailure("exited with code 1")),
+ "npm" => Task.FromResult(new ProcessSuccess()),
+ _ => throw new NotSupportedException(f),
+ }),
+ CancellationToken.None);
+
+ Assert.IsInstanceOfType(result, out var failed);
+ StringAssert.Contains(failed.Reason, "could not be verified");
+ }
+
+ // ---- BuildInvocation / ParseCost ----
+
+ [TestMethod]
+ public void BuildInvocation_ProducesPiJsonModeInvocation()
+ {
+ var config = TestConfig.Valid(agent: "pi", maxTokens: 1234);
+
+ var invocation = Agent.BuildInvocation(config, "SYSTEM");
+
+ Assert.AreEqual("pi", invocation.FileName);
+ var args = invocation.Arguments.ToList();
+ CollectionAssert.Contains(args, "--mode");
+ CollectionAssert.Contains(args, "json");
+ CollectionAssert.Contains(args, "--append-system-prompt");
+ CollectionAssert.Contains(args, "SYSTEM");
+ CollectionAssert.Contains(args, "do it");
+ // Pi has no output-token cap equivalent, so no environment overrides are set.
+ Assert.AreEqual(0, invocation.EnvironmentOverrides.Count);
+ }
+
+ [TestMethod]
+ public void BuildInvocation_OmitsModelFlag_ByDefault()
+ {
+ var config = TestConfig.Valid(agent: "pi");
+
+ var args = Agent.BuildInvocation(config, "SYSTEM").Arguments.ToList();
+
+ CollectionAssert.DoesNotContain(args, "--model");
+ }
+
+ [TestMethod]
+ public void BuildInvocation_IncludesModelFlag_WhenModelOverridden()
+ {
+ var config = TestConfig.Valid(agent: "pi", model: "openai/gpt-4o");
+
+ var args = Agent.BuildInvocation(config, "SYSTEM").Arguments.ToList();
+
+ var modelIndex = args.IndexOf("--model");
+ Assert.AreNotEqual(-1, modelIndex);
+ Assert.AreEqual("openai/gpt-4o", args[modelIndex + 1]);
+ }
+}
diff --git a/tests/e2e/rix-job-e2e.bats b/tests/e2e/rix-job-e2e.bats
index 554d6428..3babc6cd 100644
--- a/tests/e2e/rix-job-e2e.bats
+++ b/tests/e2e/rix-job-e2e.bats
@@ -1,7 +1,7 @@
#!/usr/bin/env bats
# End-to-end tests for the rix binary itself - one level deeper than ci.yml's agent-plumbing job,
# which only proves the composite-action/workflow plumbing works for the happy path. These tests
-# invoke the built rix binary directly against the real opencode/claude CLIs (no npm version
+# invoke the built rix binary directly against the real opencode/claude/pi CLIs (no npm version
# pinning - see EnsureInstalledViaNpmAsync - so this is knowingly a bit brittle to upstream CLI
# changes) and do real JSON parsing on the result.
#
@@ -26,6 +26,20 @@
# before ever calling the API). This rules out a false pass from some other JSON value (e.g.
# a bare `null`) slipping past the weaker "parses as JSON" check.
#
+# pi doesn't fit either pattern above, and gets its own tests further down. Confirmed (by running
+# the real CLI locally, both with no credentials at all and with an explicit invalid key) that:
+# - With no provider resolvable at all, pi throws before ever starting a turn and its last
+# stderr line (see ProcessWrapper.Diagnostic) is plain prose, not JSON - it's a doc pointer
+# baked into the installed package (see auth-guidance.js's getProviderLoginHelp), e.g.
+# ".../docs/models.md". So the structural check here is that it's a real file that ships with
+# the CLI, not that it parses as JSON.
+# - Unlike opencode/claude, an invalid key does not fail pi's process at all: pi embeds the
+# failure in its own JSON event stream as a per-turn error (stopReason:"error", errorMessage)
+# and still exits 0. So the "reached the CLI's own request/auth-failure protocol" case for pi
+# looks like rix job *succeeding* with no PRs produced, not a JobFailure - the discriminator
+# against a real regression (our flags rejected as unknown) is the same exit-0/success shape,
+# since a rejected flag instead fails the process outright with a plain "Unknown option" line.
+#
# Requires: RIX_BIN (a built rix binary), RIX_REPO/RIX_READ_TOKEN (a real repo + token to clone),
# and network access to install/run the real agent CLIs via npm.
@@ -92,3 +106,24 @@ diagnostic_of() {
echo "$diagnostic" | jq empty
[ "$(echo "$diagnostic" | jq -r .is_error)" = true ]
}
+
+@test "pi fails without a key, pointing at a real doc file it ships" {
+ export RIX_AGENT=pi
+ run "$RIX_BIN" job
+ [ "$status" -eq 1 ]
+ [ "$(result_field status)" = failure ]
+ # See the file header for why this is a .md path rather than JSON, and why it needs trimming.
+ diagnostic="$(result_field error | diagnostic_of | xargs)"
+ [[ "$diagnostic" == *.md ]]
+ [ -f "$diagnostic" ]
+}
+
+@test "pi treats an invalid key as a successful run producing no PRs, not a failure" {
+ export RIX_AGENT=pi RIX_MODEL=openai/gpt-4o
+ export OPENAI_API_KEY=sk-invalid-0000000000000000000000000000000000000000
+ run "$RIX_BIN" job
+ # See the file header: pi swallows a per-turn auth error into its JSON stream and still exits 0.
+ [ "$status" -eq 0 ]
+ [ "$(result_field status)" = success ]
+ [ "$(result_field pendingPrRequests | jq 'length')" = 0 ]
+}