Skip to content

Commit 2c0ecba

Browse files
committed
fix: treat missing exec sessions as tool-rendered results
- Add structured RemoteExecError::SessionNotFound for remote exec sessions - Let WriteStdin and ExecControl render missing sessions as non-error tool results - Show WriteStdin session-not-found notices in the web UI tool card - Add focused Rust and web UI tests for the new session-missing behavior
1 parent 493745a commit 2c0ecba

16 files changed

Lines changed: 368 additions & 65 deletions

File tree

src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs

Lines changed: 140 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ use super::rendering::render_exec_response_for_assistant;
22
use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult};
33
use crate::service::remote_ssh::{
44
get_global_remote_exec_process_manager, RemoteExecControlAction, RemoteExecControlOrigin,
5-
RemoteExecControlRequest, RemoteExecSessionCompletion, RemoteExecSessionCompletionSource,
6-
RemoteExecSessionCompletionStatus,
5+
RemoteExecControlRequest, RemoteExecError, RemoteExecSessionCompletion,
6+
RemoteExecSessionCompletionSource, RemoteExecSessionCompletionStatus,
77
};
88
use crate::util::errors::{BitFunError, BitFunResult};
99
use async_trait::async_trait;
1010
use serde_json::{json, Value};
1111
use terminal_core::{
1212
get_global_exec_process_manager, LocalExecControlAction, LocalExecControlOrigin,
1313
LocalExecControlRequest, LocalExecSessionCompletion, LocalExecSessionCompletionSource,
14-
LocalExecSessionCompletionStatus,
14+
LocalExecSessionCompletionStatus, TerminalError,
1515
};
1616

1717
const DEFAULT_MAX_OUTPUT_CHARS: u64 = 10_000;
@@ -93,9 +93,18 @@ pub struct ExecCommandControlResponse {
9393
pub completion: Option<ExecCommandCompletion>,
9494
}
9595

