feat: Enhance OAuth module and OpenRouter client functionality

- Updated OAuth module to include unused imports for better clarity.
- Refactored OpenRouterClient to improve chat functionality and added support for tools in chat requests.
- Modified internet tools (Download, Fetch, Search) to use a more flexible internet mode check.
- Introduced new Bash tools for managing background jobs (BashOutput, BashKill).
- Enhanced workflow tool to parse and execute workflow scripts with arguments.
- Updated status and workflow views to reflect new agent and findings counts.
- Added IPC protocol definitions for client requests and state payloads.
This commit is contained in:
asepharyana
2026-07-11 20:21:59 +07:00
parent c1ad206a00
commit 802d9677ae
42 changed files with 1423 additions and 925 deletions
+392 -43
View File
@@ -1,10 +1,17 @@
use std::collections::VecDeque;
use crate::app::harness::Verdict;
use sha2::Digest;
use crate::app::mode::ModeKind;
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use crate::dto::chat::message::ChatMessage;
use crate::app::review::{should_trigger_review, trigger_review};
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind};
use crate::dto::chat::message::{ChatMessage, Role};
const MAX_AGENT_STEPS: usize = 40;
#[derive(Debug, Clone)]
#[expect(dead_code)]
pub enum Action {
Quit,
ForceQuit,
@@ -22,6 +29,7 @@ pub enum Action {
OpenOverlay(Overlay),
CloseOverlay,
ToggleYoloArm,
CycleAgentMode,
ToolResult {
tool_call_id: String,
output: String,
@@ -44,22 +52,29 @@ pub enum Action {
LessonImport {
path: String,
},
#[expect(dead_code)]
RecordUsage {
tokens_in: u64,
tokens_out: u64,
duration_ms: u64,
},
#[expect(dead_code)]
RecordReviewTokens {
tokens: u64,
},
SaveSession,
ResumeSession,
RefreshSessions,
}
pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action {
Action::Quit => {
save_current_session(state);
state.quit = true;
}
Action::ForceQuit => {
save_current_session(state);
state.quit = true;
}
Action::SwitchMode(mode) => {
@@ -87,32 +102,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.dirty = true;
}
Action::SubmitInput(text) => {
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::user(text.clone()));
let api_key = state.settings.api_key.clone();
let model = state.settings.model.clone();
let msgs = rt.messages.clone();
let pending = state.pending_api_response.clone();
if let Some(key) = api_key {
if !key.is_empty() {
std::thread::spawn(move || {
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
match client.chat(&msgs) {
Ok(response) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(response);
}
}
Err(e) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(format!("Error: {}", e));
}
}
}
});
}
}
state.input.submit();
let text = text.trim().to_string();
if text.is_empty() {
state.dirty = true;
return;
}
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::user(text));
}
spawn_turn(state);
state.dirty = true;
}
Action::InsertChar(c) => {
@@ -164,6 +164,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.misc.yolo_armed = !state.misc.yolo_armed;
state.dirty = true;
}
Action::CycleAgentMode => {
let next = state.mode.cycle();
state.set_mode(next);
let toast = Toast::new(ToastKind::Info, format!("Mode: {}", state.mode.name()));
state.push_toast(toast);
state.dirty = true;
}
Action::ToolResult {
tool_call_id,
output,
@@ -269,26 +276,117 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}
state.dirty = true;
}
Action::SaveSession => {
save_current_session(state);
state.push_toast(Toast::new(ToastKind::Success, "session saved".to_string()));
state.dirty = true;
}
Action::ResumeSession => {
let base = state.store_base_dir();
let sessions = crate::model::session::Session::list(&base);
let target = sessions.into_iter()
.filter(|s| s.id != state.session_id)
.max_by_key(|s| s.updated_at);
if let Some(session) = target {
let conv_path = session.conversation_path(&base);
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
std::fs::read_to_string(&conv_path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default();
state.session_id = session.id.clone();
state.session_dir = session.session_dir(&base);
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
state.session_dir.clone(),
));
state.transcript_cache.messages.clear();
if let Some(ref mut rt) = state.session_runtime {
for msg in loaded_msgs {
let display = ChatMessageDisplay::new(
msg.role.clone(),
msg.content.clone().unwrap_or_default(),
);
state.transcript_cache.messages.push(display);
rt.push_message(msg);
}
}
state.push_toast(Toast::new(ToastKind::Success,
format!("resumed session: {}", session.title)));
} else {
state.push_toast(Toast::new(ToastKind::Info,
"no other sessions to resume".to_string()));
}
state.dirty = true;
}
Action::RefreshSessions => {
let base = state.store_base_dir();
state.sessions = crate::model::session::Session::list(&base);
state.dirty = true;
}
Action::Tick => {
let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms);
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() {
guard.take()
} else {
None
let events: Vec<TurnEvent> = {
if let Ok(mut q) = state.turn_events.lock() {
q.drain(..).collect()
} else {
Vec::new()
}
};
if let Some(response) = api_response {
if let Some(ref mut rt) = state.session_runtime {
if response.starts_with("Error:") {
let toast = crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
response,
);
state.push_toast(toast);
} else {
rt.push_message(ChatMessage::assistant(Some(response)));
let mut turn_finished = false;
for event in events {
match event {
TurnEvent::AssistantMessage(msg) => {
let display_content = msg.content.clone().unwrap_or_default();
if !display_content.is_empty() {
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content));
}
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(msg);
}
}
TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error } => {
state.push_transcript(ChatMessageDisplay::new(
Role::Tool,
format!("{}: {}", tool_name, output),
));
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
rt.tool_call_results.push(crate::app::state::runtime::ToolCallResult {
tool_call_id,
tool_name,
output,
is_error,
duration_ms: 0,
});
}
}
TurnEvent::SystemNote { kind, message } => {
if kind == "edits" {
if let Some(ref mut rt) = state.session_runtime {
if let Ok(count) = message.parse::<u32>() {
rt.edit_count += count;
}
}
if should_trigger_review(state, Origin::Main) {
let _ = trigger_review(state);
}
}
state.push_toast(Toast::new(ToastKind::Info, message));
}
TurnEvent::Error(msg) => {
state.push_toast(Toast::new(ToastKind::Error, msg));
turn_finished = true;
}
TurnEvent::Done => {
turn_finished = true;
}
}
}
if turn_finished {
maybe_trigger_review(state);
}
if turn_finished || state.dirty {
state.dirty = true;
}
}
@@ -306,3 +404,254 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}
}
}
fn spawn_turn(state: &AppStateRest) {
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard
} else {
return;
};
if in_flight {
return;
}
let messages = state
.session_runtime
.as_ref()
.map(|rt| rt.messages.clone())
.unwrap_or_default();
if messages.is_empty() {
return;
}
let api_key = state.settings.api_key.clone().unwrap_or_default();
if api_key.is_empty() {
return;
}
let model = state.settings.model.clone();
let mut tools = crate::tool::all_tools();
tools.extend(state.mcp_manager.as_tools());
let tool_defs = crate::tool::tool_defs(&tools);
let ctx = state.tool_ctx();
let mode = state.mode;
let edit_log_path = state.edit_log.path.clone();
let session_id = state.session_id.clone();
let turn_events = state.turn_events.clone();
let in_flight_flag = state.turn_in_flight.clone();
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
*in_flight_flag.lock().unwrap() = true;
let events_q = turn_events.clone();
std::thread::spawn(move || {
let tc = TurnCtx {
client: crate::service::openrouter::OpenRouterClient::new(api_key, model),
tdefs: tool_defs,
tools,
ctx,
mode,
workspace_roots,
edit_log_path,
session_id,
};
let result = run_agent_turn(tc, &messages, &events_q);
if let Err(e) = result {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error(e.to_string()));
}
}
if let Ok(mut flag) = in_flight_flag.lock() {
*flag = false;
}
});
}
struct TurnCtx {
client: crate::service::openrouter::OpenRouterClient,
tdefs: Vec<crate::dto::openrouter::request::ToolDef>,
tools: Vec<Box<dyn crate::tool::Tool>>,
ctx: crate::tool::ToolCtx,
mode: AgentMode,
workspace_roots: Vec<std::path::PathBuf>,
edit_log_path: std::path::PathBuf,
session_id: String,
}
fn run_agent_turn(
tc: TurnCtx,
messages: &[ChatMessage],
events_q: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> anyhow::Result<()> {
let mut msgs = messages.to_vec();
let mut edits_this_turn = 0u32;
for _step in 0..MAX_AGENT_STEPS {
let response = tc
.client
.chat_with_tools(&msgs, Some(tc.tdefs.clone()))?;
let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
let content = response.content.clone().unwrap_or_default();
if has_tool_calls {
let tool_calls = response.tool_calls.clone().unwrap_or_default();
msgs.push(response);
for tool_call in tool_calls {
let tool_name = tool_call.function.name.clone();
let args = crate::dto::chat::tool::sanitize_tool_arguments(
&tool_call.function.arguments,
);
let ws_roots: Vec<&std::path::Path> =
tc.workspace_roots.iter().map(|p| p.as_path()).collect();
let verdict = crate::app::harness::Harness::gate_tool_call(
&tool_name,
&args,
&tc.mode,
&ws_roots,
);
let is_edit_tool = tool_name == "write" || tool_name == "edit";
let (output, is_error, is_edit) = match verdict {
Verdict::Allow => match execute_one_tool(
&tc.tools,
&tc.ctx,
&tool_name,
&args,
&tc.edit_log_path,
&tc.session_id,
) {
Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false),
},
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
Verdict::Escalate => (
"Tool requires approval. Switch to Auto mode or provide explicit approval."
.to_string(),
true,
false,
),
};
if is_edit {
edits_this_turn += 1;
}
{
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::ToolResult {
tool_call_id: tool_call.id.clone(),
tool_name: tool_name.clone(),
output: output.clone(),
is_error,
});
}
}
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
msgs.push(tool_msg);
}
} else {
if !content.is_empty() {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::AssistantMessage(response));
}
}
break;
}
}
if edits_this_turn > 0 {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "edits".to_string(),
message: edits_this_turn.to_string(),
});
}
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Done);
}
Ok(())
}
fn execute_one_tool(
tools: &[Box<dyn crate::tool::Tool>],
ctx: &crate::tool::ToolCtx,
name: &str,
args: &serde_json::Value,
edit_log_path: &std::path::Path,
session_id: &str,
) -> anyhow::Result<String> {
for tool in tools {
if tool.name() == name {
let result = tool.run(ctx, args)?;
if name == "write" || name == "edit" {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let content_sha256 = {
let content = args.get("content").or_else(|| args.get("new"));
let hash = sha2::Sha256::digest(
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
);
format!("{:x}", hash)
};
let entry = crate::model::editlog::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: name.to_string(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta: result.len() as i64,
origin: ctx.origin.tag(),
session_id: session_id.to_string(),
};
let mut el = crate::model::editlog::EditLog::new(edit_log_path);
el.append(entry).ok();
}
return Ok(result);
}
}
anyhow::bail!("tool not found: {}", name)
}
fn maybe_trigger_review(state: &mut AppStateRest) {
if !state.settings.review_enabled {
return;
}
let edit_count = state
.session_runtime
.as_ref()
.map(|rt| rt.edit_count)
.unwrap_or(0);
if edit_count == 0 {
return;
}
state.push_toast(Toast::new(
ToastKind::Info,
format!("{} file(s) modified this turn. Review available.", edit_count),
));
}
fn save_current_session(state: &AppStateRest) {
let base = state.store_base_dir();
let session = crate::model::session::Session::new(
state.session_id.clone(),
"session".to_string(),
);
let _ = session.save(&base);
if let Some(ref rt) = state.session_runtime {
let conv_path = session.conversation_path(&base);
if let Ok(data) = serde_json::to_string(&rt.messages) {
let _ = std::fs::write(&conv_path, data);
}
}
}