Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "git-ai"
version = "1.5.13"
version = "1.5.14"
edition = "2024"
default-run = "git-ai"

Expand Down
7 changes: 7 additions & 0 deletions src/commands/checkpoint_agent/bash_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,13 +362,20 @@ pub fn classify_tool(agent: Agent, tool_name: &str) -> ToolClass {
"Shell" => ToolClass::Bash,
_ => ToolClass::Skip,
},
Agent::CodeBuddy => match tool_name {
"Write" | "write_to_file" | "Edit" | "replace_in_file" | "MultiEdit"
| "multi_edit" | "NotebookEdit" | "notebook_edit" => ToolClass::FileEdit,
"Bash" => ToolClass::Bash,
_ => ToolClass::Skip,
},
Comment on lines +365 to +370

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Asymmetric casing for Bash tool classification vs file-edit tools

The CodeBuddy classify_tool arm at src/commands/checkpoint_agent/bash_tool.rs:365-370 includes both PascalCase and snake_case variants for file-edit tools ("Write" | "write_to_file" | "Edit" | "replace_in_file" | "MultiEdit" | "multi_edit" | "NotebookEdit" | "notebook_edit"), but only PascalCase "Bash" for the bash tool — no "bash" lowercase variant. If CodeBuddy can send tool names in snake_case (as suggested by write_to_file, replace_in_file, multi_edit, notebook_edit), then a lowercase "bash" would be classified as ToolClass::Skip instead of ToolClass::Bash, causing bash-based file changes to be missed by the stat-diff system. This depends on what tool names CodeBuddy actually emits, which I cannot verify from the codebase alone.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}

/// Supported AI agents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Claude,
CodeBuddy,
Gemini,
ContinueCli,
Droid,
Expand Down
213 changes: 213 additions & 0 deletions src/commands/checkpoint_agent/presets/codebuddy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
use super::parse;
use super::{
AgentPreset, ParsedHookEvent, PostBashCall, PostFileEdit, PreBashCall, PreFileEdit,
PresetContext, StreamFormat, StreamSource,
};
use crate::authorship::authorship_log_serialization::generate_session_id;
use crate::authorship::working_log::AgentId;
use crate::commands::checkpoint_agent::bash_tool::{self, Agent, ToolClass};
use crate::error::GitAiError;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

pub struct CodeBuddyPreset;

impl AgentPreset for CodeBuddyPreset {
fn parse(&self, hook_input: &str, trace_id: &str) -> Result<Vec<ParsedHookEvent>, GitAiError> {
let data: serde_json::Value = serde_json::from_str(hook_input)
.map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?;

let cwd = parse::required_str(&data, "cwd")?;
let transcript_path = parse::required_str(&data, "transcript_path")?;

let session_id = parse::optional_str(&data, "session_id")
.map(|s| s.to_string())
.unwrap_or_else(|| {
parse::required_file_stem(&data, "transcript_path")
.unwrap_or_else(|_| "unknown".to_string())
});

let tool_name = parse::optional_str_multi(&data, &["tool_name", "toolName"]);
let hook_event = parse::optional_str_multi(&data, &["hook_event_name", "hookEventName"]);
let tool_use_id =
parse::str_or_default_multi(&data, &["tool_use_id", "toolUseId"], "bash");

let is_bash = tool_name
.map(|n| bash_tool::classify_tool(Agent::CodeBuddy, n) == ToolClass::Bash)
.unwrap_or(false);

let context = PresetContext {
agent_id: AgentId {
tool: "codebuddy".to_string(),
id: session_id.clone(),
model: crate::streams::model_extraction::extract_model(
Path::new(transcript_path),
crate::streams::sweep::StreamFormat::ClaudeJsonl,
None,
)
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string()),
},
external_session_id: session_id.clone(),
trace_id: trace_id.to_string(),
cwd: PathBuf::from(cwd),
metadata: HashMap::from([(
"transcript_path".to_string(),
transcript_path.to_string(),
)]),
};

let transcript_path_buf = PathBuf::from(transcript_path);
let stream_source = Some(StreamSource {
path: transcript_path_buf,
format: StreamFormat::ClaudeJsonl,
session_id: generate_session_id(&session_id, "codebuddy"),
external_session_id: session_id.clone(),
external_parent_session_id: None,
});
Comment on lines +62 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 CodeBuddy not registered in ALL_AGENT_TYPES — no sweep/discovery support

CodeBuddy is not added to ALL_AGENT_TYPES in src/streams/agent.rs:179-192 or the get_agent function (src/streams/agent.rs:197-214), and there is no corresponding stream agent implementation in src/streams/agents/. This means the sweep coordinator will not independently discover CodeBuddy sessions for background transcript ingestion. Transcript streaming will only work when triggered inline via the stream_source field in checkpoint hook events. This is consistent with firebender (which also lacks sweep support), but differs from most other agents (Claude, Cursor, Codex, etc.) that have full sweep implementations. If background sweep is desired for CodeBuddy, a stream agent would need to be added.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


let event = match (hook_event, is_bash) {
(Some("PreToolUse"), true) => ParsedHookEvent::PreBashCall(PreBashCall {
context,
tool_use_id: tool_use_id.to_string(),
}),
(Some("PreToolUse"), false) => ParsedHookEvent::PreFileEdit(PreFileEdit {
context,
file_paths: parse::file_paths_from_tool_input(&data, cwd),
dirty_files: None,
tool_use_id: Some(tool_use_id.to_string()),
}),
(_, true) => ParsedHookEvent::PostBashCall(PostBashCall {
context,
tool_use_id: tool_use_id.to_string(),
stream_source,
}),
(_, false) => ParsedHookEvent::PostFileEdit(PostFileEdit {
context,
file_paths: parse::file_paths_from_tool_input(&data, cwd),
dirty_files: None,
stream_source,
tool_use_id: Some(tool_use_id.to_string()),
}),
};

Ok(vec![event])
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::commands::checkpoint_agent::presets::*;
use serde_json::json;

fn make_hook_input(event: &str, tool: &str) -> String {
json!({
"transcript_path": "/home/user/.codebuddy/sessions/abc123.jsonl",
"cwd": "/home/user/project",
"hook_event_name": event,
"tool_name": tool,
"session_id": "sess-1",
"tool_use_id": "tu-1",
"tool_input": {"file_path": "src/main.rs"}
})
.to_string()
}

#[test]
fn test_pre_file_edit() {
let input = make_hook_input("PreToolUse", "Write");
let events = CodeBuddyPreset.parse(&input, "t_test123456789a").unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ParsedHookEvent::PreFileEdit(e) => {
assert_eq!(e.context.agent_id.tool, "codebuddy");
assert_eq!(e.context.external_session_id, "sess-1");
assert_eq!(e.context.trace_id, "t_test123456789a");
assert_eq!(e.context.cwd, PathBuf::from("/home/user/project"));
assert_eq!(
e.file_paths,
vec![PathBuf::from("/home/user/project/src/main.rs")]
);
}
_ => panic!("Expected PreFileEdit"),
}
}