96+
#[derive(Debug, thiserror::Error)]
97+
pub enum ExecCommandControlError {
98+
#[error("session not found: {0}")]
99+
SessionNotFound(i32),
100+
101+
#[error(transparent)]
102+
Tool(#[from] BitFunError),
103+
}
104+
96105
pub async fn control_exec_command_session(
97106
request: ExecCommandControlRequest,
98-
) -> BitFunResult<ExecCommandControlResponse> {
107+
) -> Result<ExecCommandControlResponse, ExecCommandControlError> {
99108
if request.remote {
100109
let response = get_global_remote_exec_process_manager()
101110
.control_session(RemoteExecControlRequest {
@@ -106,7 +115,14 @@ pub async fn control_exec_command_session(
106115
max_output_chars: request.max_output_chars,
107116
})
108117
.await
109-
.map_err(|error| BitFunError::tool(format!("ExecControl failed: {error}")))?;
118+
.map_err(|error| match error {
119+
RemoteExecError::SessionNotFound(session_id) => {
120+
ExecCommandControlError::SessionNotFound(session_id)
121+
}
122+
error => ExecCommandControlError::Tool(BitFunError::tool(format!(
123+
"ExecControl failed: {error}"
124+
))),
125+
})?;
110126

111127
return Ok(ExecCommandControlResponse {
112128
chunk_id: response.chunk_id,
@@ -130,7 +146,14 @@ pub async fn control_exec_command_session(
130146
max_output_chars: request.max_output_chars,
131147
})
132148
.await
133-
.map_err(|error| BitFunError::tool(format!("ExecControl failed: {error}")))?;
149+
.map_err(|error| match error {
150+
TerminalError::SessionNotFound(_) => {
151+
ExecCommandControlError::SessionNotFound(request.session_id)
152+
}
153+
error => ExecCommandControlError::Tool(BitFunError::tool(format!(
154+
"ExecControl failed: {error}"
155+
))),
156+
})?;
134157

135158
Ok(ExecCommandControlResponse {
136159
chunk_id: response.chunk_id,
@@ -193,6 +216,39 @@ impl ExecControlTool {
193216
render_exec_response_for_assistant(data, status_lines, 4)
194217
}
195218

219+
fn session_not_found_result(
220+
session_id: i32,
221+
action: ExecCommandControlAction,
222+
remote: bool,
223+
) -> Vec<ToolResult> {
224+
let action_name = match action {
225+
ExecCommandControlAction::Interrupt => "interrupt",
226+
ExecCommandControlAction::Kill => "kill",
227+
};
228+
let message = format!(
229+
"No {action_name} was sent because ExecCommand session {session_id} was not found. It may have already exited, been collected, or been pruned."
230+
);
231+
let mut data = json!({
232+
"status": "session_not_found",
233+
"message": message,
234+
"requested_session_id": session_id,
235+
"session_id": null,
236+
"exit_code": null,
237+
"output": "",
238+
"original_output_chars": 0,
239+
"action": action_name,
240+
});
241+
if remote {
242+
data["remote"] = json!(true);
243+
}
244+
245+
vec![ToolResult::Result {
246+
data,
247+
result_for_assistant: Some(message),
248+
image_attachments: None,
249+
}]
250+
}
251+
196252
fn local_action(action: ExecCommandControlAction) -> LocalExecControlAction {
197253
match action {
198254
ExecCommandControlAction::Interrupt => LocalExecControlAction::Interrupt,
@@ -274,15 +330,22 @@ impl ExecControlTool {
274330
.try_into()
275331
.unwrap_or(usize::MAX);
276332

277-
let response = control_exec_command_session(ExecCommandControlRequest {
333+
let response = match control_exec_command_session(ExecCommandControlRequest {
278334
session_id,
279335
action,
280336
origin: ExecCommandControlOrigin::ModelTool,
281337
remote: true,
282338
yield_time_ms,
283339
max_output_chars: Some(max_output_chars),
284340
})
285-
.await?;
341+
.await
342+
{
343+
Ok(response) => response,
344+
Err(ExecCommandControlError::SessionNotFound(session_id)) => {
345+
return Ok(Self::session_not_found_result(session_id, action, true));
346+
}
347+
Err(ExecCommandControlError::Tool(error)) => return Err(error),
348+
};
286349

287350
let action_name = match action {
288351
ExecCommandControlAction::Interrupt => "interrupt",
@@ -422,15 +485,22 @@ After the action, yield_time_ms waits for output or exit status. Output is only
422485
.try_into()
423486
.unwrap_or(usize::MAX);
424487

425-
let response = control_exec_command_session(ExecCommandControlRequest {
488+
let response = match control_exec_command_session(ExecCommandControlRequest {
426489
session_id,
427490
action,
428491
origin: ExecCommandControlOrigin::ModelTool,
429492
remote: false,
430493
yield_time_ms,
431494
max_output_chars: Some(max_output_chars),
432495
})
433-
.await?;
496+
.await
497+
{
498+
Ok(response) => response,
499+
Err(ExecCommandControlError::SessionNotFound(session_id)) => {
500+
return Ok(Self::session_not_found_result(session_id, action, false));
501+
}
502+
Err(ExecCommandControlError::Tool(error)) => return Err(error),
503+
};
434504

435505
let action_name = match action {
436506
ExecCommandControlAction::Interrupt => "interrupt",
@@ -454,3 +524,63 @@ After the action, yield_time_ms waits for output or exit status. Output is only
454524
}])
455525
}
456526
}
527+
528+
#[cfg(test)]
529+
mod tests {
530+
use super::{
531+
control_exec_command_session, ExecCommandControlAction, ExecCommandControlError,
532+
ExecCommandControlOrigin, ExecCommandControlRequest, ExecControlTool,
533+
};
534+
use crate::agentic::tools::framework::ToolResult;
535+
536+
#[test]
537+
fn session_not_found_result_uses_plain_assistant_message() {
538+
let results = ExecControlTool::session_not_found_result(
539+
456,
540+
ExecCommandControlAction::Interrupt,
541+
false,
542+
);
543+
let ToolResult::Result {
544+
data,
545+
result_for_assistant,
546+
..
547+
} = &results[0]
548+
else {
549+
panic!("expected result");
550+
};
551+
552+
assert_eq!(
553+
data.get("status").and_then(|value| value.as_str()),
554+
Some("session_not_found")
555+
);
556+
assert_eq!(
557+
data.get("requested_session_id")
558+
.and_then(|value| value.as_i64()),
559+
Some(456)
560+
);
561+
let assistant = result_for_assistant.as_deref().expect("assistant text");
562+
assert!(assistant.contains("No interrupt was sent"));
563+
assert!(assistant.contains("ExecCommand session 456 was not found"));
564+
assert!(!assistant.contains("<wall_time>"));
565+
assert!(!assistant.contains("<output>"));
566+
}
567+
568+
#[tokio::test]
569+
async fn control_exec_command_session_returns_structured_session_not_found() {
570+
let error = control_exec_command_session(ExecCommandControlRequest {
571+
session_id: 987_654,
572+
action: ExecCommandControlAction::Kill,
573+
origin: ExecCommandControlOrigin::ModelTool,
574+
remote: false,
575+
yield_time_ms: Some(0),
576+
max_output_chars: Some(1),
577+
})
578+
.await
579+
.expect_err("missing session should be structured");
580+
581+
assert!(matches!(
582+
error,
583+
ExecCommandControlError::SessionNotFound(987_654)
584+
));
585+
}
586+
}

src/crates/assembly/core/src/agentic/tools/implementations/exec_command/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ pub use background_command_output::{
1717
pub use command::ExecCommandTool;
1818
pub use control::{
1919
control_exec_command_session, ExecCommandCompletion, ExecCommandCompletionSource,
20-
ExecCommandCompletionStatus, ExecCommandControlAction, ExecCommandControlOrigin,
21-
ExecCommandControlRequest, ExecCommandControlResponse, ExecControlTool,
20+
ExecCommandCompletionStatus, ExecCommandControlAction, ExecCommandControlError,
21+
ExecCommandControlOrigin, ExecCommandControlRequest, ExecCommandControlResponse,
22+
ExecControlTool,
2223
};
2324
pub use input::{send_exec_command_input, ExecCommandInputRequest};
2425
pub use stdin::WriteStdinTool;

src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ use super::progress::ExecOutputProgressBridge;
22
use super::rendering::render_exec_response_for_assistant;
33
use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult};
44
use crate::service::remote_ssh::{
5-
get_global_remote_exec_process_manager, RemoteExecSessionCompletion,
5+
get_global_remote_exec_process_manager, RemoteExecError, RemoteExecSessionCompletion,
66
RemoteExecSessionCompletionSource, RemoteExecSessionCompletionStatus, RemoteWriteStdinRequest,
77
};
88
use crate::util::errors::{BitFunError, BitFunResult};
99
use async_trait::async_trait;
1010
use serde_json::{json, Value};
1111
use terminal_core::{
1212
get_global_exec_process_manager, LocalExecSessionCompletion, LocalExecSessionCompletionSource,
13-
LocalExecSessionCompletionStatus, LocalWriteStdinRequest,
13+
LocalExecSessionCompletionStatus, LocalWriteStdinRequest, TerminalError,
1414
};
1515

1616
const DEFAULT_MAX_OUTPUT_CHARS: u64 = 10_000;
@@ -72,6 +72,30 @@ impl WriteStdinTool {
7272
render_exec_response_for_assistant(data, status_lines, 4)
7373
}
7474

75+
fn session_not_found_result(session_id: i32, remote: bool) -> Vec<ToolResult> {
76+
let message = format!(
77+
"ExecCommand session {session_id} was not found. It may have already exited, been collected, or been pruned."
78+
);
79+
let mut data = json!({
80+
"status": "session_not_found",
81+
"message": message,
82+
"requested_session_id": session_id,
83+
"session_id": null,
84+
"exit_code": null,
85+
"output": "",
86+
"original_output_chars": 0,
87+
});
88+
if remote {
89+
data["remote"] = json!(true);
90+
}
91+
92+
vec![ToolResult::Result {
93+
data,
94+
result_for_assistant: Some(message),
95+
image_attachments: None,
96+
}]
97+
}
98+
7599
fn local_completion_value(completion: LocalExecSessionCompletion) -> Value {
76100
json!({
77101
"status": match completion.status {
@@ -147,8 +171,13 @@ impl WriteStdinTool {
147171
if let Some(bridge) = progress_bridge {
148172
bridge.finish().await;
149173
}
150-
let response = response_result
151-
.map_err(|error| BitFunError::tool(format!("WriteStdin failed: {error}")))?;
174+
let response = match response_result {
175+
Ok(response) => response,
176+
Err(RemoteExecError::SessionNotFound(session_id)) => {
177+
return Ok(Self::session_not_found_result(session_id, true));
178+
}
179+
Err(error) => return Err(BitFunError::tool(format!("WriteStdin failed: {error}"))),
180+
};
152181

153182
let data = json!({
154183
"chunk_id": response.chunk_id,
@@ -300,8 +329,13 @@ Output is only what was produced during this tool call's wait window."#
300329
if let Some(bridge) = progress_bridge {
301330
bridge.finish().await;
302331
}
303-
let response = response_result
304-
.map_err(|error| BitFunError::tool(format!("WriteStdin failed: {error}")))?;
332+
let response = match response_result {
333+
Ok(response) => response,
334+
Err(TerminalError::SessionNotFound(_)) => {
335+
return Ok(Self::session_not_found_result(session_id, false));
336+
}
337+
Err(error) => return Err(BitFunError::tool(format!("WriteStdin failed: {error}"))),
338+
};
305339

306340
let data = json!({
307341
"chunk_id": response.chunk_id,
@@ -321,3 +355,36 @@ Output is only what was produced during this tool call's wait window."#
321355
}])
322356
}
323357
}
358+
359+
#[cfg(test)]
360+
mod tests {
361+
use super::WriteStdinTool;
362+
use crate::agentic::tools::framework::ToolResult;
363+
364+
#[test]
365+
fn session_not_found_result_uses_plain_assistant_message() {
366+
let results = WriteStdinTool::session_not_found_result(123, false);
367+
let ToolResult::Result {
368+
data,
369+
result_for_assistant,
370+
..
371+
} = &results[0]
372+
else {
373+
panic!("expected result");
374+
};
375+
376+
assert_eq!(
377+
data.get("status").and_then(|value| value.as_str()),
378+
Some("session_not_found")
379+
);
380+
assert_eq!(
381+
data.get("requested_session_id")
382+
.and_then(|value| value.as_i64()),
383+
Some(123)
384+
);
385+
let assistant = result_for_assistant.as_deref().expect("assistant text");
386+
assert!(assistant.contains("ExecCommand session 123 was not found"));
387+
assert!(!assistant.contains("<wall_time>"));
388+
assert!(!assistant.contains("<output>"));
389+
}
390+
}

0 commit comments

Comments
 (0)