Skip to content

Commit 5935c7e

Browse files
committed
Defer Claude input during tool calls
1 parent 69014d4 commit 5935c7e

5 files changed

Lines changed: 379 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ from a real chat captured during local debugging.
4040
- Normal streaming completion should close stdin and wait for the Claude CLI
4141
process to exit; force-closing the transport can race Claude's local
4242
transcript flush and break later `--resume` behavior.
43+
- If a follow-up user input arrives while Claude has emitted `tool_use` but not
44+
the matching `tool_result`, accept it in Garyx but defer writing it to Claude
45+
stdin until the tool-result boundary. Sending during that gap can make
46+
Claude's own transcript contain a clipped assistant continuation.
4347
- Claude SDK approval tests need a `can_use_tool` callback, which adds
4448
`--permission-prompt-tool stdio`; changing only `permission_mode` may not
4549
make Claude send `can_use_tool` requests to the SDK.

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ from a real chat captured during local debugging.
4040
- Normal streaming completion should close stdin and wait for the Claude CLI
4141
process to exit; force-closing the transport can race Claude's local
4242
transcript flush and break later `--resume` behavior.
43+
- If a follow-up user input arrives while Claude has emitted `tool_use` but not
44+
the matching `tool_result`, accept it in Garyx but defer writing it to Claude
45+
stdin until the tool-result boundary. Sending during that gap can make
46+
Claude's own transcript contain a clipped assistant continuation.
4347
- Claude SDK approval tests need a `can_use_tool` callback, which adds
4448
`--permission-prompt-tool stdio`; changing only `permission_mode` may not
4549
make Claude send `can_use_tool` requests to the SDK.

garyx-bridge/src/claude_provider.rs

Lines changed: 190 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -467,10 +467,30 @@ fn extract_tool_session_messages(
467467
}
468468
}
469469

