Skip to content

feat(sdk): add Pi wire protocol types and event classification - #154

Open
ross-rl wants to merge 2 commits into
mainfrom
pi/sdk-wire-types
Open

feat(sdk): add Pi wire protocol types and event classification#154
ross-rl wants to merge 2 commits into
mainfrom
pi/sdk-wire-types

Conversation

@ross-rl

@ross-rl ross-rl commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Adds the Pi protocol layer to the SDK as a pure addition — no connection class yet, so the wire types can be reviewed against the Rust pi-codes crate in isolation.

  • sdk/src/pi/protocol/index.ts — hand-written Pi JSONL RPC wire types (commands, the eleven agent events, the twelve AssistantMessageEvent delta variants, the seven message variants, session state) plus the broker event-type constants (turn/start, cancel, response, PI_EVENT_TYPES).
  • sdk/src/pi/types.tsPiTimelineEvent union: one pi_protocol timeline event per modelled Pi event, PiResponseTimelineEvent for acks, and message_update narrowed to text/thinking deltas, unioned with the shared system and unknown events.
  • sdk/src/pi/classify-pi-axon-event.tsclassifyPiAxonEvent built on the shared createClassifier, so SYSTEM_EVENT classification wins and unmodelled Pi frames stay unknown with their payload reachable.
  • sdk/src/pi/timeline-event-guards.ts — per-event guards plus re-exports of the shared system/unknown guards, matching the Codex module layout.
  • Tests for the classifier and the guards, with fixtures transcribed from pi-codes/tests/protocol_tests.rs.

Why

The broker speaks pi_json (AXON_ATTACH_PROTOCOL_PI) and the SDK needs faithful types for it. Depending on @earendil-works/pi-coding-agent is not viable: its type graph pulls in five provider SDKs and its RpcResponse references internal paths that no subpath export reaches. Pi ships no JSON Schema, so codegen is unavailable — hence hand-written types, kept field-for-field aligned with java/rust/broker/clients/pi-codes/src/{commands,events,response,types}.rs so a reviewer can diff the two directly.

Two protocol subtleties are captured in comments for reviewers:

  • agent_end is not turn completion — willRetry: true means more events follow, and only agent_settled ends an accepted turn.
  • steer / follow_up exist in Pi's own RpcCommand but not in pi-codes; they reach Pi verbatim as Control frames.

Checklist

  • PR title follows <type>(<scope>): <description> format (see above)
  • bun run check passes (lint + format)
  • bun run build passes
  • bun run test passes
  • SDK documentation updated (if applicable) — deferred to the follow-up PR that exports the public pi entry point

🤖 Generated with Claude Code

Hand-written Pi JSONL RPC wire types, kept field-for-field aligned with
the broker's Rust bindings in `pi-codes` so the two can be diffed, plus
the timeline classifier and type guards for Pi agent events.

Depending on `@earendil-works/pi-coding-agent` for these types is not
viable: its type graph pulls in five provider SDKs and its `RpcResponse`
references internal paths no subpath export reaches. Pi ships no JSON
Schema, so codegen is unavailable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cacheRead: number;
cacheWrite: number;
total: number;
}

@ross-rl ross-rl Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] majorusage is optional on a real assistant error frame. The current pi-codes crate marks AssistantMessage.usage with #[serde(default)], and protocol_tests.rs::assistant_message_without_usage_deserializes explicitly accepts a message_end whose assistant has stopReason: "error", errorMessage: "overloaded", and no usage. That frame cannot be represented by this interface. Please make this field optional (and update consumers to tolerate its absence), or otherwise model the wire default explicitly. Concrete failing frame: {"type":"message_end","message":{"role":"assistant","content":[],"api":"...","provider":"...","model":"...","stopReason":"error","errorMessage":"overloaded","timestamp":1}}.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 39d0a1e. Confirmed against the crate: AssistantMessage.usage is #[serde(default)] (types.rs:270), and assistant_message_without_usage_deserializes asserts message.usage == Usage::default() for a stopReason: "error" / errorMessage: "overloaded" frame with usage removed.

usage is now usage?: Usage, with a doc comment recording that the Rust side materialises a zeroed Usage when the field is absent — TypeScript has no equivalent of #[serde(default)], so surfacing the absence is more honest than inventing zeros the wire never sent. No SDK code read usage, so there were no consumers to update.

Regression coverage: assistantErrorMessageWithoutUsage() in src/__test-utils__/pi-fixtures.ts builds exactly the frame from your comment, and timeline-event-guards.test.ts classifies it as message_end and asserts usage === undefined alongside stopReason and errorMessage.

@ross-rl ross-rl left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Findings: 1 major.

  • [BUG] majorAssistantMessage.usage is required here but defaults when absent in the current Rust crate; see the inline comment.

Wire-fidelity verdict: Apart from that optionality mismatch, the transcription is field-for-field faithful to the current pi-codes sources and protocol_tests.rs. I checked:

  • every scalar enum value and casing (ThinkingLevel, QueueMode, StopReason, InputKind, StreamingBehavior);
  • every model/session field and serde optional/default behavior;
  • all content-block tags and fields;
  • Usage/Cost;
  • all seven message roles and their fields;
  • all 12 assistant-streaming variants, tags, terminal reason unions, and camelCase fields;
  • all 11 Pi event variants and payload fields;
  • response fields; and
  • the five broker-modelled commands, tags, optional ids/images, and renamed fields.

The classifier, discriminated timeline union, guards, event constants, and fixtures match the modeled Rust event set. The sole mismatch comes from the crate's newer regression for an error assistant message without token usage.

The Rust crate marks the field `#[serde(default)]`, so a request that fails
before any token accounting is recorded sends a message_end whose assistant
message has no usage at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ross-rl

ross-rl commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the one finding in 39d0a1e — AssistantMessage.usage is now optional, with a fixture and test for the usage-less stopReason: "error" frame. Detail in the inline reply.

Thanks for the field-by-field pass on the transcription; that was the part I most wanted a second set of eyes on. CI is green on the new head: check (22), check (24), audit, conventional-commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant