Skip to content
This repository was archived by the owner on Apr 27, 2026. It is now read-only.

Commit 098ed00

Browse files
committed
feat: web_fetch for planning and codebase investigator
1 parent b183783 commit 098ed00

12 files changed

Lines changed: 709 additions & 54 deletions

internal/acp/agent.go

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@ type statcodeSession struct {
784784
isActive bool
785785
toolLocations map[string][]acp.ToolCallLocation
786786
toolParams map[string]map[string]interface{}
787+
toolProgress map[string]*strings.Builder // Accumulated progress output per tool ID
787788
mu sync.Mutex
788789
}
789790

@@ -818,17 +819,38 @@ func (a *ScriptschnellAIAgent) getToolLocations(session *statcodeSession, toolID
818819
return out
819820
}
820821

821-
func (a *ScriptschnellAIAgent) popToolContext(session *statcodeSession, toolID string) (map[string]interface{}, []acp.ToolCallLocation) {
822+
func (a *ScriptschnellAIAgent) accumulateToolProgress(session *statcodeSession, toolID, message string) {
823+
session.mu.Lock()
824+
defer session.mu.Unlock()
825+
826+
if session.toolProgress == nil {
827+
session.toolProgress = make(map[string]*strings.Builder)
828+
}
829+
830+
if _, exists := session.toolProgress[toolID]; !exists {
831+
session.toolProgress[toolID] = &strings.Builder{}
832+
}
833+
834+
session.toolProgress[toolID].WriteString(message)
835+
}
836+
837+
func (a *ScriptschnellAIAgent) popToolContext(session *statcodeSession, toolID string) (map[string]interface{}, []acp.ToolCallLocation, string) {
822838
session.mu.Lock()
823839
defer session.mu.Unlock()
824840

825841
params := session.toolParams[toolID]
826842
locations := session.toolLocations[toolID]
843+
var progressText string
844+
845+
if builder, exists := session.toolProgress[toolID]; exists {
846+
progressText = builder.String()
847+
delete(session.toolProgress, toolID)
848+
}
827849

828850
delete(session.toolParams, toolID)
829851
delete(session.toolLocations, toolID)
830852

831-
return params, locations
853+
return params, locations, progressText
832854
}
833855

834856
var (
@@ -981,6 +1003,7 @@ func (a *ScriptschnellAIAgent) NewSession(ctx context.Context, params acp.NewSes
9811003
isActive: true,
9821004
toolLocations: make(map[string][]acp.ToolCallLocation),
9831005
toolParams: make(map[string]map[string]interface{}),
1006+
toolProgress: make(map[string]*strings.Builder),
9841007
}
9851008

9861009
a.mu.Lock()
@@ -1725,34 +1748,20 @@ func (a *ScriptschnellAIAgent) resolveDiffPath(fd *godiff.FileDiff, params map[s
17251748
return filepath.Clean(candidate)
17261749
}
17271750

1728-
// sendToolCallProgress sends intermediate progress updates for long-running tools
1751+
// sendToolCallProgress accumulates intermediate progress updates for long-running tools
1752+
// Instead of sending individual ACP updates that replace content, we accumulate
1753+
// the progress and include it in the final result to avoid content replacement issues
17291754
func (a *ScriptschnellAIAgent) sendToolCallProgress(session *statcodeSession, toolID string, message string) error {
1730-
opts := []acp.ToolCallUpdateOpt{
1731-
acp.WithUpdateContent([]acp.ToolCallContent{
1732-
acp.ToolContent(acp.TextBlock(message)),
1733-
}),
1734-
}
1735-
1736-
if locations := a.getToolLocations(session, toolID); len(locations) > 0 {
1737-
opts = append(opts, acp.WithUpdateLocations(locations))
1738-
}
1739-
1740-
if err := a.conn.SessionUpdate(session.promptCtx, acp.SessionNotification{
1741-
SessionId: acp.SessionId(session.sessionID),
1742-
Update: acp.UpdateToolCall(
1743-
acp.ToolCallId(toolID),
1744-
opts...,
1745-
),
1746-
}); err != nil {
1747-
logger.Warn("Failed to send tool call progress update: %v", err)
1748-
return err
1749-
}
1755+
// Accumulate the progress message locally instead of sending immediate ACP updates
1756+
// This prevents the ACP client from replacing content with each progress update
1757+
a.accumulateToolProgress(session, toolID, message)
1758+
logger.Debug("sendToolCallProgress[%s]: accumulated progress for tool %s: %q", session.sessionID, toolID, truncateForLog(message))
17501759
return nil
17511760
}
17521761

17531762
// handleToolCallResult handles the completion of a tool call
17541763
func (a *ScriptschnellAIAgent) handleToolCallResult(session *statcodeSession, toolName, toolID, result, errorMsg string) error {
1755-
params, locations := a.popToolContext(session, toolID)
1764+
params, locations, progressText := a.popToolContext(session, toolID)
17561765

17571766
status := acp.ToolCallStatusCompleted
17581767
var content []acp.ToolCallContent
@@ -1761,11 +1770,24 @@ func (a *ScriptschnellAIAgent) handleToolCallResult(session *statcodeSession, to
17611770
status = acp.ToolCallStatusFailed
17621771
content = append(content, acp.ToolContent(acp.TextBlock(fmt.Sprintf("Error: %s", errorMsg))))
17631772
} else {
1764-
content = a.formatToolResultContent(toolName, result, params)
1773+
// Include accumulated progress output along with the final result
1774+
combinedResult := result
1775+
if progressText != "" {
1776+
// Combine progress and final result, with progress first to show the stream
1777+
if combinedResult != "" {
1778+
combinedResult = progressText + combinedResult
1779+
} else {
1780+
combinedResult = progressText
1781+
}
1782+
}
1783+
content = a.formatToolResultContent(toolName, combinedResult, params)
17651784
}
17661785

17671786
// Update the client about the tool call result
17681787
rawOutput := map[string]interface{}{"result": result}
1788+
if progressText != "" {
1789+
rawOutput["progress_output"] = progressText
1790+
}
17691791
if errorMsg != "" {
17701792
rawOutput["error"] = errorMsg
17711793
}
@@ -1790,7 +1812,7 @@ func (a *ScriptschnellAIAgent) handleToolCallResult(session *statcodeSession, to
17901812
return err
17911813
}
17921814

1793-
logger.Debug("handleToolCallResult[%s]: tool %s completed with status %s", session.sessionID, toolName, status)
1815+
logger.Debug("handleToolCallResult[%s]: tool %s completed with status %s (progressLen=%d, resultLen=%d)", session.sessionID, toolName, status, len(progressText), len(result))
17941816
return nil
17951817
}
17961818

internal/acp/agent_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ func TestHandleToolCallResult_UpdatedContent(t *testing.T) {
144144
promptCtx: context.Background(),
145145
toolLocations: make(map[string][]acp.ToolCallLocation),
146146
toolParams: make(map[string]map[string]interface{}),
147+
toolProgress: make(map[string]*strings.Builder),
147148
}
148149

149150
// Create a temporary test file
@@ -216,8 +217,8 @@ func TestHandleToolCallResult_UpdatedContent(t *testing.T) {
216217
}
217218

218219
// Verify the tool context was cleaned up
219-
params, locations := agent.popToolContext(session, tt.toolID)
220-
if params != nil || len(locations) != 0 {
220+
params, locations, progressText := agent.popToolContext(session, tt.toolID)
221+
if params != nil || len(locations) != 0 || progressText != "" {
221222
t.Error("tool context was not cleaned up after result handling")
222223
}
223224
})
@@ -342,6 +343,7 @@ func TestToolCallContentUpdates(t *testing.T) {
342343
promptCtx: context.Background(),
343344
toolLocations: make(map[string][]acp.ToolCallLocation),
344345
toolParams: make(map[string]map[string]interface{}),
346+
toolProgress: make(map[string]*strings.Builder),
345347
}
346348

347349
// Create a test file
@@ -383,13 +385,16 @@ func TestToolCallContentUpdates(t *testing.T) {
383385
}
384386

385387
// Verify tool context was cleaned up
386-
storedParams, storedLocations := agent.popToolContext(session, toolID)
388+
storedParams, storedLocations, progressText := agent.popToolContext(session, toolID)
387389
if storedParams != nil {
388390
t.Error("tool context was not cleaned up after result handling")
389391
}
390392
if len(storedLocations) != 0 {
391393
t.Error("tool locations were not cleaned up after result handling")
392394
}
395+
if progressText != "" {
396+
t.Error("tool progress was not cleaned up after result handling")
397+
}
393398

394399
// Verify the file was actually modified
395400
finalContent, err := os.ReadFile(testFile)

internal/acp/progress_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package acp
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/coder/acp-go-sdk"
9+
)
10+
11+
// TestToolProgressAccumulation tests that tool progress is properly accumulated
12+
// and included in the final tool result
13+
func TestToolProgressAccumulation(t *testing.T) {
14+
agent := &ScriptschnellAIAgent{}
15+
16+
session := &statcodeSession{
17+
sessionID: "test-progress-accumulation",
18+
promptCtx: context.Background(),
19+
toolLocations: make(map[string][]acp.ToolCallLocation),
20+
toolParams: make(map[string]map[string]interface{}),
21+
toolProgress: make(map[string]*strings.Builder),
22+
}
23+
24+
toolID := "test-tool-123"
25+
26+
// Send multiple progress messages
27+
progress1 := "Starting operation...\n"
28+
progress2 := "Processing step 1...\n"
29+
progress3 := "Processing step 2...\n"
30+
31+
// Accumulate progress messages
32+
err := agent.sendToolCallProgress(session, toolID, progress1)
33+
if err != nil {
34+
t.Fatalf("Failed to send first progress: %v", err)
35+
}
36+
37+
err = agent.sendToolCallProgress(session, toolID, progress2)
38+
if err != nil {
39+
t.Fatalf("Failed to send second progress: %v", err)
40+
}
41+
42+
err = agent.sendToolCallProgress(session, toolID, progress3)
43+
if err != nil {
44+
t.Fatalf("Failed to send third progress: %v", err)
45+
}
46+
47+
// Verify progress was accumulated
48+
session.mu.Lock()
49+
progressBuilder, exists := session.toolProgress[toolID]
50+
if !exists {
51+
t.Fatal("No progress was accumulated")
52+
}
53+
accumulated := progressBuilder.String()
54+
session.mu.Unlock()
55+
56+
expectedAccumulated := progress1 + progress2 + progress3
57+
if accumulated != expectedAccumulated {
58+
t.Errorf("Expected accumulated progress: %q, got: %q", expectedAccumulated, accumulated)
59+
}
60+
61+
// Test that popToolContext returns the accumulated progress
62+
params, locations, progressText := agent.popToolContext(session, toolID)
63+
64+
if params != nil || len(locations) != 0 {
65+
t.Error("Expected empty params and locations")
66+
}
67+
68+
if progressText != expectedAccumulated {
69+
t.Errorf("Expected progress text: %q, got: %q", expectedAccumulated, progressText)
70+
}
71+
72+
// Verify the progress was cleaned up
73+
session.mu.Lock()
74+
_, exists = session.toolProgress[toolID]
75+
if exists {
76+
t.Error("Progress was not cleaned up after popToolContext")
77+
}
78+
session.mu.Unlock()
79+
}
80+
81+
// TestToolProgressWithFinalResult tests that accumulated progress is combined
82+
// with the final tool result correctly
83+
func TestToolProgressWithFinalResult(t *testing.T) {
84+
agent := &ScriptschnellAIAgent{}
85+
86+
session := &statcodeSession{
87+
sessionID: "test-progress-with-result",
88+
promptCtx: context.Background(),
89+
toolLocations: make(map[string][]acp.ToolCallLocation),
90+
toolParams: make(map[string]map[string]interface{}),
91+
toolProgress: make(map[string]*strings.Builder),
92+
}
93+
94+
toolID := "test-tool-with-result-123"
95+
96+
// Store some mock tool parameters
97+
agent.rememberToolContext(session, toolID, map[string]interface{}{
98+
"command": "echo hello",
99+
}, []acp.ToolCallLocation{})
100+
101+
// Send progress messages
102+
progressMsg := "Executing command...\nOutput: hello\n"
103+
err := agent.sendToolCallProgress(session, toolID, progressMsg)
104+
if err != nil {
105+
t.Fatalf("Failed to send progress: %v", err)
106+
}
107+
108+
// Test the combination logic by manually calling popToolContext
109+
// with the final result to simulate what handleToolCallResult does
110+
params, locations, progressText := agent.popToolContext(session, toolID)
111+
finalResult := "\nCommand completed successfully."
112+
113+
// Verify progress was accumulated correctly
114+
if progressText != progressMsg {
115+
t.Errorf("Expected progress: %q, got: %q", progressMsg, progressText)
116+
}
117+
118+
// Simulate the combined result logic from handleToolCallResult
119+
combinedResult := finalResult
120+
if progressText != "" {
121+
if combinedResult != "" {
122+
combinedResult = progressText + combinedResult
123+
} else {
124+
combinedResult = progressText
125+
}
126+
}
127+
128+
expectedCombined := progressMsg + finalResult
129+
if combinedResult != expectedCombined {
130+
t.Errorf("Expected combined result: %q, got: %q", expectedCombined, combinedResult)
131+
}
132+
133+
// Verify params and locations are also retrieved correctly
134+
if params == nil {
135+
t.Error("Expected params to be retrieved")
136+
}
137+
if len(locations) != 0 {
138+
t.Error("Expected empty locations")
139+
}
140+
}
141+
142+
// TestMultipleToolProgresses tests that progress accumulation works correctly
143+
// for multiple tools running concurrently
144+
func TestMultipleToolProgresses(t *testing.T) {
145+
agent := &ScriptschnellAIAgent{}
146+
147+
session := &statcodeSession{
148+
sessionID: "test-multiple-progresses",
149+
promptCtx: context.Background(),
150+
toolLocations: make(map[string][]acp.ToolCallLocation),
151+
toolParams: make(map[string]map[string]interface{}),
152+
toolProgress: make(map[string]*strings.Builder),
153+
}
154+
155+
toolID1 := "tool-1"
156+
toolID2 := "tool-2"
157+
158+
// Send progress to both tools
159+
err := agent.sendToolCallProgress(session, toolID1, "Tool 1 progress A\n")
160+
if err != nil {
161+
t.Fatalf("Failed to send progress to tool 1: %v", err)
162+
}
163+
164+
err = agent.sendToolCallProgress(session, toolID2, "Tool 2 progress X\n")
165+
if err != nil {
166+
t.Fatalf("Failed to send progress to tool 2: %v", err)
167+
}
168+
169+
err = agent.sendToolCallProgress(session, toolID1, "Tool 1 progress B\n")
170+
if err != nil {
171+
t.Fatalf("Failed to send more progress to tool 1: %v", err)
172+
}
173+
174+
// Verify each tool has its own accumulated progress
175+
_, _, progress1 := agent.popToolContext(session, toolID1)
176+
if progress1 != "Tool 1 progress A\nTool 1 progress B\n" {
177+
t.Errorf("Invalid progress for tool 1: %q", progress1)
178+
}
179+
180+
_, _, progress2 := agent.popToolContext(session, toolID2)
181+
if progress2 != "Tool 2 progress X\n" {
182+
t.Errorf("Invalid progress for tool 2: %q", progress2)
183+
}
184+
}

0 commit comments

Comments
 (0)