fabro/lib/foundation/fabro-api/tests/pair_round_trip.rs
Bryan Helmkamp bc31b8d23a
Serve the run stream as the only run event API
Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.

The API:
- `GET /runs/{id}/events` pages the run stream only
  (`PaginatedRunStreamList` by `after`); the legacy `since_seq`,
  `before_seq` and `order` cursors, the `oneOf` envelope, the legacy
  `EventEnvelope`, `PaginatedEventList`, `RunEvent`, `EventSeq`,
  `AppendEventResponse` and `RunEventDetailResponse` schemas,
  `POST /runs/{id}/events`, `GET /runs/{id}/events/{seq}` and
  `GET /runs/{id}/stages/{stageId}/events` are deleted. `GET
  /runs/{id}/attach` and `GET /attach` frame `RunStreamItem`s only.
- The Rust and TypeScript clients regenerate; the removed models leave
  the TypeScript package.

The readers:
- `fabro-client` drops the legacy run event listing, tail and attach
  methods and `RunEventStream`; `list_run_stream_until` bounds a stream
  read.
- `fabro-tool`'s `fabro_run_events` lists, searches and details the run
  stream: `after` is the exclusive `stream_seq` cursor, `event_id` the
  item's id, filters match the item's name and `recorded_at`.
- `fabro-dump` writes the stream to `events.jsonl`; `fabro dump` reads
  it.
- The CLI's progress renderer keeps only what the run stream drives:
  the legacy event conversion, the sandbox and setup displays and their
  styles go. `fabro system events` prints stream items.
- The server's demo mode folds its agent fixture straight into the
  session projection and answers the attach stub with a stream item;
  the demo stage events endpoint is gone.
- The web app: every run is a Petri run. The legacy event hooks,
  renderer props, stage popover summary, run phases derivation and
  live-event payload handling are deleted or ported to `RunStreamItem`;
  toasts and board refreshes read the stream's platform records.
- Tests: the legacy API round trips and pagination tests are deleted;
  the CLI's MCP, attach and system event mocks serve stream pages; the
  CLI test helpers read stream items.

Still failing until the later commits: the CLI tests seeded through
`POST /runs/{id}/events`, the server tests over the legacy store, and
the legacy type tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:36:00 -04:00

163 lines
5.2 KiB
Rust

use std::any::{TypeId, type_name};
use fabro_api::types::{
PairId as ApiPairId, PairMessageId as ApiPairMessageId,
PairMessageRecord as ApiPairMessageRecord, PairMessageRequest as ApiPairMessageRequest,
PairRecord as ApiPairRecord, PairStartRequest as ApiPairStartRequest,
PairStatus as ApiPairStatus, PairTarget as ApiPairTarget,
PairTranscriptEntry as ApiPairTranscriptEntry,
PairTranscriptResponse as ApiPairTranscriptResponse,
RunPairStatusResponse as ApiRunPairStatusResponse,
};
use fabro_types::{
PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest,
PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, RunPairStatusResponse,
fixtures,
};
use serde_json::{Value, json};
#[test]
fn pair_api_reuses_canonical_types() {
assert_same_type::<ApiPairId, PairId>();
assert_same_type::<ApiPairMessageId, PairMessageId>();
assert_same_type::<ApiPairStatus, PairStatus>();
assert_same_type::<ApiPairTarget, PairTarget>();
assert_same_type::<ApiPairRecord, PairRecord>();
assert_same_type::<ApiRunPairStatusResponse, RunPairStatusResponse>();
assert_same_type::<ApiPairStartRequest, PairStartRequest>();
assert_same_type::<ApiPairMessageRequest, PairMessageRequest>();
assert_same_type::<ApiPairMessageRecord, PairMessageRecord>();
assert_same_type::<ApiPairTranscriptEntry, PairTranscriptEntry>();
assert_same_type::<ApiPairTranscriptResponse, PairTranscriptResponse>();
}
#[test]
fn pair_target_omits_internal_fields() {
let target = PairTarget {
stage_id: "code@1".parse().unwrap(),
node_label: "Code".to_string(),
};
let serialized = serde_json::to_value(&target).unwrap();
assert_eq!(
serialized,
json!({
"stage_id": "code@1",
"node_label": "Code"
})
);
assert!(serialized.get("agent_session_id").is_none());
assert!(serialized.get("session_id").is_none());
assert!(serialized.get("node_id").is_none());
assert!(serialized.get("visit").is_none());
assert!(serialized.get("provider").is_none());
assert!(serialized.get("model").is_none());
}
#[test]
fn pair_start_request_only_carries_stage_id() {
let request = PairStartRequest {
stage_id: "code@1".parse().unwrap(),
};
let serialized = serde_json::to_value(&request).unwrap();
assert_eq!(
serialized,
json!({
"stage_id": "code@1"
})
);
assert!(serialized.get("agent_session_id").is_none());
assert!(serialized.get("target").is_none());
}
#[test]
fn pair_record_round_trips_json() {
assert_round_trip::<PairRecord>(json!({
"pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ",
"run_id": fixtures::RUN_1,
"status": "active",
"started_at": "2026-05-18T12:00:01Z",
"ended_at": null,
"failure_reason": null,
"target": pair_target_json()
}));
}
#[test]
fn pair_message_record_round_trips_json() {
assert_round_trip::<PairMessageRecord>(json!({
"message_id": "01HZX6M4D7Y1QW0Q0P6V8Z4DR5",
"client_message_id": "client-1",
"pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ",
"run_id": fixtures::RUN_1,
"stage_id": "code@1",
"text": "Can you inspect the failing test?",
"accepted_at": "2026-05-18T12:01:00Z"
}));
}
#[test]
fn pair_status_response_round_trips_json() {
assert_round_trip::<RunPairStatusResponse>(json!({
"run_id": fixtures::RUN_1,
"current_pair": null,
"targets": [pair_target_json()]
}));
}
#[test]
fn pair_transcript_response_round_trips_json() {
assert_round_trip::<PairTranscriptResponse>(json!({
"data": [
{
"kind": "user_message",
"seq": 42,
"event_id": "evt_1",
"ts": "2026-05-18T12:01:00Z",
"pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ",
"target": pair_target_json(),
"message_id": "01HZX6M4D7Y1QW0Q0P6V8Z4DR5",
"client_message_id": "client-1",
"text": "Can you inspect the failing test?"
},
{
"kind": "system_message",
"seq": 43,
"event_id": "evt_2",
"ts": "2026-05-18T12:01:01Z",
"pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ",
"target": pair_target_json(),
"system_message_kind": "human_joined",
"text": "A human has joined this workflow run for live pairing. Wait for their next message before continuing."
}
],
"meta": {
"next_since_seq": 44,
"has_more": false
}
}));
}
fn pair_target_json() -> Value {
json!({
"stage_id": "code@1",
"node_label": "Code"
})
}
fn assert_round_trip<T>(value: Value)
where
T: serde::de::DeserializeOwned + serde::Serialize,
{
let parsed: T = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}