470-
fn has_tool_result_blocks(blocks: &[ContentBlock]) -> bool {
470+
fn count_tool_use_blocks(blocks: &[ContentBlock]) -> usize {
471471
blocks
472472
.iter()
473-
.any(|b| matches!(b, ContentBlock::ToolResult(_)))
473+
.filter(|block| matches!(block, ContentBlock::ToolUse(_)))
474+
.count()
475+
}
476+
477+
fn count_tool_result_blocks(blocks: &[ContentBlock]) -> usize {
478+
blocks
479+
.iter()
480+
.filter(|block| matches!(block, ContentBlock::ToolResult(_)))
481+
.count()
482+
}
483+
484+
fn count_user_tool_result_messages(user_msg: &claude_agent_sdk::UserMessage) -> usize {
485+
let block_count = match user_msg.content {
486+
claude_agent_sdk::UserContent::Blocks(ref blocks) => count_tool_result_blocks(blocks),
487+
claude_agent_sdk::UserContent::Text(_) => 0,
488+
};
489+
if block_count > 0 {
490+
block_count
491+
} else {
492+
usize::from(user_msg.tool_use_result.is_some())
493+
}
474494
}
475495

476496
fn metadata_string_map(
@@ -643,9 +663,13 @@ pub struct ClaudeCliProvider {
643663
run_session_map: Mutex<HashMap<String, String>>,
644664
/// User inputs accepted but not yet completed per live run.
645665
run_pending_inputs: Mutex<HashMap<String, VecDeque<PendingAckMarker>>>,
666+
/// User inputs accepted while Claude is between tool_use and tool_result.
667+
run_input_states: Mutex<HashMap<String, RunInputState>>,
646668
/// Last user message per thread, used for auto-recovery replay.
647669
last_messages: Mutex<HashMap<String, String>>,
648670
#[cfg(test)]
671+
test_sent_streaming_inputs: Mutex<Vec<(String, OutboundUserMessage)>>,
672+
#[cfg(test)]
649673
test_run_attempts: Mutex<VecDeque<Result<Option<SdkRunOutcome>, BridgeError>>>,
650674
#[cfg(test)]
651675
test_recorded_session_attempts: Mutex<Vec<Option<String>>>,
@@ -658,6 +682,18 @@ enum PendingAckMarker {
658682
QueuedInput(String),
659683
}
660684

685+
#[derive(Debug, Clone)]
686+
struct PendingClaudeInput {
687+
pending_input_id: String,
688+
outbound: OutboundUserMessage,
689+
}
690+
691+
#[derive(Debug, Default)]
692+
struct RunInputState {
693+
outstanding_tool_uses: usize,
694+
queued_inputs: VecDeque<PendingClaudeInput>,
695+
}
696+
661697
impl ClaudeCliProvider {
662698
/// Create a new provider with the given config.
663699
pub fn new(config: ClaudeCodeConfig) -> Self {
@@ -668,8 +704,11 @@ impl ClaudeCliProvider {
668704
active_runs: Mutex::new(HashMap::new()),
669705
run_session_map: Mutex::new(HashMap::new()),
670706
run_pending_inputs: Mutex::new(HashMap::new()),
707+
run_input_states: Mutex::new(HashMap::new()),
671708
last_messages: Mutex::new(HashMap::new()),
672709
#[cfg(test)]
710+
test_sent_streaming_inputs: Mutex::new(Vec::new()),
711+
#[cfg(test)]
673712
test_run_attempts: Mutex::new(VecDeque::new()),
674713
#[cfg(test)]
675714
test_recorded_session_attempts: Mutex::new(Vec::new()),
@@ -881,13 +920,18 @@ impl ClaudeCliProvider {
881920
.lock()
882921
.await
883922
.insert(run_id.to_owned(), thread_id.to_owned());
923+
self.run_input_states
924+
.lock()
925+
.await
926+
.insert(run_id.to_owned(), RunInputState::default());
884927
}
885928

886929
/// Unregister a run after completion and return handle for cleanup.
887930
async fn unregister_run(&self, run_id: &str) -> (Option<ClaudeRunControl>, Option<String>) {
888931
let run = self.active_runs.lock().await.remove(run_id);
889932
let thread_id = self.run_session_map.lock().await.remove(run_id);
890933
self.run_pending_inputs.lock().await.remove(run_id);
934+
self.run_input_states.lock().await.remove(run_id);
891935
(run, thread_id)
892936
}
893937

@@ -898,6 +942,124 @@ impl ClaudeCliProvider {
898942
}
899943
}
900944

945+
async fn note_tool_uses_started(&self, run_id: &str, count: usize) {
946+
if count == 0 {
947+
return;
948+
}
949+
if let Some(state) = self.run_input_states.lock().await.get_mut(run_id) {
950+
state.outstanding_tool_uses = state.outstanding_tool_uses.saturating_add(count);
951+
}
952+
}
953+
954+
async fn note_tool_results_finished(&self, run_id: &str, count: usize) {
955+
if count == 0 {
956+
return;
957+
}
958+
if let Some(state) = self.run_input_states.lock().await.get_mut(run_id) {
959+
state.outstanding_tool_uses = state.outstanding_tool_uses.saturating_sub(count);
960+
}
961+
}
962+
963+
async fn send_streaming_input_now(
964+
&self,
965+
run_id: &str,
966+
pending_input_id: &str,
967+
outbound: OutboundUserMessage,
968+
) -> bool {
969+
let run = { self.active_runs.lock().await.get(run_id).cloned() };
970+
if let Some(run) = run {
971+
match run.send_user_message(outbound).await {
972+
Ok(()) => return true,
973+
Err(e) => {
974+
self.rollback_pending_input(run_id, pending_input_id).await;
975+
tracing::warn!(
976+
run_id = %run_id,
977+
pending_input_id = %pending_input_id,
978+
error = %e,
979+
"failed to send queued Claude streaming input"
980+
);
981+
return false;
982+
}
983+
}
984+
}
985+
986+
#[cfg(test)]
987+
{
988+
self.test_sent_streaming_inputs
989+
.lock()
990+
.await
991+
.push((run_id.to_owned(), outbound));
992+
return true;
993+
}
994+
995+
#[cfg(not(test))]
996+
{
997+
self.rollback_pending_input(run_id, pending_input_id).await;
998+
false
999+
}
1000+
}
1001+
1002+
async fn send_or_queue_streaming_input(
1003+
&self,
1004+
run_id: &str,
1005+
pending_input_id: String,
1006+
outbound: OutboundUserMessage,
1007+
) -> bool {
1008+
let pending = PendingClaudeInput {
1009+
pending_input_id,
1010+
outbound,
1011+
};
1012+
1013+
{
1014+
let mut states = self.run_input_states.lock().await;
1015+
if let Some(state) = states.get_mut(run_id)
1016+
&& state.outstanding_tool_uses > 0
1017+
{
1018+
state.queued_inputs.push_back(pending);
1019+
tracing::debug!(
1020+
run_id = %run_id,
1021+
outstanding_tool_uses = state.outstanding_tool_uses,
1022+
queued_inputs = state.queued_inputs.len(),
1023+
"deferring Claude streaming input until tool results are complete"
1024+
);
1025+
return true;
1026+
}
1027+
}
1028+
1029+
let PendingClaudeInput {
1030+
pending_input_id,
1031+
outbound,
1032+
} = pending;
1033+
self.send_streaming_input_now(run_id, &pending_input_id, outbound)
1034+
.await
1035+
}
1036+
1037+
async fn flush_queued_streaming_inputs_if_safe(&self, run_id: &str) {
1038+
loop {
1039+
let pending = {
1040+
let mut states = self.run_input_states.lock().await;
1041+
let Some(state) = states.get_mut(run_id) else {
1042+
return;
1043+
};
1044+
if state.outstanding_tool_uses > 0 {
1045+
return;
1046+
}
1047+
state.queued_inputs.pop_front()
1048+
};
1049+
1050+
let Some(pending) = pending else {
1051+
return;
1052+
};
1053+
1054+
let sent = self
1055+
.send_streaming_input_now(run_id, &pending.pending_input_id, pending.outbound)
1056+
.await;
1057+
if !sent {
1058+
return;
1059+
}
1060+
}
1061+
}
1062+
9011063
async fn initialize_pending_inputs(&self, run_id: &str) {
9021064
self.run_pending_inputs.lock().await.insert(
9031065
run_id.to_owned(),
@@ -1187,6 +1349,7 @@ impl ClaudeCliProvider {
11871349
Ok(None) => break, // stream closed normally
11881350
Ok(Some(msg_result)) => match msg_result {
11891351
Ok(Message::User(user_msg)) => {
1352+
let tool_result_count = count_user_tool_result_messages(&user_msg);
11901353
if let claude_agent_sdk::UserContent::Blocks(ref blocks) = user_msg.content
11911354
{
11921355
extract_tool_session_messages(
@@ -1195,10 +1358,12 @@ impl ClaudeCliProvider {
11951358
Some(on_chunk),
11961359
user_msg.parent_tool_use_id.as_deref(),
11971360
);
1198-
if has_tool_result_blocks(blocks) || user_msg.tool_use_result.is_some()
1199-
{
1200-
continue;
1201-
}
1361+
}
1362+
if tool_result_count > 0 {
1363+
self.note_tool_results_finished(run_id, tool_result_count)
1364+
.await;
1365+
self.flush_queued_streaming_inputs_if_safe(run_id).await;
1366+
continue;
12021367
}
12031368
// Keep Python parity: normal UserMessage echoes are ACKs that
12041369
// indicate one queued user input has been consumed.
@@ -1215,13 +1380,18 @@ impl ClaudeCliProvider {
12151380
Ok(Message::Assistant(assistant_msg)) => {
12161381
if is_synthetic_no_response_message(&assistant_msg) {
12171382
tracing::debug!(
1218-
run_id = %run_id,
1219-
thread_id = %thread_id,
1383+
run_id = %run_id,
1384+
thread_id = %thread_id,
12201385
"suppressing claude synthetic no-response placeholder"
12211386
);
12221387
continue;
12231388
}
12241389
assistant_or_tool_activity_seen = true;
1390+
self.note_tool_uses_started(
1391+
run_id,
1392+
count_tool_use_blocks(&assistant_msg.content),
1393+
)
1394+
.await;
12251395
if actual_model.is_none() {
12261396
actual_model = Some(assistant_msg.model.trim().to_owned())
12271397
.filter(|value| !value.is_empty());
@@ -1265,6 +1435,7 @@ impl ClaudeCliProvider {
12651435
thread_title: thread_title.clone(),
12661436
session_messages: session_messages.clone(),
12671437
});
1438+
self.flush_queued_streaming_inputs_if_safe(run_id).await;
12681439
// Atomically check-and-close: if no queued inputs remain,
12691440
// remove the queue entry so that concurrent
12701441
// add_streaming_input callers see the run as closed and
@@ -1446,6 +1617,7 @@ impl AgentLoopProvider for ClaudeCliProvider {
14461617
};
14471618
self.run_session_map.lock().await.clear();
14481619
self.run_pending_inputs.lock().await.clear();
1620+
self.run_input_states.lock().await.clear();
14491621

14501622
for (run_id, run) in runs {
14511623
if let Err(e) = run.interrupt().await {
@@ -1621,8 +1793,8 @@ impl AgentLoopProvider for ClaudeCliProvider {
16211793

16221794
async fn add_streaming_input(&self, thread_id: &str, input: QueuedUserInput) -> bool {
16231795
if let Some(run_id) = self.resolve_active_run_id_for_session(thread_id).await {
1624-
let run = { self.active_runs.lock().await.get(&run_id).cloned() };
1625-
if let Some(run) = run {
1796+
let has_run = { self.active_runs.lock().await.contains_key(&run_id) };
1797+
if has_run {
16261798
let pending_input_id = input.pending_input_id.unwrap_or_default();
16271799
if pending_input_id.trim().is_empty() {
16281800
return false;
@@ -1641,21 +1813,14 @@ impl AgentLoopProvider for ClaudeCliProvider {
16411813
UserInput::Text(text) => OutboundUserMessage::text(text, ""),
16421814
UserInput::Blocks(blocks) => OutboundUserMessage::blocks(blocks, ""),
16431815
};
1644-
match run.send_user_message(outbound).await {
1645-
Ok(()) => {
1646-
tracing::debug!(thread_id = %thread_id, "queued streaming input");
1647-
true
1648-
}
1649-
Err(e) => {
1650-
self.rollback_pending_input(&run_id, &pending_input_id)
1651-
.await;
1652-
tracing::warn!(
1653-
thread_id = %thread_id,
1654-
error = %e,
1655-
"failed to queue streaming input"
1656-
);
1657-
false
1658-
}
1816+
if self
1817+
.send_or_queue_streaming_input(&run_id, pending_input_id.clone(), outbound)
1818+
.await
1819+
{
1820+
tracing::debug!(thread_id = %thread_id, "queued streaming input");
1821+
true
1822+
} else {
1823+
false
16591824
}
16601825
} else {
16611826
let _ = self.unregister_run(&run_id).await;

0 commit comments

Comments
 (0)