81
SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
82
SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
83
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
84
- ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
84
+ ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
85
+ ToolKind, UnstructuredCommandInput,
86
};
87
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
88
use onde::inference::{ChatEngine, GgufModelConfig};
350
}
351
}
352
353
+/// The longest run of consecutive backticks in `text`, so a fence built around
354
+/// it can't be broken out of by output that itself contains ```` ``` ````.
355
+fn longest_backtick_run(text: &str) -> usize {
356
+ let mut max = 0;
357
+ let mut current = 0;
358
+ for c in text.chars() {
359
+ if c == '`' {
360
+ current += 1;
361
+ max = max.max(current);
362
+ } else {
363
+ current = 0;
364
+ }
365
+ }
366
+ max
367
+}
368
+
369
+/// Fences `text` as a Markdown code block, sizing the fence from the longest
370
+/// backtick run already in `text` so it can't break out of the block.
371
+fn fenced_code_block(language: &str, text: &str) -> String {
372
+ let fence = "`".repeat((longest_backtick_run(text) + 1).max(3));
373
+ format!("{fence}{language}\n{text}\n{fence}")
374
+}
375
+
376
+/// Wraps a tool's output as the display content of its ACP tool-call card
377
+/// (issue #78). Capped via [`chat::cap_output_preview`] — display only, the
378
+/// model still gets the full output — and fenced so plain-text output (or a
379
+/// stray tag, per issue #73) renders literally instead of as markdown.
380
+fn tool_output_content(output: &str) -> ToolCallContent {
381
+ if output.trim().is_empty() {
382
+ return "(no output)".to_string().into();
383
+ }
384
+ fenced_code_block("", &chat::cap_output_preview(output)).into()
385
+}
386
+
387
+/// A single-file location for the path-bearing tools, so ACP clients can
388
+/// follow the agent through a file as it works (issue #78's "follow-along").
389
+fn tool_call_locations(name: &str, arguments_json: &str) -> Vec<ToolCallLocation> {
390
+ if !matches!(
391
+ name,
392
+ "read_file" | "create_file" | "edit_file" | "multi_edit" | "delete_file"
393
+ ) {
394
+ return Vec::new();
395
+ }
396
+ let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments_json) else {
397
+ return Vec::new();
398
+ };
399
+ let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
400
+ return Vec::new();
401
+ };
402
+ let path_buf = PathBuf::from(path);
403
+ let absolute = if path_buf.is_absolute() {
404
+ path_buf
405
+ } else {
406
+ std::env::current_dir()
407
+ .unwrap_or_else(|_| PathBuf::from("."))
408
+ .join(path_buf)
409
+ };
410
+ vec![ToolCallLocation::new(absolute)]
411
+}
412
+
413
/// The visible text of one saved history message. `content` is normally a
414
/// string, but an OpenAI-compatible endpoint may hand back the block form, and
415
/// tool-call-only assistant turns carry `null`.
492
} else {
493
id.to_string()
494
};
434
- let mut tool_call = ToolCall::new(replay_id, name)
495
+ let output = outputs.get(id).copied().unwrap_or_default();
496
+ let mut tool_call = ToolCall::new(replay_id, chat::tool_title(name, arguments))
497
.kind(tool_kind_for(name))
498
.status(ToolCallStatus::Completed)
499
+ .content(vec![tool_output_content(output)])
500
+ .locations(tool_call_locations(name, arguments))
501
.raw_input(serde_json::from_str::<serde_json::Value>(arguments).ok());
502
if let Some(output) = outputs.get(id) {
503
tool_call =
1882
log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
1883
serde_json::Value::String(tc.arguments.clone())
1884
});
1885
+ let invocation_content =
1886
+ fenced_code_block("json", &chat::pretty_tool_arguments(&tc.arguments));
1887
self.send_tool_call_update(
1888
cx,
1889
session_id.clone(),
1890
SessionUpdate::ToolCall(
1825
- ToolCall::new(tc.id.clone(), tc.name.clone())
1891
+ ToolCall::new(tc.id.clone(), chat::tool_title(&tc.name, &tc.arguments))
1892
.kind(tool_kind_for(&tc.name))
1893
.status(ToolCallStatus::InProgress)
1894
+ .content(vec![invocation_content.into()])
1895
+ .locations(tool_call_locations(&tc.name, &tc.arguments))
1896
.raw_input(raw_input),
1897
),
1898
)
1969
),
1970
});
1971
}
1972
+ let cancelled_reason = "Not executed: the user cancelled the turn at the permission prompt.";
1973
self.send_tool_call_update(
1974
cx,
1975
session_id.clone(),
1977
tc.id.clone(),
1978
ToolCallUpdateFields::new()
1979
.status(ToolCallStatus::Failed)
1980
+ .content(vec![tool_output_content(
1981
+ cancelled_reason,
1982
+ )])
1983
.raw_output(serde_json::Value::String(
1912
- "Not executed: the user cancelled the turn at the permission prompt."
1913
- .to_string(),
1984
+ cancelled_reason.to_string(),
1985
)),
1986
)),
1987
)
2004
tc.id.clone(),
2005
ToolCallUpdateFields::new()
2006
.status(ToolCallStatus::Completed)
2007
+ .content(vec![tool_output_content(&output)])
2008
.raw_output(serde_json::Value::String(output.clone())),
2009
)),
2010
)
4077
}
4078
match &updates[2] {
4079
SessionUpdate::ToolCall(call) => {
4008
- assert_eq!(call.title, "read_file");
4080
+ assert_eq!(call.title, "read_file · /tmp/CHANGELOG.md");
4081
assert_eq!(call.kind, ToolKind::Read);
4082
// Nothing is still running in a saved session.
4083
assert_eq!(call.status, ToolCallStatus::Completed);
4090
call.raw_output,
4091
Some(serde_json::Value::String("# Changelog".to_string()))
4092
);
4093
+ // Replay is expandable exactly like a live card: the saved
4094
+ // output shows up as displayable content, not just raw_output.
4095
+ assert!(format!("{:?}", call.content).contains("# Changelog"));
4096
+ assert_eq!(call.locations.len(), 1);
4097
+ assert_eq!(call.locations[0].path, PathBuf::from("/tmp/CHANGELOG.md"));
4098
}
4099
other => panic!("expected the tool call, got {other:?}"),
4100
}
4125
}
4126
}
4127
4128
+ #[test]
4129
+ fn history_replay_shows_a_placeholder_for_a_call_with_no_recorded_output() {
4130
+ // A session file can carry a tool call whose result was never saved
4131
+ // (e.g. an older session format). Replay must not show an empty card.
4132
+ let history = vec![serde_json::json!({
4133
+ "role": "assistant",
4134
+ "content": null,
4135
+ "tool_calls": [{
4136
+ "id": "call_1", "type": "function",
4137
+ "function": { "name": "run_command", "arguments": "{\"command\":\"git add -A\"}" },
4138
+ }],
4139
+ })];
4140
+
4141
+ match history_replay_updates(&history).as_slice() {
4142
+ [SessionUpdate::ToolCall(call)] => {
4143
+ assert!(format!("{:?}", call.content).contains("(no output)"));
4144
+ assert_eq!(call.raw_output, None);
4145
+ }
4146
+ other => panic!("expected one tool call, got {other:?}"),
4147
+ }
4148
+ }
4149
+
4150
#[test]
4151
fn history_replay_skips_messages_with_nothing_to_show() {
4152
// A tool-call-only assistant turn (`content: null`), an empty reply and
4365
.collect();
4366
assert_eq!(inference_names, ["Local", "Cloud"]);
4367
}
4368
+
4369
+ #[test]
4370
+ fn tool_output_content_shows_a_placeholder_for_empty_output() {
4371
+ let content = tool_output_content(" \n\t ");
4372
+ assert!(format!("{content:?}").contains("(no output)"));
4373
+ }
4374
+
4375
+ #[test]
4376
+ fn tool_output_content_widens_the_fence_around_embedded_backticks() {
4377
+ let output = "here is a fence:\n```\ncode\n```";
4378
+ let content = tool_output_content(output);
4379
+ let rendered = format!("{content:?}");
4380
+ // A three-backtick fence would terminate early on the embedded ```;
4381
+ // the wrapping fence must be longer than any run already present.
4382
+ assert!(rendered.contains("````"));
4383
+ }
4384
+
4385
+ #[test]
4386
+ fn tool_output_content_caps_long_output_via_cap_output_preview() {
4387
+ // Comfortably past chat's internal preview cap without depending on
4388
+ // its exact value, which is private to that module.
4389
+ let output = "x".repeat(10_000);
4390
+ let content = tool_output_content(&output);
4391
+ let rendered = format!("{content:?}");
4392
+ assert!(rendered.contains("truncated"));
4393
+ }
4394
+
4395
+ #[test]
4396
+ fn longest_backtick_run_finds_the_widest_run() {
4397
+ assert_eq!(longest_backtick_run("no backticks here"), 0);
4398
+ assert_eq!(longest_backtick_run("one ` two `` three ``` four"), 3);
4399
+ }
4400
}