Skip to content
Closed

main #342

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
58 changes: 54 additions & 4 deletions src/crates/core/src/agentic/tools/implementations/bash_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,28 @@ const BANNED_COMMANDS: &[&str] = &[
"safari",
];

fn truncate_string_by_chars(s: &str, max_chars: usize) -> String {
fn truncate_output_preserving_tail(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
chars[..max_chars].into_iter().collect()
if chars.len() <= max_chars {
return s.to_string();
}

let tail_bias = max_chars.saturating_mul(4) / 5;
let separator = "\n... [truncated, middle omitted, tail preserved] ...\n";
let separator_len = separator.chars().count();

if separator_len >= max_chars {
return chars[chars.len() - max_chars..].iter().collect();
}

let content_budget = max_chars - separator_len;
let tail_len = tail_bias.min(content_budget);
let head_len = content_budget.saturating_sub(tail_len);

let head: String = chars[..head_len].iter().collect();
let tail: String = chars[chars.len() - tail_len..].iter().collect();

format!("{head}{separator}{tail}")
}

/// Result of shell resolution for bash tool
Expand Down Expand Up @@ -142,7 +161,7 @@ impl BashTool {
let cleaned_output = strip_ansi(output_text);
let output_len = cleaned_output.chars().count();
if output_len > MAX_OUTPUT_LENGTH {
let truncated = truncate_string_by_chars(&cleaned_output, MAX_OUTPUT_LENGTH);
let truncated = truncate_output_preserving_tail(&cleaned_output, MAX_OUTPUT_LENGTH);
result_string.push_str(&format!(
"<output truncated=\"true\">{}</output>",
truncated
Expand Down Expand Up @@ -226,7 +245,7 @@ Usage notes:
- DO NOT use multiline commands or HEREDOC syntax (e.g., <<EOF, heredoc with newlines). Only single-line commands are supported.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does.
- If the output exceeds {MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you.
- If the output exceeds {MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you, with the tail of the output preserved because the ending is usually more important.
- You can use the `run_in_background` parameter to run the command in a new dedicated background terminal session. The tool returns the background session ID immediately without waiting for the command to finish. Only use this for long-running processes (e.g., dev servers, watchers) where you don't need the output right away. You do not need to append '&' to the command. NOTE: `timeout_ms` is ignored when `run_in_background` is true.
- Each result includes a `<terminal_session_id>` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID.
- The output may include the command echo and/or the shell prompt (e.g., `PS C:\path>`). Do not treat these as part of the command's actual result.
Expand Down Expand Up @@ -916,3 +935,34 @@ impl BashTool {
}])
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn truncate_output_preserving_tail_keeps_end_of_output() {
let input = "BEGIN-".to_string() + &"x".repeat(120) + "-IMPORTANT-END";

let truncated = truncate_output_preserving_tail(&input, 80);

assert!(truncated.contains("tail preserved"));
assert!(truncated.ends_with("IMPORTANT-END"));
assert!(!truncated.contains("BEGIN-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
assert!(truncated.chars().count() <= 80);
}

#[test]
fn render_result_marks_truncated_output_and_keeps_tail() {
let tool = BashTool::new();
let long_output =
"prefix\n".to_string() + &"y".repeat(MAX_OUTPUT_LENGTH + 100) + "\nfinal-error";

let rendered = tool.render_result("session-1", &long_output, false, false, 1);

assert!(rendered.contains("<output truncated=\"true\">"));
assert!(rendered.contains("tail preserved"));
assert!(rendered.contains("final-error"));
assert!(rendered.contains("<exit_code>1</exit_code>"));
}
}
33 changes: 30 additions & 3 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,12 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
const collapseIntent = pendingCollapseIntentRef.current;
const now = performance.now();
const hasValidCollapseIntent = collapseIntent.active && collapseIntent.expiresAtMs >= now;
const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - currentTotalCompensation);
const fallbackAdditionalCompensation = Math.max(0, shrinkAmount - effectiveDistanceFromBottom);
// For unsignaled shrinks, the visible gap to the bottom is what matters.
// Existing synthetic footer compensation may be stale from an earlier
// protected collapse, and subtracting it here makes the list think the
// viewport is still pinned near the bottom when the user has already moved
// away. That misclassification re-arms anchor restore and causes jitter.
const fallbackAdditionalCompensation = Math.max(0, shrinkAmount - distanceFromBottom);
const cumulativeShrinkPx = hasValidCollapseIntent
? collapseIntent.cumulativeShrinkPx + shrinkAmount
: 0;
Expand All @@ -452,6 +456,16 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
cumulativeShrinkPx,
};
}

if (!hasValidCollapseIntent && fallbackAdditionalCompensation <= COMPENSATION_EPSILON_PX) {
// If the user is already far enough from the bottom, this shrink does not
// need protection. Reusing stale bottom compensation here makes the
// scroll listener restore an older anchor during upward scroll and causes
// the visible "wall hit" jitter.
previousScrollTopRef.current = currentScrollTop;
return;
}

const nextReservationState: BottomReservationState = {
...bottomReservationStateRef.current,
collapse: {
Expand Down Expand Up @@ -1041,8 +1055,20 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>

mutationObserverRef.current?.disconnect();
let mutationPending = false;
mutationObserverRef.current = new MutationObserver(() => {
mutationObserverRef.current = new MutationObserver((mutations) => {
if (mutationPending) return;
if (!isProcessing) {
return;
}
const characterDataMutations = mutations.filter(mutation => mutation.type === 'characterData');
const attributesMutations = mutations.filter(mutation => mutation.type === 'attributes');
const hasSemanticMutation = (
characterDataMutations.length > 0 ||
attributesMutations.length > 0
);
if (!hasSemanticMutation) {
return;
}
mutationPending = true;
requestAnimationFrame(() => {
mutationPending = false;
Expand Down Expand Up @@ -1331,6 +1357,7 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
scheduleVisibleTurnMeasure,
scrollerElement,
shouldSuspendAutoFollow,
isProcessing,
updateBottomReservationState,
]);

Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export const FileOperationToolCard: React.FC<FileOperationToolCardProps> = ({

(async () => {
try {
// TODO: Persist diff stats with the tool result so historical cards can
// read a static value instead of recomputing on every remount.
const { snapshotAPI } = await import('../../infrastructure/api');
const summary = await snapshotAPI.getOperationSummary(sessionId, toolCall.id);
if (cancelled) return;
Expand Down
Loading