Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ concurrency:
env:
CARGO_TERM_COLOR: always

# Least privilege for every job: CI only reads the repo. Declared here rather
# than per-job so a new job cannot silently inherit a wider repository default.
permissions:
contents: read

jobs:
test:
# Both platforms matter: bullpen-sandbox confines shell commands with
Expand Down Expand Up @@ -45,6 +50,22 @@ jobs:
- name: Test
run: cargo test --workspace

fmt:
name: fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Install pinned toolchain
run: rustup toolchain install --no-self-update

# No rust-cache: rustfmt parses sources and never builds, so there is
# nothing to restore and the cache round-trip would cost more than the job.
- name: Format
run: cargo fmt --all --check

clippy:
name: clippy
runs-on: ubuntu-latest
Expand Down
27 changes: 19 additions & 8 deletions crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,7 @@ impl Agent {
tool_use_id: id.clone(),
name: name.clone(),
input: input.clone(),
replay_safe: self
.registry
.get(name)
.is_some_and(|t| t.replay_safe()),
replay_safe: self.registry.get(name).is_some_and(|t| t.replay_safe()),
}),
_ => None,
})
Expand Down Expand Up @@ -346,7 +343,12 @@ impl Agent {
(output, is_error)
}

async fn run_tool(&self, name: &str, call_id: &str, input: serde_json::Value) -> (String, bool) {
async fn run_tool(
&self,
name: &str,
call_id: &str,
input: serde_json::Value,
) -> (String, bool) {
let Some(tool) = self.registry.get(name) else {
return (format!("unknown tool: {name}"), true);
};
Expand Down Expand Up @@ -526,7 +528,9 @@ mod tests {
let mut agent = agent(provider);
let out = agent.send("go").await.unwrap();
assert_eq!(out, "recovered");
let ContentBlock::ToolResult { is_error, content, .. } = &agent.messages()[2].content[0]
let ContentBlock::ToolResult {
is_error, content, ..
} = &agent.messages()[2].content[0]
else {
panic!("expected tool result");
};
Expand Down Expand Up @@ -739,7 +743,11 @@ mod tests {
self.0.lock().unwrap().push("results".into());
Ok(())
}
async fn run_finished(&mut self, outcome: RunOutcome, _: Usage) -> Result<(), JournalError> {
async fn run_finished(
&mut self,
outcome: RunOutcome,
_: Usage,
) -> Result<(), JournalError> {
self.0
.lock()
.unwrap()
Expand Down Expand Up @@ -824,6 +832,9 @@ mod tests {
Event::TurnDone { .. } => "done",
});
}
assert_eq!(kinds, vec!["text", "tool_start", "tool_end", "text", "done"]);
assert_eq!(
kinds,
vec!["text", "tool_start", "tool_end", "text", "done"]
);
}
}
4 changes: 3 additions & 1 deletion crates/auth/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ impl CodexAuth {
])
.await?;
// A refresh response may omit a new refresh token; keep the old one.
if let Credential::Oauth { refresh_token: rt, .. } = &mut credential
if let Credential::Oauth {
refresh_token: rt, ..
} = &mut credential
&& rt.is_empty()
{
*rt = refresh_token.to_string();
Expand Down
16 changes: 12 additions & 4 deletions crates/auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,13 @@ mod tests {
let mut file = AuthFile::load(&path).unwrap();
assert!(file.get("openrouter").is_none());

file.set("openrouter", Credential::ApiKey { key: "sk-or-x".into() })
.unwrap();
file.set(
"openrouter",
Credential::ApiKey {
key: "sk-or-x".into(),
},
)
.unwrap();
file.set(
"codex",
Credential::Oauth {
Expand All @@ -153,7 +158,9 @@ mod tests {
let reloaded = AuthFile::load(&path).unwrap();
assert_eq!(
reloaded.get("openrouter"),
Some(&Credential::ApiKey { key: "sk-or-x".into() })
Some(&Credential::ApiKey {
key: "sk-or-x".into()
})
);
assert!(matches!(
reloaded.get("codex"),
Expand All @@ -168,7 +175,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let mut file = AuthFile::load(&path).unwrap();
file.set("x", Credential::ApiKey { key: "k".into() }).unwrap();
file.set("x", Credential::ApiKey { key: "k".into() })
.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
Expand Down
19 changes: 15 additions & 4 deletions crates/auth/src/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ pub async fn capture_code(listener: TcpListener) -> Result<String, AuthError> {
let mut buf = vec![0u8; 8 * 1024];
let n = stream.read(&mut buf).await?;
let request = String::from_utf8_lossy(&buf[..n]);
let Some(target) = request.lines().next().and_then(|l| l.split_whitespace().nth(1))
let Some(target) = request
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
else {
continue;
};
Expand All @@ -91,9 +94,15 @@ pub async fn capture_code(listener: TcpListener) -> Result<String, AuthError> {
continue;
}
let (status, page) = if code.is_some() {
("200 OK", "bullpen is connected to OpenRouter. You can close this tab.")
(
"200 OK",
"bullpen is connected to OpenRouter. You can close this tab.",
)
} else {
("200 OK", "Authorization was not completed. You can close this tab.")
(
"200 OK",
"Authorization was not completed. You can close this tab.",
)
};
let _ = respond(&mut stream, status, page).await;

Expand Down Expand Up @@ -197,7 +206,9 @@ mod tests {

// A stray request first — must not terminate the wait.
let mut s = tokio::net::TcpStream::connect(addr).await.unwrap();
s.write_all(b"GET /favicon.ico HTTP/1.1\r\n\r\n").await.unwrap();
s.write_all(b"GET /favicon.ico HTTP/1.1\r\n\r\n")
.await
.unwrap();
let mut buf = Vec::new();
let _ = s.read_to_end(&mut buf).await;

Expand Down
5 changes: 4 additions & 1 deletion crates/auth/src/pkce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ impl Pkce {
getrandom::fill(&mut bytes).expect("os rng");
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = challenge_s256(&verifier);
Self { verifier, challenge }
Self {
verifier,
challenge,
}
}
}

Expand Down
33 changes: 20 additions & 13 deletions crates/cli/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,11 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App) {
.filter(|r| r.status == AgentStatus::Working)
.count();
let line = Line::from(vec![
Span::styled(" bullpen agents ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!(
"· {} sessions · {working} working",
app.rows.len()
)),
Span::styled(
" bullpen agents ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!("· {} sessions · {working} working", app.rows.len())),
]);
f.render_widget(Paragraph::new(line), area);
}
Expand All @@ -245,9 +245,17 @@ fn draw_list(f: &mut Frame, area: Rect, app: &App) {
let selected = i == app.selected;
let marker = if selected { "› " } else { " " };
let s = &row.session;
let title = if s.title.is_empty() { "(untitled)" } else { &s.title };
let title = if s.title.is_empty() {
"(untitled)"
} else {
&s.title
};
let title: String = title.chars().take(48).collect();
let child = if s.parent_session_id.is_some() { " ↳" } else { "" };
let child = if s.parent_session_id.is_some() {
" ↳"
} else {
""
};
let text = format!(
"{marker}{} {:<10} {:>6}/{:<6} {title}{child}",
&s.id[..8],
Expand Down Expand Up @@ -280,10 +288,7 @@ fn draw_input(f: &mut Frame, area: Rect, app: &App) {
};
let block = Block::default().borders(Borders::ALL).title(title);
let content = if app.input.is_empty() {
Span::styled(
"describe a task…",
Style::default().fg(Color::DarkGray),
)
Span::styled("describe a task…", Style::default().fg(Color::DarkGray))
} else {
Span::raw(app.input.as_str())
};
Expand Down Expand Up @@ -330,7 +335,9 @@ fn draw_peek(f: &mut Frame, app: &App) {
.borders(Borders::ALL)
.title(" peek · Esc to close ");
f.render_widget(
Paragraph::new(lines).block(block).wrap(Wrap { trim: false }),
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
Expand Down Expand Up @@ -402,7 +409,7 @@ mod tests {
vec![
// Working, newest first
"work-b", "work-a", // then Failed
"fail", // then Completed, newest first
"fail", // then Completed, newest first
"done-new", "done-old",
]
);
Expand Down
37 changes: 18 additions & 19 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,7 @@ enum LoginProvider {
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "warn".into()),
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.with_writer(std::io::stderr)
.init();
Expand Down Expand Up @@ -379,19 +378,15 @@ async fn run(
let (session, provider_kind, model) = match &resume {
Some(prefix) => {
let session = store.resolve_session(prefix)?;
let kind = ProviderKind::from_name(&session.provider).with_context(|| {
format!("session uses unknown provider `{}`", session.provider)
})?;
let kind = ProviderKind::from_name(&session.provider)
.with_context(|| format!("session uses unknown provider `{}`", session.provider))?;
let model = session.model.clone();
(session, kind, model)
}
None => {
let model = model.unwrap_or_else(|| provider_kind.default_model());
let session = store.create_session(
&cwd.display().to_string(),
provider_kind.name(),
&model,
)?;
let session =
store.create_session(&cwd.display().to_string(), provider_kind.name(), &model)?;
(session, provider_kind, model)
}
};
Expand Down Expand Up @@ -432,9 +427,7 @@ async fn run(
eprintln!("✗ {name} failed")
}
// Assistant text streams to stdout via the delta sink below.
Event::AssistantText { .. }
| Event::ToolEnd { .. }
| Event::TurnDone { .. } => {}
Event::AssistantText { .. } | Event::ToolEnd { .. } | Event::TurnDone { .. } => {}
}
}
});
Expand All @@ -457,10 +450,8 @@ async fn run(
// The journal persists every step of the run as it happens (its own
// store handle; WAL makes the two connections safe). If the process
// dies mid-run, the next invocation recovers from the durable state.
let journal = bullpen_harness::StoreJournal::new(
Store::open(&Store::default_path())?,
&session.id,
);
let journal =
bullpen_harness::StoreJournal::new(Store::open(&Store::default_path())?, &session.id);
// The pen: the model can delegate bounded tasks to durable child agents
// (sessions in the same store, resumable and listed like any other).
let mut pen_config = bullpen_harness::PenConfig::new(
Expand Down Expand Up @@ -494,7 +485,11 @@ async fn run(
let _ = streamer.await;

// Record the terminal run status for the dashboard.
let final_status = if result.is_ok() { "completed" } else { "failed" };
let final_status = if result.is_ok() {
"completed"
} else {
"failed"
};
if let Ok(store) = Store::open(&Store::default_path()) {
let _ = store.set_run_status(&session.id, final_status, None);
}
Expand Down Expand Up @@ -554,7 +549,11 @@ fn sessions() -> anyhow::Result<()> {
s.provider,
s.usage.input_tokens,
s.usage.output_tokens,
if s.title.is_empty() { "(untitled)" } else { &s.title },
if s.title.is_empty() {
"(untitled)"
} else {
&s.title
},
child_marker,
);
}
Expand Down
Loading