chore: enable pi session resume (#802)

* feat(agent): reuse Pi fixer sessions

* fix(agent): report Pi model telemetry and prove session fallback end to end

- Capture provider/model from Pi's final assistant message into Result so
  local invocation telemetry matches Claude/Codex/Grok.
- Drop cwd from the resume-test session-header fixture: %CD% expands to
  backslash paths that produce invalid JSON on Windows.
- Add a RunSessions integration test driving the real piAgent through
  persist -> dead-resume fallback -> replacement resume.

* no-mistakes(document): Scope stats token-counter cumulativeness note to codex vs pi

* no-mistakes(document): Scope stats token-counter cumulativeness note to codex vs pi in environment.md

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
This commit is contained in:
Tobias Ibounig
2026-08-21 19:35:48 +02:00
committed by GitHub
parent b00db34745
commit b39624f232
12 changed files with 499 additions and 39 deletions
+2 -2
View File
@@ -201,7 +201,7 @@ Five global config fields tune resolution and invocation, and the [Global Config
## Review session reuse
With the default `session_reuse: true`, Claude, Codex, and Grok keep one durable review-fixer session per run, and resume failures fall back to a fresh fixer session instead of skipping the fix turn.
With the default `session_reuse: true`, Claude, Codex, Grok, and Pi keep one durable review-fixer session per run, and resume failures fall back to a fresh fixer session instead of skipping the fix turn. Pi stores its native fixer transcript in Pi's session directory; no-mistakes persists only the minimum session identity needed to resume it.
Review turns always run in fresh, session-free invocations: a rereview certifies fixes that implement the previous review turn's findings, so it must never resume the session that prescribed them.
The [`session_reuse` field reference](/no-mistakes/reference/global-config/#session_reuse) owns the exact reuse, fallback, privacy, and restart-recovery semantics.
@@ -281,7 +281,7 @@ Starts a persistent HTTP server (`opencode serve`) on first use and reuses it ac
## Pi
Spawns a `pi` subprocess for each invocation with `--mode json --no-session`.
Spawns a `pi` subprocess for each invocation with `--mode json`. Cold invocations add `--no-session`; with `session_reuse: true`, review-fixer turns instead create and resume one Pi session per run via `--session <UUID>`.
See [`agent_args_override`](/no-mistakes/reference/global-config/#agent_args_override) for Pi override precedence.
Reads JSONL events from stdout and streams incremental text deltas to the TUI.
When structured output is requested, no-mistakes injects the JSON schema into the prompt and validates the final text response.
+1 -1
View File
@@ -398,7 +398,7 @@ no-mistakes stats
Displays total changes, rescued changes, rescue rate, reported and fixed mistakes, fixes by pipeline step, and the top repos by rescue activity.
Use `--agents` for local, per-purpose agent performance aggregates: duration and the subprocess-vs-model time split, session mode, errors, the token totals (input, output, cache-read, cache-creation, fresh input, reasoning), and the model round-trip and tool-category activity histogram, with a `METRICS` coverage count that tells a real zero apart from missing instrumentation.
Use `--run <id>` to inspect the individual agent invocations for one run - including each invocation's per-round token deltas next to the raw (cumulative for resumed sessions) counters, tool-category breakdown, workload size, finding count, and fallback reason - plus the total time parked at approval gates; it implies `--agents`.
Use `--run <id>` to inspect the individual agent invocations for one run - including each invocation's per-round token deltas next to the raw counters (cumulative across a resumed session for codex; per-invocation for pi), tool-category breakdown, workload size, finding count, and fallback reason - plus the total time parked at approval gates; it implies `--agents`.
Nullable fields an adapter did not report render as `-` (unknown), which is distinct from a recorded `0`; the legacy raw input, output, and cache-read counters remain numeric.
```sh
@@ -258,7 +258,7 @@ It never stores prompts, model outputs, diffs, raw command arguments, secret val
The additive session-fidelity fields are nullable and read back as unknown (rendered `-`) rather than a fabricated zero when the adapter did not report them, so rows written before a field existed, and adapters that do not surface a datum, stay honest.
The legacy raw input, output, and cache-read token counters render numerically; use the nullable per-round and derived fields to determine whether the adapter reported comparable usage:
- Token detail: `input_tokens`/`output_tokens`/`cache_read_tokens` (raw, cumulative across a resumed session for codex), `fresh_input_tokens` (input minus cache reads), `cache_creation_tokens` (unknown when the provider does not surface it), `reasoning_tokens`, and `delta_input_tokens`/`delta_output_tokens`/`delta_cache_read_tokens` (the correct per-round amounts, so a resumed session's cumulative counter is never mistaken for one round's usage).
- Token detail: `input_tokens`/`output_tokens`/`cache_read_tokens` (raw, cumulative across a resumed session for codex; per-invocation for pi), `fresh_input_tokens` (input minus cache reads), `cache_creation_tokens` (unknown when the provider does not surface it), `reasoning_tokens`, and `delta_input_tokens`/`delta_output_tokens`/`delta_cache_read_tokens` (the correct per-round amounts, so a resumed session's cumulative counter is never mistaken for one round's usage).
- Activity: `model_roundtrips` (a proxy for productive model turns), `tool_calls`, and a bounded tool-category histogram (`tool_wait_calls`, `tool_test_lint_calls`, `tool_edit_calls`, `tool_read_calls`, `tool_git_calls`, `tool_other_calls`); a compound command counts once per sub-command, so the histogram can sum higher than `tool_calls`.
- Timing split: `subprocess_wait_ms` is the wall-clock spent inside tool subprocesses; model/reasoning time is the invocation duration minus it, clamped at zero.
- Context: `workload_files`/`workload_lines` (bounded change size), `finding_count` (findings in the structured output), and `fallback_reason` (why a failed resume forced a fresh session, one of transient/parse/exit/spawn/unsupported/other).
@@ -198,11 +198,11 @@ User-supplied flags are normally inserted ahead of no-mistakes' managed flags, s
| `grok` | `-p`, `--single`, `--prompt-file`, `--prompt-json`, `--output-format`, `--json-schema`, `-r`, `--resume`, `-c`, `--continue`, `--fork-session`, `--session-id`, `--system-prompt-override`, `--system-prompt`, `--rules`, `--append-system-prompt`, `--agent`, `--agents`, `--verbatim`, `--no-subagents`, `--no-auto-update`, `--cwd`, `--restore-code`, `--worktree`, `--worktree-ref` |
| `rovodev` | `rovodev`, `serve`, `--disable-session-token` |
| `opencode` | `serve`, `--hostname`, `--port`, `--print-logs` |
| `pi` | `--mode`, `--no-session` |
| `pi` | `--mode`, `--no-session`, `-c`, `--continue`, `-r`, `--resume`, `--session`, `--session-id`, `--fork` |
| `copilot` | `-p`, `--prompt`, `--output-format`, `--no-color` |
For structured `codex` runs, no-mistakes also appends its own `--output-schema <tempfile>` after your overrides. Treat that flag as managed even though config validation does not currently reject it.
The Claude, Codex, and Grok session-control forms are reserved so no-mistakes can keep review-loop conversations deterministic: review turns stay session-free while the fixer keeps its own isolated durable session.
The Claude, Codex, Grok, and Pi session-control forms are reserved so no-mistakes can keep review-loop conversations deterministic: review turns stay session-free while the fixer keeps its own isolated durable session.
Smart defaults:
@@ -342,11 +342,11 @@ Per-run agent session reuse for the review loop's fixer role.
| Type | `bool` |
| Default | `true` |
When enabled and the pipeline agent supports native session resume (Claude or Grok via `--resume`, Codex via `exec resume`), each run keeps one durable fixer session across its review-fix turns.
When enabled and the pipeline agent supports native session resume (Claude or Grok via `--resume`, Codex via `exec resume`, Pi via `--session <UUID>`), each run keeps one durable fixer session across its review-fix turns.
Review turns - the initial full review and every full rereview - always run as fresh, session-free invocations regardless of this setting: a rereview certifies fixes that implement the previous review turn's findings, so it must never resume the session that prescribed them; cross-round review context travels only in the explicit sanitized round history.
The fixer session is never lent to review turns, other pipeline steps stay session-isolated in their own cold invocations, and different runs never reuse identities.
When resume is unavailable or fails, the fix turn falls back to a cold run or a fresh fixer session and the fallback is recorded in the local `agent_invocations` performance record.
Session identities are persisted only as minimum local resume metadata, never as prompts or transcripts.
When resume is unavailable or fails, the fix turn falls back to a cold run or a fresh fixer session and the fallback is recorded in the local `agent_invocations` performance record. Pi emits per-invocation usage after a resume, unlike Codex's cumulative session counters.
Session identities are persisted only as minimum local resume metadata, never as prompts or transcripts; Pi's own session directory retains its native transcript. Keep Pi's session directory private, and keep any `--session-dir` or `PI_CODING_AGENT_SESSION_DIR` setting stable while a run is active so a daemon restart can find the fixer session.
The [daemon crash-recovery reference](/no-mistakes/concepts/daemon/#crash-recovery) owns which parked gates can resume or reconcile after a restart.
Set `false` to force every agent invocation cold.
@@ -98,7 +98,7 @@ AI code review of your diff.
- Also returns a `risk_level` (`low`, `medium`, `high`) and `risk_rationale`
- Runs every review turn - the initial review and every full rereview - as a fresh, session-free invocation, so the rereview that certifies a fix round never resumes the session whose findings prescribed those fixes; the rereview prompt additionally reframes fix-round changes as pipeline-authored code to review under the same adversarial standard as the author's changes, with prior findings, fix summaries, and same-round tests treated as claims rather than evidence
- When a review-step fixer round commits and its re-review does not complete, persists that branch's uncertified commit range (lint and document fixer commits do not); the next run's initial review of that range receives the same pipeline-authored provenance framing so the replacement reviewer is not cold. A later rebase remaps the persisted SHAs onto the rewritten head. The range is cleared only after a completed review whose approved head equals or descends from the range tip; parked, failed, skipped, and aborted reviews leave it in place
- With the default `session_reuse: true`, Claude, Codex, and Grok reuse one durable fixer session across review-fix turns; a resume failure retries the same fix turn in a fresh fixer session, and unsupported agents run cold
- With the default `session_reuse: true`, Claude, Codex, Grok, and Pi reuse one durable fixer session across review-fix turns; a resume failure retries the same fix turn in a fresh fixer session, and unsupported agents run cold
- Bounds its agent turns with [`review_agent_timeout`](/no-mistakes/reference/global-config/#review_agent_timeout): a round's optional fix turn and its rereview turn share one budget, each later auto-fix round starts a fresh one, and an expired budget cancels the agent and fails the step with a timeout diagnostic rather than leaving the run active indefinitely
- Atomically records the exact commit examined when a full review completes successfully; a parked review retains its candidate only for recovery, while failed, skipped, superseded, and legacy reviews grant no inferred approval authority
+83 -6
View File
@@ -27,6 +27,10 @@ type piAgent struct {
func (a *piAgent) Name() string { return "pi" }
// SupportsSessionResume reports Pi's durable-session capability: JSON mode
// emits a session header with its UUID, and `--session <uuid>` reopens it.
func (a *piAgent) SupportsSessionResume() bool { return true }
func (a *piAgent) ReportsAgentAttempts() bool { return true }
// NeutralizesGateInstructions reports whether pi is currently launched with the
@@ -50,7 +54,13 @@ func (a *piAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) {
func (a *piAgent) Close() error { return nil }
func (a *piAgent) runOnce(ctx context.Context, opts RunOpts) (*Result, error) {
args := a.buildArgs()
if opts.Session != nil && opts.Session.ID != "" && !isPiSessionID(opts.Session.ID) {
// Pi accepts a path or partial UUID for --session. no-mistakes persists
// only the full UUID that Pi minted, so corrupt local metadata cannot
// turn a recovery attempt into an arbitrary session-file selection.
return nil, fmt.Errorf("invalid pi session identity")
}
args := a.buildArgs(opts.Session)
cmd := exec.CommandContext(ctx, a.bin, args...)
cmd.Dir = opts.CWD
cmd.Env = gitSafeEnv(opts.CWD, opts.Env)
@@ -121,6 +131,25 @@ func (a *piAgent) runOnce(ctx context.Context, opts RunOpts) (*Result, error) {
text := pp.finalText()
res, err := finalizeTextResult("pi", text, opts.JSONSchema, pp.usage)
if err == nil {
res.Model = pp.model
res.ModelProvider = pp.provider
}
if err == nil && opts.Session != nil {
if pp.sessionID == "" {
// A durable invocation without Pi's required JSON-mode session header
// cannot be resumed safely on a later fixer turn.
err = fmt.Errorf("pi did not report a session identity")
} else if opts.Session.ID != "" && pp.sessionID != opts.Session.ID {
err = fmt.Errorf("pi did not confirm the requested session")
} else {
res.SessionID = pp.sessionID
res.Resumed = opts.Session.ID != ""
// Pi's agent_end event contains only the messages generated by this
// invocation, including after --session, so usage is not cumulative.
res.SessionUsageCumulative = false
}
}
emitAgentExited(opts, "pi", pid, err)
return res, err
}
@@ -132,10 +161,12 @@ func piStdinError(err error) error {
return fmt.Errorf("pi stdin: %w", err)
}
// buildArgs returns the Pi argv for one invocation. Under the project-settings
// opt-out, the context-file suppression flag comes first. User extras otherwise
// precede the managed flags that no-mistakes requires for JSONL parsing.
func (a *piAgent) buildArgs() []string {
// buildArgs returns the Pi argv for one invocation. A nil session is an
// intentionally cold step; an empty SessionRef starts a durable session; a
// populated SessionRef resumes the UUID that no-mistakes previously recorded.
// Under the project-settings opt-out, the context-file suppression flag comes
// first. User extras otherwise precede managed JSON/session flags.
func (a *piAgent) buildArgs(session *SessionRef) []string {
args := make([]string, 0, len(a.extraArgs)+5)
// Project-settings opt-out (trusted-only; see config.DisableProjectSettings):
// disable AGENTS.md/CLAUDE.md discovery so an agent-orchestration target
@@ -157,10 +188,37 @@ func (a *piAgent) buildArgs() []string {
args = append(args, arg)
}
}
args = append(args, "--mode", "json", "--no-session")
args = append(args, "--mode", "json")
switch {
case session == nil:
args = append(args, "--no-session")
case session.ID != "":
args = append(args, "--session", session.ID)
}
return args
}
// isPiSessionID accepts only the full canonical UUID Pi emits in its JSON
// session header. It deliberately rejects paths and partial UUIDs accepted by
// Pi's CLI because no-mistakes must never resume an ambiguous global session.
func isPiSessionID(id string) bool {
if len(id) != 36 {
return false
}
for i := 0; i < len(id); i++ {
if i == 8 || i == 13 || i == 18 || i == 23 {
if id[i] != '-' {
return false
}
continue
}
if !(id[i] >= '0' && id[i] <= '9') && !(id[i] >= 'a' && id[i] <= 'f') && !(id[i] >= 'A' && id[i] <= 'F') {
return false
}
}
return true
}
func piNoContextFilesArgIndex(args []string) int {
for i := 0; i < len(args); i++ {
if piNoContextFilesArg(args[i]) {
@@ -216,6 +274,9 @@ type piParser struct {
streamText map[int]string
completeText map[int]string
finalAssistant map[string]any
sessionID string
model string
provider string
usage TokenUsage
seenUsage map[string]struct{}
assistantError string
@@ -254,6 +315,14 @@ func (p *piParser) parse(ctx context.Context, r io.Reader) error {
func (p *piParser) handleEvent(event map[string]any) {
typ, _ := event["type"].(string)
switch typ {
case "session":
// The JSON-mode header is emitted before agent_start. Preserve only its
// first valid full UUID so later arbitrary events cannot replace it.
if p.sessionID == "" {
if id, _ := event["id"].(string); isPiSessionID(id) {
p.sessionID = id
}
}
case "message_update":
p.rememberAssistant(event["message"])
p.handleAssistantEvent(event["assistantMessageEvent"])
@@ -274,6 +343,14 @@ func (p *piParser) rememberAssistant(raw any) {
return
}
p.finalAssistant = msg
// Pi reports the serving model and provider on every assistant message;
// keep the latest so local invocation telemetry matches Claude/Codex/Grok.
if v, _ := msg["model"].(string); v != "" {
p.model = v
}
if v, _ := msg["provider"].(string); v != "" {
p.provider = v
}
if reason, _ := msg["stopReason"].(string); reason == "error" || reason == "aborted" {
p.assistantError = piFirstString(msg, "errorMessage", "error", "message")
+184 -15
View File
@@ -14,7 +14,7 @@ import (
func TestPiAgent_BuildArgs(t *testing.T) {
pa := &piAgent{bin: "pi"}
args := pa.buildArgs()
args := pa.buildArgs(nil)
expected := []string{"--mode", "json", "--no-session"}
@@ -28,9 +28,23 @@ func TestPiAgent_BuildArgs(t *testing.T) {
}
}
func TestPiAgent_BuildArgs_DurableSession(t *testing.T) {
pa := &piAgent{bin: "pi"}
started := pa.buildArgs(&SessionRef{})
if got, want := strings.Join(started, " "), "--mode json"; got != want {
t.Fatalf("durable-session args = %q, want %q", got, want)
}
const sessionID = "019ff2f3-5f31-744b-90b8-679074ff7686"
resumed := pa.buildArgs(&SessionRef{ID: sessionID})
if got, want := strings.Join(resumed, " "), "--mode json --session "+sessionID; got != want {
t.Fatalf("resume args = %q, want %q", got, want)
}
}
func TestPiAgent_BuildArgs_PrependsExtraArgs(t *testing.T) {
pa := &piAgent{bin: "pi", extraArgs: []string{"--provider", "google"}}
args := pa.buildArgs()
args := pa.buildArgs(nil)
expected := []string{"--provider", "google", "--mode", "json", "--no-session"}
@@ -46,7 +60,7 @@ func TestPiAgent_BuildArgs_PrependsExtraArgs(t *testing.T) {
func TestPiAgent_BuildArgs_OptOutAddsNoContextFiles(t *testing.T) {
pa := &piAgent{bin: "pi", extraArgs: []string{"--system-prompt"}, disableProjectSettings: true}
args := pa.buildArgs()
args := pa.buildArgs(nil)
expected := []string{"--no-context-files", "--system-prompt", "--mode", "json", "--no-session"}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
@@ -60,7 +74,7 @@ func TestPiAgent_BuildArgs_OptOutAddsNoContextFiles(t *testing.T) {
func TestPiAgent_BuildArgs_OptOutDoesNotDuplicateNoContextFiles(t *testing.T) {
pa := &piAgent{bin: "pi", extraArgs: []string{"--provider", "google", "-nc"}, disableProjectSettings: true}
args := pa.buildArgs()
args := pa.buildArgs(nil)
expected := []string{"-nc", "--provider", "google", "--mode", "json", "--no-session"}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
@@ -74,7 +88,7 @@ func TestPiAgent_BuildArgs_OptOutDoesNotDuplicateNoContextFiles(t *testing.T) {
func TestPiAgent_BuildArgs_OptOutPreservesNoContextFilesOptionValue(t *testing.T) {
pa := &piAgent{bin: "pi", extraArgs: []string{"--system-prompt", "-nc"}, disableProjectSettings: true}
args := pa.buildArgs()
args := pa.buildArgs(nil)
expected := []string{"--no-context-files", "--system-prompt", "-nc", "--mode", "json", "--no-session"}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
@@ -138,12 +152,12 @@ func TestPiAgent_RunOptOutPassesNoContextFilesToCLI(t *testing.T) {
bin := writeFakePi(t, t.TempDir(), `#!/bin/sh
printf '%s\n' "$*" > pi-argv.txt
cat > /dev/null
printf '%s\n' '{"type":"agent_end","messages":[{"role":"assistant","content":"ok"}]}'
printf '%s\n' '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"ok"}]}]}'
`, strings.Join([]string{
"@echo off",
"echo %* > pi-argv.txt",
"more > nul",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"assistant\",\"content\":\"ok\"}]}",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}]}",
}, "\r\n"))
pa := &piAgent{
@@ -173,16 +187,16 @@ func TestPiAgent_RunParsesAssistantContentAndUsage(t *testing.T) {
// content blocks and a usage record. Mirrors the live JSONL shape.
bin := writeFakePi(t, dir, `#!/bin/sh
cat > /dev/null
printf '%s\n' '{"type":"message_update","message":{"role":"assistant","responseId":"r1"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"{\"ok"}}'
printf '%s\n' '{"type":"message_update","message":{"role":"assistant","responseId":"r1"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"\":true}"}}'
printf '%s\n' '{"type":"message_end","message":{"role":"assistant","responseId":"r1","content":[{"type":"text","text":"{\"ok\":true}"}],"usage":{"input":11,"output":7,"cacheRead":3,"cacheWrite":1}}}'
printf '%s\n' '{"type":"message_update","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"{\"ok"}}'
printf '%s\n' '{"type":"message_update","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"\":true}"}}'
printf '%s\n' '{"type":"message_end","message":{"role":"assistant","responseId":"r1","provider":"openai-codex","model":"gpt-5.6-luna","content":[{"type":"text","text":"{\"ok\":true}"}],"usage":{"input":11,"output":7,"cacheRead":3,"cacheWrite":1}}}'
printf '%s\n' '{"type":"agent_end","messages":[]}'
`, strings.Join([]string{
"@echo off",
"more > nul",
"echo {\"type\":\"message_update\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\"},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"{\\\"ok\"}}",
"echo {\"type\":\"message_update\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\"},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"\\\":true}\"}}",
"echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":11,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}}",
"echo {\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"{\\\"ok\"}}",
"echo {\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"\\\":true}\"}}",
"echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r1\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.6-luna\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":11,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}}",
"echo {\"type\":\"agent_end\",\"messages\":[]}",
}, "\r\n"))
@@ -206,6 +220,9 @@ printf '%s\n' '{"type":"agent_end","messages":[]}'
result.Usage.CacheReadTokens != 3 || result.Usage.CacheCreationTokens != 1 {
t.Fatalf("unexpected usage: %+v", result.Usage)
}
if result.Model != "gpt-5.6-luna" || result.ModelProvider != "openai-codex" {
t.Fatalf("unexpected model telemetry: model=%q provider=%q", result.Model, result.ModelProvider)
}
if len(chunks) == 0 {
t.Fatal("expected onChunk to receive streaming text")
}
@@ -227,11 +244,11 @@ func TestPiAgent_RunFallsBackToAgentEndMessages(t *testing.T) {
dir := t.TempDir()
bin := writeFakePi(t, dir, `#!/bin/sh
cat > /dev/null
printf '%s\n' '{"type":"agent_end","messages":[{"role":"user","content":"prompt"},{"role":"assistant","content":"{\"ok\":true}"}]}'
printf '%s\n' '{"type":"agent_end","messages":[{"role":"user","content":"prompt"},{"role":"assistant","content":[{"type":"text","text":"{\"ok\":true}"}]}]}'
`, strings.Join([]string{
"@echo off",
"more > nul",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":\"prompt\"},{\"role\":\"assistant\",\"content\":\"{\\\"ok\\\":true}\"}]}",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":\"prompt\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}]}]}",
}, "\r\n"))
schema := json.RawMessage(`{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}`)
@@ -249,6 +266,158 @@ printf '%s\n' '{"type":"agent_end","messages":[{"role":"user","content":"prompt"
}
}
func TestPiAgent_RunResumesPersistedSession(t *testing.T) {
const sessionID = "019ff2f3-5f31-744b-90b8-679074ff7686"
workDir := t.TempDir()
bin := writeFakePi(t, t.TempDir(), `#!/bin/sh
set -eu
cat > /dev/null
printf '%s\n' "$*" >> pi-argv.txt
if [ -f pi-session-id ]; then
id=$(cat pi-session-id)
[ "$*" = "--mode json --session $id" ] || { echo "unexpected resume args: $*" >&2; exit 1; }
input=22
else
[ "$*" = "--mode json" ] || { echo "unexpected start args: $*" >&2; exit 1; }
id=019ff2f3-5f31-744b-90b8-679074ff7686
printf '%s\n' "$id" > pi-session-id
input=11
fi
printf '%s\n' "{\"type\":\"session\",\"version\":3,\"id\":\"$id\",\"timestamp\":\"2026-08-21T00:00:00.000Z\"}"
printf '%s\n' "{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r$input\",\"stopReason\":\"stop\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":$input,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}}"
printf '%s\n' "{\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":\"fix\"},{\"role\":\"assistant\",\"responseId\":\"r$input\",\"stopReason\":\"stop\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":$input,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}]}"
`, strings.Join([]string{
"@echo off",
"setlocal EnableDelayedExpansion",
"more > nul",
// The space before >> matters: %* ends in a hex digit, and cmd.exe
// parses a digit immediately preceding a redirect as a file-descriptor
// number (6>> would append handle 6, leaving the file empty).
"echo %* >> pi-argv.txt",
"if exist pi-session-id (",
" set /p id=<pi-session-id",
" echo %*| findstr /x /c:\"--mode json --session !id!\" >nul",
" if errorlevel 1 (echo unexpected resume args: %* 1>&2 & exit /b 1)",
" set input=22",
") else (",
" echo %*| findstr /x /c:\"--mode json\" >nul",
" if errorlevel 1 (echo unexpected start args: %* 1>&2 & exit /b 1)",
" set id=019ff2f3-5f31-744b-90b8-679074ff7686",
" echo !id!>pi-session-id",
" set input=11",
")",
"echo {\"type\":\"session\",\"version\":3,\"id\":\"!id!\",\"timestamp\":\"2026-08-21T00:00:00.000Z\"}",
"echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"responseId\":\"r!input!\",\"stopReason\":\"stop\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":!input!,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}}",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":\"fix\"},{\"role\":\"assistant\",\"responseId\":\"r!input!\",\"stopReason\":\"stop\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":true}\"}],\"usage\":{\"input\":!input!,\"output\":7,\"cacheRead\":3,\"cacheWrite\":1}}]}",
}, "\r\n"))
schema := json.RawMessage(`{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}`)
pa := &piAgent{bin: bin}
started, err := pa.Run(context.Background(), RunOpts{Prompt: "fix", CWD: workDir, JSONSchema: schema, Session: &SessionRef{}})
if err != nil {
t.Fatalf("start durable Pi session: %v", err)
}
if started.SessionID != sessionID || started.Resumed {
t.Fatalf("started session = %+v, want id=%q and Resumed=false", started, sessionID)
}
if started.Usage.InputTokens != 11 || started.SessionUsageCumulative {
t.Fatalf("started usage = %+v, want invocation-only input 11", started.Usage)
}
if got, err := os.ReadFile(filepath.Join(workDir, "pi-session-id")); err != nil || strings.TrimSpace(string(got)) != sessionID {
t.Fatalf("persisted session ID = %q, %v; want %q", got, err, sessionID)
}
resumed, err := pa.Run(context.Background(), RunOpts{Prompt: "fix", CWD: workDir, JSONSchema: schema, Session: &SessionRef{ID: started.SessionID}})
if err != nil {
t.Fatalf("resume durable Pi session: %v", err)
}
if resumed.SessionID != sessionID || !resumed.Resumed {
t.Fatalf("resumed session = %+v, want id=%q and Resumed=true", resumed, sessionID)
}
if resumed.Usage.InputTokens != 22 || resumed.SessionUsageCumulative {
t.Fatalf("resumed usage = %+v, want invocation-only input 22", resumed.Usage)
}
argv, err := os.ReadFile(filepath.Join(workDir, "pi-argv.txt"))
if err != nil {
t.Fatalf("read captured pi argv: %v", err)
}
if got, want := strings.Fields(string(argv)), []string{"--mode", "json", "--mode", "json", "--session", sessionID}; strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("pi argv = %q, want %q", got, want)
}
}
func TestPiAgent_RunFailsWhenStartingDurableSessionWithoutHeader(t *testing.T) {
bin := writeFakePi(t, t.TempDir(), `#!/bin/sh
cat > /dev/null
printf '%s\n' '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}'
printf '%s\n' '{"type":"agent_end","messages":[]}'
`, strings.Join([]string{
"@echo off",
"more > nul",
"echo {\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}",
"echo {\"type\":\"agent_end\",\"messages\":[]}",
}, "\r\n"))
_, err := (&piAgent{bin: bin}).Run(context.Background(), RunOpts{
Prompt: "fix",
CWD: t.TempDir(),
Session: &SessionRef{},
})
if err == nil || !strings.Contains(err.Error(), "did not report a session identity") {
t.Fatalf("missing-session-header error = %v", err)
}
}
func TestPiAgent_RunRejectsUnconfirmedResume(t *testing.T) {
bin := writeFakePi(t, t.TempDir(), `#!/bin/sh
cat > /dev/null
printf '%s\n' '{"type":"session","id":"019ff2f3-5f31-744b-90b8-679074ff7686"}'
printf '%s\n' '{"type":"agent_end","messages":[{"role":"assistant","content":"ok"}]}'
`, strings.Join([]string{
"@echo off",
"more > nul",
"echo {\"type\":\"session\",\"id\":\"019ff2f3-5f31-744b-90b8-679074ff7686\"}",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"assistant\",\"content\":\"ok\"}]}",
}, "\r\n"))
_, err := (&piAgent{bin: bin}).Run(context.Background(), RunOpts{
Prompt: "fix",
CWD: t.TempDir(),
Session: &SessionRef{ID: "019ff2f3-5f31-744b-90b8-679074ff7687"},
})
if err == nil || !strings.Contains(err.Error(), "did not confirm") {
t.Fatalf("resume mismatch error = %v", err)
}
}
func TestPiAgent_RunRejectsInvalidResumeID(t *testing.T) {
_, err := (&piAgent{bin: "unused"}).Run(context.Background(), RunOpts{
Prompt: "fix",
CWD: t.TempDir(),
Session: &SessionRef{ID: "/tmp/not-a-pi-session"},
})
if err == nil || !strings.Contains(err.Error(), "invalid pi session identity") {
t.Fatalf("invalid session error = %v", err)
}
}
func TestPiParser_CapturesFirstValidSessionHeader(t *testing.T) {
const sessionID = "019ff2f3-5f31-744b-90b8-679074ff7686"
stream := strings.Join([]string{
`{"type":"session","id":"not-a-uuid"}`,
`{"type":"session","id":"019ff2f3-5f31-744b-90b8-679074ff7686"}`,
`{"type":"session","id":"019ff2f3-5f31-744b-90b8-679074ff7687"}`,
}, "\n")
pp := &piParser{}
if err := pp.parse(context.Background(), strings.NewReader(stream)); err != nil {
t.Fatalf("parse: %v", err)
}
if pp.sessionID != sessionID {
t.Fatalf("session ID = %q, want first valid %q", pp.sessionID, sessionID)
}
}
func TestPiParser_ClearsPriorAssistantErrorAfterSuccessfulRetry(t *testing.T) {
stream := strings.Join([]string{
`{"type":"message_end","message":{"role":"assistant","responseId":"r1","stopReason":"error","errorMessage":"transient failure"}}`,
+4 -4
View File
@@ -7,9 +7,9 @@ import (
)
// TestSupportsSessionResume_PerAdapter pins which adapters advertise durable
// session resume. Claude and codex have native resume (claude --resume,
// codex exec resume); every other adapter must run cold so the pipeline's
// fallback path records the cold invocation instead of assuming reuse.
// session resume. Claude, Codex, Grok, and Pi have native resume (Claude/Grok
// --resume, Codex exec resume, Pi --session); every other adapter must run cold
// so the pipeline's fallback path records the cold invocation instead of assuming reuse.
func TestSupportsSessionResume_PerAdapter(t *testing.T) {
cases := []struct {
name string
@@ -21,7 +21,7 @@ func TestSupportsSessionResume_PerAdapter(t *testing.T) {
{"grok", &grokAgent{bin: "grok"}, true},
{"rovodev", &rovodevAgent{bin: "acli"}, false},
{"opencode", &opencodeAgent{bin: "opencode"}, false},
{"pi", &piAgent{bin: "pi"}, false},
{"pi", &piAgent{bin: "pi"}, true},
{"copilot", &copilotAgent{bin: "copilot"}, false},
{"acpx", &acpxAgent{bin: "acpx", target: "gemini"}, false},
{"noop", NewNoop(), false},
+3 -2
View File
@@ -149,8 +149,9 @@ func renderRunAgentPerf(w io.Writer, database *db.DB, runID string) error {
return err
}
// Table 2: per-round token deltas next to the raw (cumulative for resumed
// sessions) counters, so a cumulative counter cannot be misread as per-round.
// Table 2: per-round token deltas next to the raw counters (cumulative
// across a resumed session for codex; per-invocation for pi), so a
// cumulative counter cannot be misread as per-round.
fmt.Fprintln(w)
tw = tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
fmt.Fprintln(tw, "STEP\tROUND\tPURPOSE\tSESSION\tΔ IN (round)\tΔ OUT\tΔ CACHE RD\tIN (raw)\tOUT (raw)\tCACHE RD (raw)\tCACHE WR\tFRESH IN\tREASON")
+9 -2
View File
@@ -727,8 +727,8 @@ branch_sync_remote_timeout: "60s"
# Reuse one durable fixer session per run across review-fix turns. Review turns
# always run session-free so a rereview never resumes the session that prescribed
# its fixes. Supported for claude, codex, and grok; other agents run cold. Set false to
# force every agent invocation cold.
# its fixes. Supported for claude, codex, grok, and pi; other agents run cold.
# Set false to force every agent invocation cold.
session_reuse: true
# Log level for daemon output
@@ -1294,6 +1294,13 @@ var reservedAgentArgs = map[string]map[string]bool{
string(types.AgentPi): {
"--mode": true,
"--no-session": true,
"-c": true,
"--continue": true,
"-r": true,
"--resume": true,
"--session": true,
"--session-id": true,
"--fork": true,
},
string(types.AgentCopilot): {
"-p": true,
@@ -139,6 +139,19 @@ func TestLoadGlobal_AgentArgsOverride_ReservedArgsRejected(t *testing.T) {
{"grok", "--restore-code"},
{"grok", "--worktree"},
{"grok", "--worktree-ref"},
{"pi", "--mode"},
{"pi", "--mode=json"},
{"pi", "--no-session"},
{"pi", "-c"},
{"pi", "--continue"},
{"pi", "--resume"},
{"pi", "--resume=session-id"},
{"pi", "--session"},
{"pi", "--session=session-id"},
{"pi", "--session-id"},
{"pi", "--session-id=session-id"},
{"pi", "--fork"},
{"pi", "--fork=session-id"},
}
for _, tt := range tests {
t.Run(tt.agent+"_"+tt.arg, func(t *testing.T) {
@@ -0,0 +1,193 @@
package pipeline
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/kunchenguid/no-mistakes/internal/agent"
)
// writeFakePiExecutable materializes a fake `pi` binary for the pipeline
// package's adapter-integration test. It mirrors the POSIX/Windows dual
// fixture shape used by internal/agent's Pi tests.
func writeFakePiExecutable(t *testing.T, dir, posixScript, windowsScript string) string {
t.Helper()
name := "pi"
script := posixScript
if runtime.GOOS == "windows" {
name = "pi.cmd"
script = windowsScript
}
bin := filepath.Join(dir, name)
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake pi: %v", err)
}
return bin
}
// TestRunSessions_PiAdapterPersistsAndFallsBack drives the real piAgent
// (backed by a fake pi executable) through RunSessions to prove the
// adapter-specific contract the generic fake-based tests cannot: Pi's minted
// UUID is persisted as run_agent_sessions metadata, a dead resumed session
// triggers a marked fresh-session fallback that re-runs the same turn, and the
// replacement identity replaces the stored row.
func TestRunSessions_PiAdapterPersistsAndFallsBack(t *testing.T) {
const (
id1 = "019ff2f3-5f31-744b-90b8-679074ff7681"
id2 = "019ff2f3-5f31-744b-90b8-679074ff7682"
id3 = "019ff2f3-5f31-744b-90b8-679074ff7683"
)
dir := t.TempDir()
bin := writeFakePiExecutable(t, dir, `#!/bin/sh
set -eu
cat > /dev/null
req=""
prev=""
for a in "$@"; do
if [ "$prev" = "--session" ]; then
req="$a"
fi
prev="$a"
done
if [ -f pi-expire ] && [ -n "$req" ]; then
echo "session file missing" >&2
exit 20
fi
if [ -n "$req" ]; then
id="$req"
else
n=$(cat pi-counter 2>/dev/null || echo 0)
n=$((n+1))
printf '%s\n' "$n" > pi-counter
case "$n" in
1) id=`+id1+` ;;
2) id=`+id2+` ;;
*) id=`+id3+` ;;
esac
fi
printf '%s\n' "{\"type\":\"session\",\"id\":\"$id\"}"
printf '%s\n' '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"ok"}]}]}'
`, strings.Join([]string{
"@echo off",
"setlocal EnableDelayedExpansion",
"more > nul",
"set req=",
"set prev=",
"for %%a in (%*) do (",
" if \"!prev!\"==\"--session\" set req=%%a",
" set prev=%%a",
")",
"if exist pi-expire if not \"!req!\"==\"\" (",
" echo session file missing 1>&2",
" exit /b 20",
")",
"if not \"!req!\"==\"\" (",
" set id=!req!",
" goto emit",
")",
"rem Every %n% below sits on its own sequential line, so cmd.exe expands it",
"rem at that line's parse time - after set /a n=n+1 has run. Never move them",
"rem into one parenthesized block: parse-time expansion there would freeze",
"rem %n% at its pre-increment value and emit a stale session identity.",
"if exist pi-counter (set /p n=<pi-counter) else (set n=0)",
"set /a n=n+1",
"echo !n!>pi-counter",
"set id=" + id1,
"if \"%n%\"==\"2\" set id=" + id2,
"if \"%n%\"==\"3\" set id=" + id3,
":emit",
"echo {\"type\":\"session\",\"id\":\"!id!\"}",
"echo {\"type\":\"agent_end\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}]}",
}, "\r\n"))
d, run := sessionTestDB(t)
pa, err := agent.New("pi", bin, nil)
if err != nil {
t.Fatalf("new pi agent: %v", err)
}
rs := NewRunSessions(d, run.ID, pa, true)
var fallbackFlags []bool
opts := agent.RunOpts{
Prompt: "fix",
CWD: dir,
OnAttempt: func(a agent.Attempt) {
fallbackFlags = append(fallbackFlags, a.SessionFallback)
},
}
storedSession := func() string {
t.Helper()
sessions, err := d.GetRunAgentSessions(run.ID)
if err != nil {
t.Fatalf("get run sessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("stored %d session rows, want 1: %+v", len(sessions), sessions)
}
s := sessions[0]
if s.Role != string(SessionRoleFixer) || s.Agent != "pi" {
t.Fatalf("stored session row = %+v, want role=%s agent=pi", s, SessionRoleFixer)
}
return s.SessionID
}
first, err := rs.Run(context.Background(), pa, SessionRoleFixer, opts, nil)
if err != nil {
t.Fatalf("first fixer turn: %v", err)
}
if first.SessionID != id1 || first.Resumed {
t.Fatalf("first result = %+v, want started session %s", first, id1)
}
if got := storedSession(); got != id1 {
t.Fatalf("persisted session = %q, want %q", got, id1)
}
// The next resume hits a dead session: while the expire marker exists the
// fake pi exits nonzero for any --session invocation. RunSessions must drop
// the identity, re-run the same turn in a fresh session marked as fallback,
// and persist the replacement.
expire := filepath.Join(dir, "pi-expire")
if err := os.WriteFile(expire, []byte("x"), 0o644); err != nil {
t.Fatalf("write expire marker: %v", err)
}
second, err := rs.Run(context.Background(), pa, SessionRoleFixer, opts, nil)
if err != nil {
t.Fatalf("fixer turn after dead resume must fall back, got error: %v", err)
}
if second.SessionID != id2 || second.Resumed {
t.Fatalf("fallback result = %+v, want fresh session %s", second, id2)
}
if got := storedSession(); got != id2 {
t.Fatalf("persisted session after fallback = %q, want %q", got, id2)
}
// Attempt instrumentation: turn one emitted one non-fallback attempt; turn
// two emitted the failed resume attempt plus the marked fresh-session retry.
wantFlags := []bool{false, false, true}
if len(fallbackFlags) != len(wantFlags) {
t.Fatalf("attempt fallback flags = %v, want %v", fallbackFlags, wantFlags)
}
for i, want := range wantFlags {
if fallbackFlags[i] != want {
t.Fatalf("attempt[%d] fallback = %v, want %v (all: %v)", i, fallbackFlags[i], want, fallbackFlags)
}
}
// With the marker gone the replacement session is resumed normally.
if err := os.Remove(expire); err != nil {
t.Fatalf("remove expire marker: %v", err)
}
third, err := rs.Run(context.Background(), pa, SessionRoleFixer, opts, nil)
if err != nil {
t.Fatalf("third fixer turn: %v", err)
}
if third.SessionID != id2 || !third.Resumed {
t.Fatalf("third result = %+v, want resumed session %s", third, id2)
}
}