mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Show the agent sidebar's sections in demo mode
The demo agent stage's stored events now read as one pebble session: MCP servers up and failed, skills, a subagent, a failover, a compaction, and a written file, ending with ProcessingEnd. Demo mode serves the run state it answered not_implemented to, with the agent stage carrying the coding agent's fold of those events, so the stage sidebar renders them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
4645181dbf
commit
68283e6413
3 changed files with 297 additions and 57 deletions
|
|
@ -449,6 +449,18 @@ pub(crate) async fn get_run_status(
|
|||
}
|
||||
}
|
||||
|
||||
/// The demo run's projection: every stage with its state, and the agent
|
||||
/// stage carrying the coding agent's fold of its stored events, so the stage
|
||||
/// sidebar shows MCP servers, a failover, a subagent, files, skills, and a
|
||||
/// compaction in demo mode.
|
||||
pub(crate) async fn get_run_state(
|
||||
_auth: RequiredUser,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Path(_id): Path<String>,
|
||||
) -> Response {
|
||||
(StatusCode::OK, Json(runs::run_state())).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(crate) struct ResolveRunParams {
|
||||
selector: String,
|
||||
|
|
@ -1441,10 +1453,18 @@ mod runs {
|
|||
]
|
||||
}
|
||||
|
||||
/// The agent stage's stored events: what pebble reports for one prompt
|
||||
/// that finds MCP servers, activates a skill, reads and writes files,
|
||||
/// delegates to a subagent, moves to a fallback route, and compacts,
|
||||
/// plus fabro's own `stage.prompt`.
|
||||
pub(super) fn stage_events() -> Vec<fabro_types::EventEnvelope> {
|
||||
use fabro_types::run_event::stage::StagePromptProps;
|
||||
use fabro_types::{AgentEventProps, EventBody, EventEnvelope, RunEvent};
|
||||
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, TokenUsage};
|
||||
use pebble_coding_agent::events::{
|
||||
CodingAgentEvent, CodingEvent, CompactionReason, ErrorData, ErrorKind,
|
||||
FailoverContinuation, InputSource, McpToolSummary, SkillActivationSource, SkillSummary,
|
||||
TokenUsage,
|
||||
};
|
||||
|
||||
let run_id = demo_run_id(1);
|
||||
let node_id = "detect-drift";
|
||||
|
|
@ -1476,28 +1496,40 @@ mod runs {
|
|||
CodingAgentEvent::new("ses_demo_detect_drift", event, ts.into()),
|
||||
))
|
||||
};
|
||||
let message = |text: &str| {
|
||||
agent(CodingEvent::AssistantMessage {
|
||||
let subagent = |event: CodingEvent| {
|
||||
EventBody::Agent(AgentEventProps::new(
|
||||
node_id,
|
||||
1,
|
||||
CodingAgentEvent::new("ses_demo_sub_1", event, ts.into())
|
||||
.with_parent_session_id("ses_demo_detect_drift"),
|
||||
))
|
||||
};
|
||||
let answer =
|
||||
|model: &str, text: &str, input: u64, output: u64| CodingEvent::AssistantMessage {
|
||||
text: text.into(),
|
||||
model: "claude-opus-4.6".into(),
|
||||
usage: TokenUsage::default(),
|
||||
model: model.into(),
|
||||
usage: TokenUsage {
|
||||
input,
|
||||
output,
|
||||
..TokenUsage::default()
|
||||
},
|
||||
cost_usd_micros: None,
|
||||
cost_source: None,
|
||||
tool_call_count: 0,
|
||||
context_window: None,
|
||||
reasoning: None,
|
||||
})
|
||||
};
|
||||
let tool_started = |tool_call_id: &str, path: &str| {
|
||||
};
|
||||
let message = |text: &str| agent(answer("claude-opus-4.6", text, 1_200, 180));
|
||||
let call_started = |tool: &str, tool_call_id: &str, arguments: serde_json::Value| {
|
||||
agent(CodingEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_name: tool.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
arguments: serde_json::json!({ "path": path }),
|
||||
arguments,
|
||||
})
|
||||
};
|
||||
let tool_completed = |tool_call_id: &str, output: &str| {
|
||||
let call_completed = |tool: &str, tool_call_id: &str, output: &str| {
|
||||
agent(CodingEvent::ToolCallCompleted {
|
||||
tool_name: "read_file".into(),
|
||||
tool_name: tool.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
output: serde_json::json!(output),
|
||||
metadata: pebble_agent::ToolOutputMetadata::default(),
|
||||
|
|
@ -1508,52 +1540,222 @@ mod runs {
|
|||
output_bytes_omitted: 0,
|
||||
})
|
||||
};
|
||||
let tool_started = |tool_call_id: &str, path: &str| {
|
||||
call_started(
|
||||
"read_file",
|
||||
tool_call_id,
|
||||
serde_json::json!({ "path": path }),
|
||||
)
|
||||
};
|
||||
let tool_completed =
|
||||
|tool_call_id: &str, output: &str| call_completed("read_file", tool_call_id, output);
|
||||
let started = |provider: &str, model: &str| CodingEvent::SessionStarted {
|
||||
provider: Some(provider.into()),
|
||||
model: Some(model.into()),
|
||||
};
|
||||
|
||||
vec![
|
||||
make_envelope(
|
||||
1,
|
||||
"evt-detect-drift-1",
|
||||
EventBody::StagePrompt(StagePromptProps {
|
||||
visit: 1,
|
||||
text: "You are a drift detection agent. Compare the production and staging environments and identify any configuration or code drift.".into(),
|
||||
mode: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
speed: None,
|
||||
}),
|
||||
let prompt = "You are a drift detection agent. Compare the production and staging environments and identify any configuration or code drift.";
|
||||
let report = "# Drift report\n\n- redis.max_connections: 200 (production) vs 100 (staging)\n- redis.tls: enabled vs disabled\n- iam.session_duration: 3600s vs 1800s\n";
|
||||
let events = vec![
|
||||
EventBody::StagePrompt(StagePromptProps {
|
||||
visit: 1,
|
||||
text: prompt.into(),
|
||||
mode: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
speed: None,
|
||||
}),
|
||||
agent(started("anthropic", "claude-opus-4.6")),
|
||||
agent(CodingEvent::McpServerReady {
|
||||
server: "github".into(),
|
||||
tools: vec![
|
||||
McpToolSummary {
|
||||
name: "mcp__github__list_issues".into(),
|
||||
original_name: "list_issues".into(),
|
||||
},
|
||||
McpToolSummary {
|
||||
name: "mcp__github__create_issue".into(),
|
||||
original_name: "create_issue".into(),
|
||||
},
|
||||
],
|
||||
startup_ms: 842,
|
||||
}),
|
||||
agent(CodingEvent::McpServerFailed {
|
||||
server: "atlassian".into(),
|
||||
error: "auth failed: the API token has expired".into(),
|
||||
startup_ms: 3,
|
||||
}),
|
||||
agent(CodingEvent::SkillsDiscovered {
|
||||
profile: "anthropic".into(),
|
||||
source_dirs: vec![".fabro/skills".into()],
|
||||
skills: vec![
|
||||
SkillSummary {
|
||||
name: "drift-triage".into(),
|
||||
description: "Rank configuration drift by blast radius".into(),
|
||||
},
|
||||
SkillSummary {
|
||||
name: "terraform".into(),
|
||||
description: "Read and plan Terraform modules".into(),
|
||||
},
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
}),
|
||||
agent(CodingEvent::UserInput {
|
||||
text: prompt.into(),
|
||||
content: None,
|
||||
source: InputSource::Prompt,
|
||||
}),
|
||||
message(
|
||||
"I'll start by loading the environment configurations for both production and staging to compare them.",
|
||||
),
|
||||
make_envelope(
|
||||
2,
|
||||
"evt-detect-drift-2",
|
||||
message("I'll start by loading the environment configurations for both production and staging to compare them."),
|
||||
tool_started("toolu_01", "environments/production/config.toml"),
|
||||
tool_completed(
|
||||
"toolu_01",
|
||||
"[redis]\nhost = \"redis-prod.internal\"\nport = 6379",
|
||||
),
|
||||
make_envelope(
|
||||
3,
|
||||
"evt-detect-drift-3",
|
||||
tool_started("toolu_01", "environments/production/config.toml"),
|
||||
tool_started("toolu_02", "environments/staging/config.toml"),
|
||||
tool_completed(
|
||||
"toolu_02",
|
||||
"[redis]\nhost = \"redis-staging.internal\"\nport = 6379",
|
||||
),
|
||||
make_envelope(
|
||||
4,
|
||||
"evt-detect-drift-4",
|
||||
tool_completed("toolu_01", "[redis]\nhost = \"redis-prod.internal\"\nport = 6379"),
|
||||
agent(CodingEvent::SkillActivated {
|
||||
skill_name: "drift-triage".into(),
|
||||
source: SkillActivationSource::Tool,
|
||||
}),
|
||||
call_started(
|
||||
"mcp__github__list_issues",
|
||||
"toolu_03",
|
||||
serde_json::json!({ "labels": ["drift"] }),
|
||||
),
|
||||
make_envelope(
|
||||
5,
|
||||
"evt-detect-drift-5",
|
||||
tool_started("toolu_02", "environments/staging/config.toml"),
|
||||
call_completed("mcp__github__list_issues", "toolu_03", "[]"),
|
||||
agent(CodingEvent::SubAgentSpawned {
|
||||
agent_id: "sub-1".into(),
|
||||
depth: 1,
|
||||
task: "Check the IAM session policy in staging".into(),
|
||||
generation: 1,
|
||||
}),
|
||||
subagent(started("anthropic", "claude-opus-4.6")),
|
||||
subagent(answer(
|
||||
"claude-opus-4.6",
|
||||
"Staging sets iam.session_duration to 1800s; production uses 3600s.",
|
||||
640,
|
||||
90,
|
||||
)),
|
||||
agent(CodingEvent::SubAgentCompleted {
|
||||
agent_id: "sub-1".into(),
|
||||
depth: 1,
|
||||
generation: 1,
|
||||
success: true,
|
||||
turns_used: 2,
|
||||
}),
|
||||
agent(CodingEvent::RouteFailover {
|
||||
from: "anthropic/claude-opus-4.6".into(),
|
||||
to: "openai/gpt-5.4".into(),
|
||||
attempt: 1,
|
||||
error: ErrorData::new(ErrorKind::Llm, "rate limited: retry after 30s"),
|
||||
usage: TokenUsage {
|
||||
input: 3_600,
|
||||
output: 540,
|
||||
..TokenUsage::default()
|
||||
},
|
||||
cost_usd_micros: None,
|
||||
inference_ms: 4_200,
|
||||
tool_ms: 900,
|
||||
continuation: FailoverContinuation::ContinueTurn,
|
||||
}),
|
||||
agent(CodingEvent::CompactionCompleted {
|
||||
original_turn_count: 20,
|
||||
preserved_turn_count: 6,
|
||||
summary_token_estimate: 500,
|
||||
tracked_file_count: 2,
|
||||
reason: CompactionReason::Threshold,
|
||||
usage: TokenUsage {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
..TokenUsage::default()
|
||||
},
|
||||
cost_usd_micros: None,
|
||||
}),
|
||||
call_started(
|
||||
"write_file",
|
||||
"toolu_04",
|
||||
serde_json::json!({ "file_path": "reports/drift.md", "content": report }),
|
||||
),
|
||||
make_envelope(
|
||||
6,
|
||||
"evt-detect-drift-6",
|
||||
tool_completed("toolu_02", "[redis]\nhost = \"redis-staging.internal\"\nport = 6379"),
|
||||
),
|
||||
make_envelope(
|
||||
7,
|
||||
"evt-detect-drift-7",
|
||||
message("I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** — production has 200, staging has 100\n2. **redis.tls** — enabled in production, disabled in staging\n3. **iam.session_duration** — production uses 3600s, staging uses 1800s"),
|
||||
),
|
||||
]
|
||||
call_completed("write_file", "toolu_04", "wrote reports/drift.md"),
|
||||
agent(answer(
|
||||
"gpt-5.4",
|
||||
"I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** — production has 200, staging has 100\n2. **redis.tls** — enabled in production, disabled in staging\n3. **iam.session_duration** — production uses 3600s, staging uses 1800s\n\nThe report is in `reports/drift.md`.",
|
||||
1_500,
|
||||
260,
|
||||
)),
|
||||
agent(CodingEvent::ProcessingEnd),
|
||||
];
|
||||
events
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, body)| {
|
||||
let seq = u32::try_from(index + 1).expect("the demo stream is short");
|
||||
make_envelope(seq, &format!("evt-detect-drift-{seq}"), body)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The demo run's projection: each stage as `stages()` lists it, and the
|
||||
/// agent stage carrying the coding agent's fold of `stage_events()`.
|
||||
pub(super) fn run_state() -> fabro_types::RunProjection {
|
||||
use fabro_types::{
|
||||
EventBody, Graph, RunProjection, RunProvenance, RunSpec, StageTiming, WorkflowSettings,
|
||||
first_event_seq,
|
||||
};
|
||||
use pebble_coding_agent::projection::SessionProjection;
|
||||
|
||||
let created_at = ts("2026-03-06T14:30:00Z");
|
||||
let spec = RunSpec {
|
||||
run_id: demo_run_id(1),
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("drift-remediation"),
|
||||
graph_source: Some(super::DEMO_GRAPH_DOT.to_string()),
|
||||
workflow_slug: Some("implement".to_string()),
|
||||
workflow_version_id: None,
|
||||
target: None,
|
||||
automation: None,
|
||||
source_directory: Some("/demo/api-server".to_string()),
|
||||
labels: HashMap::new(),
|
||||
provenance: RunProvenance {
|
||||
server: None,
|
||||
client: None,
|
||||
subject: DEMO_PRINCIPAL.clone(),
|
||||
},
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
let mut projection = RunProjection::new(
|
||||
"Detect and fix environment drift".to_string(),
|
||||
spec,
|
||||
created_at,
|
||||
);
|
||||
for (index, stage) in stages().into_iter().enumerate() {
|
||||
let seq = u32::try_from(index + 1).expect("the demo has a handful of stages");
|
||||
let entry =
|
||||
projection.stage_entry(stage.id.node_id(), stage.id.visit(), first_event_seq(seq));
|
||||
entry.handler = Some(stage.handler);
|
||||
entry.state = stage.status;
|
||||
entry.started_at = stage.started_at;
|
||||
entry.timing = stage.wall_time_ms.map(StageTiming::wall_only);
|
||||
}
|
||||
let mut agent = SessionProjection::new();
|
||||
for envelope in stage_events() {
|
||||
if let EventBody::Agent(props) = &envelope.event.body {
|
||||
agent.apply(&props.event);
|
||||
}
|
||||
}
|
||||
let detect = projection.stage_entry("detect-drift", 1, first_event_seq(1));
|
||||
detect.agent = Some(agent);
|
||||
projection
|
||||
}
|
||||
|
||||
pub(super) fn billing() -> RunBilling {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
|||
.route("/runs/{id}", get(demo::get_run_status))
|
||||
.route("/runs/{id}/questions", get(demo::get_questions_stub))
|
||||
.route("/runs/{id}/questions/{qid}/answer", post(demo::answer_stub))
|
||||
.route("/runs/{id}/state", get(not_implemented))
|
||||
.route("/runs/{id}/state", get(demo::get_run_state))
|
||||
.route("/runs/{id}/logs", get(not_implemented))
|
||||
.route(
|
||||
"/runs/{id}/events",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ async fn demo_stage_events_default_returns_all_fixture_events_with_no_more() {
|
|||
let body = get_json(&app, "/api/v1/runs/run-1/stages/detect-drift@1/events").await;
|
||||
let data = body["data"].as_array().expect("data is an array");
|
||||
|
||||
assert_eq!(data.len(), 7, "all seven fixture events should be returned");
|
||||
assert_eq!(data.len(), 24, "every fixture event should be returned");
|
||||
assert_eq!(body["meta"]["has_more"], false);
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ async fn demo_stage_events_limit_one_signals_has_more() {
|
|||
async fn demo_stage_events_since_seq_filters_out_earlier_events() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
|
||||
// The fixture seqs are 1..=7. since_seq=4 should skip the first three.
|
||||
// The fixture seqs are 1..=24. since_seq=4 should skip the first three.
|
||||
let body = get_json(
|
||||
&app,
|
||||
"/api/v1/runs/run-1/stages/detect-drift@1/events?since_seq=4",
|
||||
|
|
@ -65,11 +65,49 @@ async fn demo_stage_events_since_seq_filters_out_earlier_events() {
|
|||
.await;
|
||||
let data = body["data"].as_array().expect("data is an array");
|
||||
|
||||
assert_eq!(data.len(), 4);
|
||||
assert_eq!(data.len(), 21);
|
||||
let seqs: Vec<u64> = data
|
||||
.iter()
|
||||
.map(|envelope| envelope["seq"].as_u64().expect("seq is a number"))
|
||||
.collect();
|
||||
assert_eq!(seqs, vec![4, 5, 6, 7]);
|
||||
assert_eq!(seqs, (4..=24).collect::<Vec<u64>>());
|
||||
assert_eq!(body["meta"]["has_more"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_run_state_carries_the_agent_stages_fold() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
|
||||
let body = get_json(&app, "/api/v1/runs/run-1/state").await;
|
||||
let stage = &body["stages"]["detect-drift@1"];
|
||||
assert_eq!(stage["handler"], "agent");
|
||||
let agent = &stage["agent"];
|
||||
assert_eq!(agent["root_session_id"], "ses_demo_detect_drift");
|
||||
assert_eq!(
|
||||
agent["route"]["model"], "gpt-5.4",
|
||||
"the route after the failover"
|
||||
);
|
||||
assert_eq!(agent["activity"], "idle");
|
||||
assert_eq!(
|
||||
agent["mcp_servers"]["github"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(agent["mcp_servers"]["github"]["invoked"], true);
|
||||
assert_eq!(
|
||||
agent["mcp_servers"]["atlassian"]["error"],
|
||||
"auth failed: the API token has expired"
|
||||
);
|
||||
assert_eq!(agent["skills"]["activated"][0]["name"], "drift-triage");
|
||||
assert_eq!(agent["subagents"][0]["status"]["status"], "completed");
|
||||
assert_eq!(agent["failovers"][0]["to"], "openai/gpt-5.4");
|
||||
assert_eq!(agent["compactions"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
agent["files_touched"],
|
||||
serde_json::json!(["reports/drift.md"])
|
||||
);
|
||||
assert!(agent.get("pending_writes").is_none());
|
||||
assert!(body["stages"]["apply-changes@2"]["agent"].is_null());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue