fix(pi): parse the opencode RPC event schema so turns actually deliver #81

Merged
weiwen merged 2 commits from fm/evie-pi-opencode-parser into main 2026-07-30 16:18:48 +08:00
Owner

Problem

Evie's pi stdout parser (src/pi.rs) only understood pi's native streaming RPC schema: message_start/message_update/message_end + agent_end. It yielded a response only on message_end/agent_end; everything else fell through to the Unrecognized pi event branch and was dropped.

The deployment runs pi 0.80.10 with provider = "opencode" and streaming off, which emits pi's turn-oriented schema instead: agent_start, turn_start, turn_end, tool_execution_*, auto_retry_*, agent_settled, response. Verified against the live journal on matsutake: agent_end/message_* occurred 0 times across Jul 6-30, while turn_end/agent_settled accounted for thousands of dropped events. So read_agent_response never saw a recognized terminal event and could only exit via the 60s idle timeout, discarding 100% of turn output — the load-bearing cause of Evie not replying.

The version question is resolved: turn_*/agent_settled is pi 0.80.10's native RPC vocabulary (per its bundled docs/rpc.md), not opencode-specific. Fixed in the parser rather than by pinning an old pi.

Fix

PiEvent/the parser now handle both schemas:

  • turn_end carries the turn's assistant message. A terminal turn (stopReason stop/length) delivers its text as the final response; an intermediate toolUse turn streams its thinking/text as live snapshots and keeps reading.
  • agent_settled completes a run that produced no stop turn — the only completion signal on the pure-error path (retries exhausted).
  • Provider errors are surfaced, not silently timed out: a stopReason: "error" turn, an exhausted auto_retry_end, or a rejected prompt response (e.g. missing API key) records the error, returned as an Err that flows through the existing expose_errors-gated crash notice to the user.
  • A stray leading agent_settled (the benign marker trailing a stop turn that already delivered) is skipped so the next turn can't desync.
  • drain_aborted also settles on agent_settled.
  • The native message_*/agent_end path is untouched and still works, so a streaming/native provider still functions.

Recognized-but-noise events (tool_execution_*, agent_start, queue_update, compaction_*, extension events) are matched explicitly so PiEvent::Other's Unrecognized pi event warning stays a real schema-drift alarm.

Regression test

The pre-existing tests asserted only the native agent_end/message_end schema, so they passed while production was 100% broken. Added tests built from event streams captured verbatim from the production journal (pi 0.80.10, opencode):

  • a full tool-using turn (turn_start -> tool_execution_* -> turn_end[toolUse] -> turn_end[stop] -> agent_settled) asserts the final assistant text is delivered and the intermediate turn does not short-circuit;
  • an exhausted-retry error turn (turn_end[error] x4 -> auto_retry_end{success:false} -> agent_settled) asserts the error is surfaced;
  • a rejected prompt and a stray leading agent_settled.

All 479 tests pass; the native-schema tests still pass. just check (fmt / clippy -D warnings / test) is green.

