mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Replace fabro's hand-written agent loop with pebble's `CodingAgent` and delete the `fabro-agent` crate. Workflow: `PebbleBackend` builds one agent per stage over `RunSandbox`, binds the stage's hooks as tool middleware, the interviewer as the human-input provider, and a durable `EventSink` that writes every agent event through the run event log before the agent goes on. Full-fidelity threads continue across stages through `export`/`resume_from_export`. Model failover takes the session record after the failed prompt and continues it on the next route with `ResumeMode::UseModel`, so no tool effect repeats. The steering hub targets pebble's control handle, with a steering lease holding completion open while a human is paired. Events: `EventBody::Agent` carries pebble's `CodingAgentEvent` envelope; the per-variant bodies, the transcript projection, and the fabro-only context-window, tool-summary, and skill types are gone in favor of pebble's. The OpenAPI schemas, generated Rust and TypeScript clients, and web readers follow. Ask Fabro: the session runs a `CodingAgent` under a read-only permission policy and a system prompt transform. Its conversation lives in a new `run_session_records` table and resumes on the recorded model with the event cursor advanced past the run log. `fabro exec` builds the same agent over a local sandbox with pebble's permission middleware and an interactive approval service. The catalog fills in `metadata.agent.profile` for operator providers that declare none, so pebble's lookup is the one resolution path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
82 lines
2.8 KiB
Rust
82 lines
2.8 KiB
Rust
use std::any::{TypeId, type_name};
|
|
|
|
use chrono::{TimeZone, Utc};
|
|
use fabro_api::types::{
|
|
RunSessionMetadata as ApiRunSessionMetadata, SessionDetail as ApiSessionDetail,
|
|
SessionSummary as ApiSessionSummary, SessionTurn as ApiSessionTurn, SubmitTurnRequest,
|
|
};
|
|
use fabro_types::{
|
|
RunSessionMetadata, SessionDetail, SessionId, SessionStatus, SessionSummary, SessionTurn,
|
|
TurnId, fixtures,
|
|
};
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn session_contract_reuses_domain_types() {
|
|
assert_same_type::<ApiSessionTurn, SessionTurn>();
|
|
assert_same_type::<ApiRunSessionMetadata, RunSessionMetadata>();
|
|
assert_same_type::<ApiSessionSummary, SessionSummary>();
|
|
assert_same_type::<ApiSessionDetail, SessionDetail>();
|
|
}
|
|
|
|
#[test]
|
|
fn session_detail_round_trips_active_turn_and_last_seq() {
|
|
let created_at = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 0).unwrap();
|
|
let turn_started_at = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 1).unwrap();
|
|
let updated_at = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 2).unwrap();
|
|
let session_id = SessionId::new();
|
|
let turn_id = TurnId::new();
|
|
let detail = SessionDetail::new(
|
|
RunSessionMetadata {
|
|
id: session_id,
|
|
run_id: fixtures::RUN_1,
|
|
title: Some("Ask Fabro".to_string()),
|
|
status: SessionStatus::Running,
|
|
model: Some("gpt-5.4".to_string()),
|
|
provider: Some(lithos_llm::catalog::builtin::openai()),
|
|
active_turn: Some(SessionTurn {
|
|
id: turn_id,
|
|
started_at: turn_started_at,
|
|
input: "What changed?".to_string(),
|
|
}),
|
|
created_at,
|
|
updated_at,
|
|
},
|
|
7,
|
|
);
|
|
|
|
let value = serde_json::to_value(&detail).expect("detail should serialize");
|
|
assert_eq!(value["active_turn"]["id"], turn_id.to_string());
|
|
assert!(
|
|
value.get("messages").is_none(),
|
|
"the conversation stays in the server's session record"
|
|
);
|
|
assert_eq!(value["last_seq"], 7);
|
|
|
|
let round_trip: ApiSessionDetail =
|
|
serde_json::from_value(value.clone()).expect("detail should deserialize");
|
|
assert_eq!(serde_json::to_value(round_trip).unwrap(), value);
|
|
}
|
|
|
|
#[test]
|
|
fn submit_turn_request_accepts_client_turn_id() {
|
|
let turn_id = TurnId::new();
|
|
let request: SubmitTurnRequest = serde_json::from_value(json!({
|
|
"input": "Summarize this run",
|
|
"turn_id": turn_id.to_string()
|
|
}))
|
|
.expect("submit turn request should deserialize");
|
|
|
|
assert_eq!(request.input, "Summarize this run");
|
|
assert_eq!(request.turn_id, Some(turn_id));
|
|
}
|
|
|
|
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>()
|
|
);
|
|
}
|