#[test]
fn test_post_file_edit() {
let input = make_hook_input("PostToolUse", "Write");
let events = CodeBuddyPreset.parse(&input, "t_test123456789a").unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ParsedHookEvent::PostFileEdit(e) => {
assert_eq!(e.context.agent_id.tool, "codebuddy");
assert_eq!(
e.file_paths,
vec![PathBuf::from("/home/user/project/src/main.rs")]
);
assert!(e.stream_source.is_some());
if let Some(ts) = &e.stream_source {
assert_eq!(ts.format, StreamFormat::ClaudeJsonl);
assert_eq!(
ts.session_id,
generate_session_id("sess-1", "codebuddy")
);
assert_eq!(ts.external_session_id, "sess-1");
}
}
_ => panic!("Expected PostFileEdit"),
}
}

#[test]
fn test_pre_bash_call() {
let input = make_hook_input("PreToolUse", "Bash");
let events = CodeBuddyPreset.parse(&input, "t_test123456789a").unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ParsedHookEvent::PreBashCall(e) => {
assert_eq!(e.context.agent_id.tool, "codebuddy");
assert_eq!(e.tool_use_id, "tu-1");
}
_ => panic!("Expected PreBashCall"),
}
}

#[test]
fn test_post_bash_call() {
let input = make_hook_input("PostToolUse", "Bash");
let events = CodeBuddyPreset.parse(&input, "t_test123456789a").unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ParsedHookEvent::PostBashCall(e) => {
assert_eq!(e.context.agent_id.tool, "codebuddy");
assert_eq!(e.tool_use_id, "tu-1");
}
_ => panic!("Expected PostBashCall"),
}
}

#[test]
fn test_session_id_from_filename() {
let input = json!({
"transcript_path": "/home/user/.codebuddy/sessions/cb947e5b-246e-4253-a953-631f7e464c6b.jsonl",
"cwd": "/home/user/project",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": {"file_path": "src/main.rs"}
})
.to_string();
let events = CodeBuddyPreset.parse(&input, "t_test123456789a").unwrap();
match &events[0] {
ParsedHookEvent::PostFileEdit(e) => {
assert_eq!(
e.context.external_session_id,
"cb947e5b-246e-4253-a953-631f7e464c6b"
);
}
_ => panic!("Expected PostFileEdit"),
}
}
}
2 changes: 2 additions & 0 deletions src/commands/checkpoint_agent/presets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod agent_v1;
mod ai_tab;
mod amp;
mod claude;
mod codebuddy;
mod codex;
mod continue_cli;
mod cursor;
Expand Down Expand Up @@ -148,6 +149,7 @@ pub trait AgentPreset {
pub fn resolve_preset(name: &str) -> Result<Box<dyn AgentPreset>, GitAiError> {
match name {
"claude" => Ok(Box::new(claude::ClaudePreset)),
"codebuddy" => Ok(Box::new(codebuddy::CodeBuddyPreset)),
"codex" => Ok(Box::new(codex::CodexPreset)),
"gemini" => Ok(Box::new(gemini::GeminiPreset)),
"windsurf" => Ok(Box::new(windsurf::WindsurfPreset)),
Expand Down
1 change: 1 addition & 0 deletions src/commands/checkpoint_agent/presets/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub fn file_paths_from_tool_input(data: &Value, cwd: &str) -> Vec<PathBuf> {
// Try single file_path field
if let Some(path) = tool_input
.get("file_path")
.or_else(|| tool_input.get("filePath"))
.or_else(|| tool_input.get("filepath"))
.or_else(|| tool_input.get("path"))
.and_then(|v| v.as_str())
Expand Down
Loading