From 0040b940a44829fa8ba062a74af5a67f72121849 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 10 Apr 2026 14:59:42 +0800 Subject: [PATCH] Return structured single-agent artifacts --- internal/acp/execution.go | 181 +++++++++++++++++++++++++++- internal/acp/providers_sync_test.go | 98 +++++++++++++++ internal/acp/server.go | 8 +- 3 files changed, 282 insertions(+), 5 deletions(-) diff --git a/internal/acp/execution.go b/internal/acp/execution.go index 6750b8b..dba4c86 100644 --- a/internal/acp/execution.go +++ b/internal/acp/execution.go @@ -2,10 +2,15 @@ package acp import ( "context" + "encoding/base64" "encoding/json" "fmt" + "mime" "net/http" "net/url" + "os" + "path/filepath" + "sort" "strings" "time" @@ -175,7 +180,7 @@ func (s *Server) runSingleAgentViaExternalProvider( if len(result) == 0 { result = response } - return collector.apply(result), nil + return enrichSingleAgentResultArtifacts(collector.apply(result), forwardParams), nil } func resolveSingleAgentForwardEndpoint(provider syncedProvider) string { @@ -395,6 +400,180 @@ func (c *externalACPNotificationCollector) apply(result map[string]any) map[stri return result } +func enrichSingleAgentResultArtifacts(result map[string]any, requestParams map[string]any) map[string]any { + if result == nil { + result = map[string]any{} + } + remoteWorkingDirectory := firstNonEmptyString( + shared.StringArg(result, "remoteWorkingDirectory", ""), + shared.StringArg(asMap(result["remoteExecution"]), "remoteWorkingDirectory", ""), + shared.StringArg(result, "resolvedWorkingDirectory", ""), + shared.StringArg(result, "effectiveWorkingDirectory", ""), + shared.StringArg(requestParams, "workingDirectory", ""), + ) + remoteWorkspaceRefKind := firstNonEmptyString( + shared.StringArg(result, "remoteWorkspaceRefKind", ""), + shared.StringArg(asMap(result["remoteExecution"]), "remoteWorkspaceRefKind", ""), + "remotePath", + ) + if strings.TrimSpace(shared.StringArg(result, "resultSummary", "")) == "" { + if summary := firstNonEmptyString( + shared.StringArg(result, "summary", ""), + shared.StringArg(result, "output", ""), + shared.StringArg(result, "message", ""), + ); summary != "" { + result["resultSummary"] = summary + } + } + result["remoteWorkingDirectory"] = remoteWorkingDirectory + result["remoteWorkspaceRefKind"] = remoteWorkspaceRefKind + result["remoteExecution"] = map[string]any{ + "remoteWorkingDirectory": remoteWorkingDirectory, + "remoteWorkspaceRefKind": remoteWorkspaceRefKind, + "provider": shared.StringArg(result, "provider", ""), + "turnId": shared.StringArg(result, "turnId", ""), + } + if len(asSlice(result["artifacts"])) == 0 { + result["artifacts"] = collectInlineArtifactsPayload(requestParams, result) + } + return result +} + +func collectInlineArtifactsPayload(requestParams, result map[string]any) []map[string]any { + roots := []string{ + shared.StringArg(requestParams, "workingDirectory", ""), + shared.StringArg(result, "resolvedWorkingDirectory", ""), + shared.StringArg(result, "effectiveWorkingDirectory", ""), + } + seen := map[string]struct{}{} + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" { + continue + } + if _, ok := seen[root]; ok { + continue + } + seen[root] = struct{}{} + entries := buildArtifactsForRoot(root) + if len(entries) > 0 { + return entries + } + } + return []map[string]any{} +} + +func buildArtifactsForRoot(root string) []map[string]any { + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + return []map[string]any{} + } + type candidate struct { + absolute string + relative string + modTime time.Time + size int64 + } + const maxFiles = 24 + const maxInlineBytes = 2 * 1024 * 1024 + ignoredDirs := map[string]struct{}{ + ".git": {}, ".dart_tool": {}, "build": {}, "node_modules": {}, + } + candidates := make([]candidate, 0, maxFiles) + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + if d.IsDir() { + if _, ignored := ignoredDirs[d.Name()]; ignored && path != root { + return filepath.SkipDir + } + return nil + } + info, err := d.Info() + if err != nil || info.Size() > maxInlineBytes { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return nil + } + relative = filepath.ToSlash(strings.TrimSpace(relative)) + if relative == "" || strings.HasPrefix(relative, "../") { + return nil + } + candidates = append(candidates, candidate{ + absolute: path, + relative: relative, + modTime: info.ModTime(), + size: info.Size(), + }) + return nil + }) + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].modTime.After(candidates[j].modTime) + }) + if len(candidates) > maxFiles { + candidates = candidates[:maxFiles] + } + artifacts := make([]map[string]any, 0, len(candidates)) + for _, item := range candidates { + content, err := os.ReadFile(item.absolute) + if err != nil { + continue + } + contentType := mime.TypeByExtension(filepath.Ext(item.absolute)) + if strings.TrimSpace(contentType) == "" { + contentType = "application/octet-stream" + } + encoding := "base64" + payload := base64.StdEncoding.EncodeToString(content) + if isInlineTextArtifact(item.relative, contentType) { + encoding = "utf8" + payload = string(content) + } + artifacts = append(artifacts, map[string]any{ + "relativePath": item.relative, + "label": filepath.Base(item.absolute), + "contentType": contentType, + "encoding": encoding, + "content": payload, + "sizeBytes": item.size, + }) + } + return artifacts +} + +func isInlineTextArtifact(path, contentType string) bool { + ext := strings.ToLower(filepath.Ext(path)) + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "text/") { + return true + } + switch ext { + case ".md", ".markdown", ".txt", ".log", ".json", ".yaml", ".yml", ".csv", ".html", ".htm": + return true + default: + return false + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func asSlice(value any) []any { + if value == nil { + return nil + } + items, _ := value.([]any) + return items +} + func requestExternalACPWebSocket( ctx context.Context, endpoint *urlSpec, diff --git a/internal/acp/providers_sync_test.go b/internal/acp/providers_sync_test.go index f0438dc..7fa9849 100644 --- a/internal/acp/providers_sync_test.go +++ b/internal/acp/providers_sync_test.go @@ -170,6 +170,104 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) { } } +func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteMetadata(t *testing.T) { + workingDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workingDir, "outputs"), 0o755); err != nil { + t.Fatalf("mkdir outputs: %v", err) + } + if err := os.WriteFile( + filepath.Join(workingDir, "outputs", "report.txt"), + []byte("artifact-body"), + 0o644, + ); err != nil { + t.Fatalf("write artifact: %v", err) + } + + externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/acp/rpc" { + http.NotFound(w, r) + return + } + defer r.Body.Close() + var request map[string]any + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request["id"], + "result": map[string]any{ + "success": true, + "output": "external-provider-ok", + "turnId": "turn-external-artifacts", + "provider": "claude", + "mode": "single-agent", + "resolvedWorkingDirectory": "/remote/threads/task-42", + "resolvedWorkspaceRefKind": "remotePath", + }, + }) + })) + defer externalServer.Close() + + server := NewServer() + server.syncProviders([]syncedProvider{ + { + ProviderID: "claude", + Label: "Claude", + Endpoint: externalServer.URL, + AuthorizationHeader: "Bearer test", + Enabled: true, + }, + }) + + response, rpcErr := server.executeSessionTask(task{ + req: shared.RPCRequest{ + Method: "session.start", + Params: map[string]any{ + "sessionId": "session-external-artifacts", + "threadId": "thread-external-artifacts", + "taskPrompt": "hello from external provider", + "workingDirectory": workingDir, + "routing": map[string]any{ + "routingMode": "explicit", + "explicitExecutionTarget": "singleAgent", + "explicitProviderId": "claude", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected success, got rpc error: %v", rpcErr) + } + if got := response["remoteWorkingDirectory"]; got != "/remote/threads/task-42" { + t.Fatalf("expected remoteWorkingDirectory to be preserved, got %#v", got) + } + if got := response["remoteWorkspaceRefKind"]; got != "remotePath" { + t.Fatalf("expected remoteWorkspaceRefKind remotePath, got %#v", got) + } + artifacts, ok := response["artifacts"].([]map[string]any) + if !ok || len(artifacts) == 0 { + t.Fatalf("expected enriched artifacts, got %#v", response["artifacts"]) + } + artifact := artifacts[0] + if got := artifact["relativePath"]; got != "outputs/report.txt" { + t.Fatalf("expected relativePath outputs/report.txt, got %#v", got) + } + if got := artifact["content"]; got != "artifact-body" { + t.Fatalf("expected inline artifact content, got %#v", got) + } + if got := artifact["encoding"]; got != "utf8" { + t.Fatalf("expected utf8 artifact encoding, got %#v", got) + } + remoteExecution, ok := response["remoteExecution"].(map[string]any) + if !ok { + t.Fatalf("expected remoteExecution metadata, got %#v", response["remoteExecution"]) + } + if got := remoteExecution["remoteWorkingDirectory"]; got != "/remote/threads/task-42" { + t.Fatalf("expected remoteExecution remoteWorkingDirectory, got %#v", got) + } +} + func TestRunSingleAgentUsesFrozenExternalProviderParams(t *testing.T) { externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/acp/rpc" { diff --git a/internal/acp/server.go b/internal/acp/server.go index 9d961c0..d7c6145 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -733,7 +733,7 @@ func (s *Server) runSingleAgent( if _, exists := result["effectiveWorkingDirectory"]; !exists && effectiveWorkingDirectory != "" { result["effectiveWorkingDirectory"] = effectiveWorkingDirectory } - return taskResult{response: result} + return taskResult{response: enrichSingleAgentResultArtifacts(result, params)} } s.emitSessionUpdate(session, notify, turnID, map[string]any{ "type": "status", @@ -778,7 +778,7 @@ func (s *Server) runSingleAgent( if _, exists := result["effectiveWorkingDirectory"]; !exists && effectiveWorkingDirectory != "" { result["effectiveWorkingDirectory"] = effectiveWorkingDirectory } - return taskResult{response: result} + return taskResult{response: enrichSingleAgentResultArtifacts(result, params)} } s.emitSessionUpdate(session, notify, turnID, map[string]any{ "type": "status", @@ -847,14 +847,14 @@ func (s *Server) runSingleAgent( }) return taskResult{ - response: map[string]any{ + response: enrichSingleAgentResultArtifacts(map[string]any{ "success": true, "output": output, "turnId": turnID, "mode": "single-agent", "provider": provider, "effectiveWorkingDirectory": effectiveWorkingDirectory, - }, + }, params), } }