claudetool/bash: include partial output when command times out
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s829d2dbdfda28988k
diff --git a/claudetool/bash.go b/claudetool/bash.go
index f3f0491..adbd12c 100644
--- a/claudetool/bash.go
+++ b/claudetool/bash.go
@@ -138,7 +138,12 @@
// For foreground commands, use executeBash
out, execErr := executeBash(ctx, req)
+ // If there's a timeout error, we still want to return the partial output
if execErr != nil {
+ if out != "" && strings.Contains(execErr.Error(), "timed out") {
+ // Return both the partial output and the error
+ return llm.TextContent(out), execErr
+ }
return nil, execErr
}
return llm.TextContent(out), nil
@@ -182,7 +187,13 @@
close(done)
if execCtx.Err() == context.DeadlineExceeded {
- return "", fmt.Errorf("command timed out after %s", req.timeout())
+ // Get the partial output that was captured before the timeout
+ partialOutput := output.String()
+ // Truncate if the output is too large
+ if len(partialOutput) > maxBashOutputLength {
+ partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
+ }
+ return partialOutput, fmt.Errorf("command timed out after %s - partial output included", req.timeout())
}
longOutput := output.Len() > maxBashOutputLength
var outstr string
diff --git a/test/timeout_test.go b/test/timeout_test.go
new file mode 100644
index 0000000..2330d93
--- /dev/null
+++ b/test/timeout_test.go
@@ -0,0 +1,83 @@
+package test
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "sketch.dev/claudetool"
+ "sketch.dev/llm"
+)
+
+func TestBashTimeout(t *testing.T) {
+ // Create a bash tool
+ bashTool := claudetool.NewBashTool(nil)
+
+ // Create a command that will output text and then sleep
+ cmd := `echo "Starting command..."; echo "This should appear in partial output"; sleep 5; echo "This shouldn't appear"`
+
+ // Prepare the input with a very short timeout
+ input := map[string]interface{}{
+ "command": cmd,
+ "timeout": "1s", // Very short timeout to trigger the timeout case
+ }
+
+ // Marshal the input to JSON
+ inputJSON, err := json.Marshal(input)
+ if err != nil {
+ t.Fatalf("Failed to marshal input: %v", err)
+ }
+
+ // Run the bash tool
+ ctx := context.Background()
+ result, err := bashTool.Run(ctx, inputJSON)
+
+ // Check that we got an error (due to timeout)
+ if err == nil {
+ t.Fatalf("Expected timeout error, got nil")
+ }
+
+ // Error should mention timeout
+ if !containsString(err.Error(), "timed out") {
+ t.Errorf("Error doesn't mention timeout: %v", err)
+ }
+
+ // Check that we got partial output despite the error
+ if len(result) == 0 {
+ t.Fatalf("Expected partial output, got empty result")
+ }
+
+ // Verify the error mentions that partial output is included
+ if !containsString(err.Error(), "partial output included") {
+ t.Errorf("Error should mention that partial output is included: %v", err)
+ }
+
+ // The partial output should contain the initial output but not the text after sleep
+ text := ""
+ for _, content := range result {
+ if content.Type == llm.ContentTypeText {
+ text += content.Text
+ }
+ }
+
+ if !containsString(text, "Starting command") || !containsString(text, "should appear in partial output") {
+ t.Errorf("Partial output is missing expected content: %s", text)
+ }
+
+ if containsString(text, "shouldn't appear") {
+ t.Errorf("Partial output contains unexpected content (after timeout): %s", text)
+ }
+}
+
+func containsString(s, substr string) bool {
+ return s != "" && s != "<nil>" && stringIndexOf(s, substr) >= 0
+}
+
+func stringIndexOf(s, substr string) int {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return i
+ }
+ }
+ return -1
+}