## Problem Evie's `pi` stdout parser (`src/pi.rs`) only understood pi's **native streaming** RPC schema: `message_start`/`message_update`/`message_end` + `agent_end`. It yielded a response only on `message_end`/`agent_end`; everything else fell through to the `Unrecognized pi event` branch and was dropped. The deployment runs **pi 0.80.10** with `provider = "opencode"` and streaming off, which emits pi's **turn-oriented** schema instead: `agent_start`, `turn_start`, `turn_end`, `tool_execution_*`, `auto_retry_*`, `agent_settled`, `response`. Verified against the live journal on `matsutake`: `agent_end`/`message_*` occurred **0 times** across Jul 6-30, while `turn_end`/`agent_settled` accounted for thousands of dropped events. So `read_agent_response` never saw a recognized terminal event and could only exit via the 60s idle timeout, **discarding 100% of turn output** — the load-bearing cause of Evie not replying. The version question is resolved: `turn_*`/`agent_settled` is pi 0.80.10's **native RPC vocabulary** (per its bundled `docs/rpc.md`), not opencode-specific. Fixed in the parser rather than by pinning an old `pi`. ## Fix `PiEvent`/the parser now handle **both** schemas: - **`turn_end`** carries the turn's assistant message. A terminal turn (`stopReason` `stop`/`length`) delivers its text as the final response; an intermediate `toolUse` turn streams its thinking/text as live snapshots and keeps reading. - **`agent_settled`** completes a run that produced no `stop` turn — the only completion signal on the pure-error path (retries exhausted). - **Provider errors are surfaced**, not silently timed out: a `stopReason: "error"` turn, an exhausted `auto_retry_end`, or a rejected `prompt` `response` (e.g. missing API key) records the error, returned as an `Err` that flows through the existing `expose_errors`-gated crash notice to the user. - A stray leading `agent_settled` (the benign marker trailing a `stop` turn that already delivered) is skipped so the next turn can't desync. - `drain_aborted` also settles on `agent_settled`. - The native `message_*`/`agent_end` path is untouched and still works, so a streaming/native provider still functions. Recognized-but-noise events (`tool_execution_*`, `agent_start`, `queue_update`, `compaction_*`, extension events) are matched explicitly so `PiEvent::Other`'s `Unrecognized pi event` warning stays a real schema-drift alarm. ## Regression test The pre-existing tests asserted only the native `agent_end`/`message_end` schema, so they passed while production was 100% broken. Added tests built from event streams **captured verbatim from the production journal** (pi 0.80.10, opencode): - a full tool-using turn (`turn_start` -> `tool_execution_*` -> `turn_end[toolUse]` -> `turn_end[stop]` -> `agent_settled`) asserts the final assistant text is delivered and the intermediate turn does not short-circuit; - an exhausted-retry error turn (`turn_end[error]` x4 -> `auto_retry_end{success:false}` -> `agent_settled`) asserts the error is surfaced; - a rejected `prompt` and a stray leading `agent_settled`. All 479 tests pass; the native-schema tests still pass. `just check` (fmt / clippy `-D warnings` / test) is green.
fix(pi): parse the opencode RPC event schema so turns actually deliver
Some checks failed
CI / check (pull_request) Failing after 1m57s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 2s
1bc2277bf1
The stdout parser only understood pi's native streaming schema
(`message_*` + `agent_end`). The deployment runs pi 0.80.10 with
`provider = "opencode"` and streaming off, which emits a turn-oriented
schema instead — `turn_start`/`turn_end`/`agent_settled`,
`tool_execution_*`, `auto_retry_*`, `response` — and never the events
the parser recognized. Every turn's output fell through to the
"Unrecognized pi event" branch and was dropped, so `read_agent_response`
could only exit via the 60s idle timeout: Evie never replied.

Teach `PiEvent`/the parser pi 0.80.10's turn schema while keeping the
native schema working:

- `turn_end` carries the assistant message; deliver the terminal turn's
  text (`stopReason` `stop`/`length`), stream an intermediate `toolUse`
  turn's thinking/text as live snapshots, and keep reading.
- `agent_settled` completes a run that produced no `stop` turn (the only
  completion signal on the pure-error path).
- A `stopReason: "error"` turn, an exhausted `auto_retry_end`, or a
  rejected `prompt` `response` records the provider error, surfaced via
  the existing `expose_errors`-gated crash notice instead of idling out.
- A stray leading `agent_settled` (the benign marker trailing a `stop`
  turn that already delivered) is skipped so the next turn can't desync.
- `drain_aborted` also settles on `agent_settled`.

Regression tests are built from event streams captured verbatim from the
production journal (pi 0.80.10, opencode): a full tool-using turn, an
exhausted-retry error turn, a rejected prompt, and a stray settle. The
pre-existing native-schema tests still pass, so both schemas are covered.
test(session): skip sandboxed-spawn provisioning test on a root runner
All checks were successful
CI / check (pull_request) Successful in 2m7s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 1s
f5740c897e
sandboxed_spawn_provisions_home_and_seeds_agents_md provisions a home by
resolving a non-root run-as drop target, which fails closed under uid 0.
On the root Forgejo CI runner the home is never seeded, so the AGENTS.md
assert panics and CI goes red for every branch off main — unrelated to
the change under test. Guard it the same way run_as::tests::
resolve_maps_the_current_user_to_its_own_ids already guards uid 0.
Author
Owner

CI note: the initial run went red on a pre-existing failure unrelated to this change - session::tests::sandboxed_spawn_provisions_home_and_seeds_agents_md panics on the root Forgejo runner because provisioning resolves a non-root run-as target that fails closed under uid 0 (confirmed by reproducing under a uid-0 user namespace). Added a second commit that guards the test on a root runner, mirroring run_as::tests::resolve_maps_the_current_user_to_its_own_ids. This overlaps a fix in another in-flight branch; drop this commit if that lands in main first.

CI note: the initial run went red on a pre-existing failure unrelated to this change - session::tests::sandboxed_spawn_provisions_home_and_seeds_agents_md panics on the root Forgejo runner because provisioning resolves a non-root run-as target that fails closed under uid 0 (confirmed by reproducing under a uid-0 user namespace). Added a second commit that guards the test on a root runner, mirroring run_as::tests::resolve_maps_the_current_user_to_its_own_ids. This overlaps a fix in another in-flight branch; drop this commit if that lands in main first.
weiwen merged commit 7d215346cb into main 2026-07-30 16:18:48 +08:00
weiwen deleted branch fm/evie-pi-opencode-parser 2026-07-30 16:18:48 +08:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
weiwen/evie!81
No description provided.