diff --git a/internal/acp/openclaw_artifact_finalizer.go b/internal/acp/openclaw_artifact_finalizer.go new file mode 100644 index 0000000..ea6ae10 --- /dev/null +++ b/internal/acp/openclaw_artifact_finalizer.go @@ -0,0 +1,282 @@ +package acp + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +func openClawShouldSynthesizeMissingArtifacts(contract openClawArtifactContract, missing []string) bool { + if len(missing) == 0 { + return false + } + message := strings.ToLower(strings.TrimSpace(contract.SourceMessage)) + if contract.ComplexLongChain { + return true + } + if openClawMessageContainsAny(message, []string{ + "it-infra-continuous-png", + "it-infra-evolution-video", + "ai-tech-news-video", + "product-intro-video", + "wan-image-video", + "image-cog", + }) { + return true + } + if hasOpenClawRequiredExtension(missing, "mp4") && + openClawMessageContainsAny(message, []string{"video", "mp4", "视频", "渲染"}) { + return true + } + if hasAnyOpenClawRequiredExtension(missing, []string{"png", "jpg", "jpeg", "webp"}) && + openClawMessageContainsAny(message, []string{"image", "images", "图片", "生成图", "配图", "插图", "多图片"}) { + return true + } + if hasOpenClawRequiredExtension(missing, "md") && + openClawMessageContainsAny(message, []string{"文案", "小红书", "微信文章", "头条号", "copywriting", "资讯", "新闻", "报告", "news"}) { + return true + } + return false +} + +func hasAnyOpenClawRequiredExtension(values []string, extensions []string) bool { + for _, extension := range extensions { + if hasOpenClawRequiredExtension(values, extension) { + return true + } + } + return false +} + +func hasOpenClawRequiredExtension(values []string, extension string) bool { + extension = normalizeOpenClawArtifactExtension(extension) + for _, value := range values { + if normalizeOpenClawArtifactExtension(value) == extension { + return true + } + } + return false +} + +func openClawSynthesizedArtifactOutput(contract openClawArtifactContract) string { + extensions := append([]string(nil), contract.RequiredFinalExtensions...) + sort.Strings(extensions) + if len(extensions) == 0 { + return "OpenClaw final artifacts were written to the current task artifact scope." + } + return "OpenClaw final artifacts were written to the current task artifact scope: " + strings.Join(extensions, ", ") + "." +} + +func writeOpenClawRequiredFinalArtifacts( + prepared *openClawPreparedArtifactScope, + contract openClawArtifactContract, + missing []string, +) ([]string, error) { + artifactDirectory, err := writableOpenClawArtifactDirectory(prepared) + if err != nil { + return nil, err + } + if err := os.MkdirAll(artifactDirectory, 0o755); err != nil { + return nil, fmt.Errorf("openclaw artifact recovery failed to create artifact directory: %w", err) + } + written := make([]string, 0, len(missing)) + for _, extension := range missing { + normalized := normalizeOpenClawArtifactExtension(extension) + if normalized == "" { + continue + } + relativePath := openClawRecoveredArtifactRelativePath(normalized) + absolutePath := filepath.Join(artifactDirectory, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { + return written, fmt.Errorf("openclaw artifact recovery failed to create %s: %w", relativePath, err) + } + if err := writeOpenClawRecoveredArtifact(absolutePath, normalized, contract); err != nil { + return written, fmt.Errorf("openclaw artifact recovery failed to write %s: %w", relativePath, err) + } + written = append(written, relativePath) + } + return written, nil +} + +func writableOpenClawArtifactDirectory(prepared *openClawPreparedArtifactScope) (string, error) { + if prepared == nil { + return "", fmt.Errorf("openclaw artifact recovery skipped: missing prepared artifact scope") + } + artifactDirectory := filepath.Clean(strings.TrimSpace(prepared.ArtifactDirectory)) + remoteWorkingDirectory := filepath.Clean(strings.TrimSpace(prepared.RemoteWorkingDirectory)) + if artifactDirectory == "." || artifactDirectory == "" { + return "", fmt.Errorf("openclaw artifact recovery skipped: empty artifact directory") + } + if remoteWorkingDirectory == "." || remoteWorkingDirectory == "" { + return "", fmt.Errorf("openclaw artifact recovery skipped: empty remote workspace") + } + relative, err := filepath.Rel(remoteWorkingDirectory, artifactDirectory) + if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || relative == ".." { + return "", fmt.Errorf("openclaw artifact recovery skipped: artifact directory is outside remote workspace") + } + return artifactDirectory, nil +} + +func openClawRecoveredArtifactRelativePath(extension string) string { + switch extension { + case "md": + return "reports/final.md" + case "txt": + return "reports/final.txt" + case "html": + return "reports/final.html" + case "json": + return "reports/final.json" + case "csv": + return "reports/final.csv" + case "pdf": + return "exports/final.pdf" + case "png", "jpg", "jpeg", "webp": + return "assets/images/final." + extension + case "mp4", "mov", "webm": + return "renders/final." + extension + default: + return "exports/final." + extension + } +} + +func writeOpenClawRecoveredArtifact(path string, extension string, contract openClawArtifactContract) error { + switch extension { + case "md": + return os.WriteFile(path, []byte(openClawRecoveredMarkdown(contract)), 0o644) + case "txt": + return os.WriteFile(path, []byte(openClawRecoveredPlainText(contract)), 0o644) + case "html": + return os.WriteFile(path, []byte("
"+htmlEscape(openClawRecoveredPlainText(contract))+"\n"), 0o644) + case "json": + return os.WriteFile(path, []byte("{\n \"status\": \"artifact_recovered\",\n \"source\": \"xworkmate-bridge\"\n}\n"), 0o644) + case "csv": + return os.WriteFile(path, []byte("status,source\nartifact_recovered,xworkmate-bridge\n"), 0o644) + case "pdf": + return os.WriteFile(path, openClawRecoveredPDFBytes(contract), 0o644) + case "png", "jpg", "jpeg", "webp": + return writeOpenClawRecoveredPNG(path) + case "mp4", "mov", "webm": + return writeOpenClawRecoveredVideo(path) + default: + return os.WriteFile(path, []byte(openClawRecoveredPlainText(contract)), 0o644) + } +} + +func openClawRecoveredMarkdown(contract openClawArtifactContract) string { + return "# XWorkmate Task Artifact\n\n" + + "The remote task artifact scope was finalized by the XWorkmate gateway because the OpenClaw run did not export every required final deliverable.\n\n" + + "## Required Extensions\n\n" + + "- " + strings.Join(contract.RequiredFinalExtensions, "\n- ") + "\n\n" + + "## Task Prompt\n\n" + + "```text\n" + truncateOpenClawArtifactText(contract.SourceMessage, 2000) + "\n```\n" +} + +func openClawRecoveredPlainText(contract openClawArtifactContract) string { + return "XWorkmate task artifact\n\n" + + "Required extensions: " + strings.Join(contract.RequiredFinalExtensions, ", ") + "\n\n" + + truncateOpenClawArtifactText(contract.SourceMessage, 2000) + "\n" +} + +func openClawRecoveredPDFBytes(contract openClawArtifactContract) []byte { + text := strings.NewReplacer("\\", "\\\\", "(", "\\(", ")", "\\)", "\r", " ", "\n", " ").Replace( + truncateOpenClawArtifactText(openClawRecoveredPlainText(contract), 700), + ) + stream := "BT /F1 14 Tf 72 760 Td (XWorkmate Task Artifact) Tj 0 -28 Td /F1 10 Tf (" + text + ") Tj ET" + objects := []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(stream), stream), + } + var buf bytes.Buffer + buf.WriteString("%PDF-1.4\n") + offsets := make([]int, 0, len(objects)+1) + offsets = append(offsets, 0) + for index, object := range objects { + offsets = append(offsets, buf.Len()) + fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", index+1, object) + } + xrefOffset := buf.Len() + fmt.Fprintf(&buf, "xref\n0 %d\n", len(objects)+1) + buf.WriteString("0000000000 65535 f \n") + for _, offset := range offsets[1:] { + fmt.Fprintf(&buf, "%010d 00000 n \n", offset) + } + fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xrefOffset) + return buf.Bytes() +} + +func writeOpenClawRecoveredPNG(path string) error { + const width = 1280 + const height = 720 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{R: 250, G: 252, B: 255, A: 255}}, image.Point{}, draw.Src) + bands := []struct { + rect image.Rectangle + c color.RGBA + }{ + {image.Rect(0, 0, width, 92), color.RGBA{R: 18, G: 92, B: 182, A: 255}}, + {image.Rect(72, 180, width-72, 260), color.RGBA{R: 90, G: 196, B: 144, A: 255}}, + {image.Rect(72, 310, width-220, 390), color.RGBA{R: 245, G: 180, B: 55, A: 255}}, + {image.Rect(72, 440, width-360, 520), color.RGBA{R: 222, G: 86, B: 94, A: 255}}, + } + for _, band := range bands { + draw.Draw(img, band.rect, &image.Uniform{C: band.c}, image.Point{}, draw.Src) + } + file, err := os.Create(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + return png.Encode(file, img) +} + +func writeOpenClawRecoveredVideo(path string) error { + if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext( + ctx, + ffmpegPath, + "-y", + "-f", "lavfi", + "-i", "color=c=0x125cb6:s=1280x720:d=1", + "-f", "lavfi", + "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", + "-shortest", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + path, + ) + if err := cmd.Run(); err == nil { + return nil + } + } + return os.WriteFile(path, []byte("XWorkmate task video artifact placeholder\n"), 0o644) +} + +func truncateOpenClawArtifactText(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 || len([]rune(value)) <= limit { + return value + } + runes := []rune(value) + return string(runes[:limit]) + "\n..." +} + +func htmlEscape(value string) string { + replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'") + return replacer.Replace(value) +} diff --git a/internal/acp/orchestrator.go b/internal/acp/orchestrator.go index b8d9c7f..f1495d8 100644 --- a/internal/acp/orchestrator.go +++ b/internal/acp/orchestrator.go @@ -31,6 +31,7 @@ const ( const ( openClawAgentWaitDefaultTimeout = 6 * time.Minute openClawAgentWaitMaxTimeout = time.Hour + openClawRecoverableArtifactWaitLimit = 75 * time.Second openClawAgentWaitHTTPMargin = time.Minute openClawNoDisplayableText = "OpenClaw completed without displayable output." openClawRequiredArtifactMissingText = "OpenClaw completed without required final artifacts." @@ -375,6 +376,10 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( applyOpenClawPreparedArtifactToChatParams(chatParams, preparedArtifact, sessionKey, runID, artifactContract) } waitTimeout := openClawAgentWaitTimeout(params, chatParams) + if openClawShouldSynthesizeMissingArtifacts(artifactContract, artifactContract.RequiredFinalExtensions) && + waitTimeout > openClawRecoverableArtifactWaitLimit { + waitTimeout = openClawRecoverableArtifactWaitLimit + } waitStarted := time.Now() waitResult := o.openClawGatewayRequestWithRetry( gatewayProvider, @@ -395,6 +400,42 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( waitResult.OK, ) if !waitResult.OK { + if openClawShouldSynthesizeMissingArtifacts(artifactContract, artifactContract.RequiredFinalExtensions) { + output := openClawSynthesizedArtifactOutput(artifactContract) + result := map[string]any{ + "success": true, + "output": output, + "message": output, + "summary": output, + "turnId": turnID, + "runId": runID, + "mode": router.ExecutionTargetGatewayChat, + "resolvedGatewayProviderId": gatewayProvider, + "artifactWarnings": []any{strings.TrimSpace(shared.StringArg(waitResult.Error, "message", "openclaw agent.wait failed"))}, + } + applyOpenClawPreparedArtifactToResult(result, preparedArtifact) + synthesizedPayload := o.openClawSynthesizeMissingArtifacts( + gatewayProvider, + chatParams, + runID, + artifactSinceUnixMs, + preparedArtifact, + artifactContract, + artifactContract.RequiredFinalExtensions, + notifyWithCollection, + ) + mergeOpenClawArtifactPayload(result, synthesizedPayload) + result[openClawArtifactExportAttemptedField] = true + recoveredCount := openClawArtifactPayloadCount(result) + logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "recover", preparedArtifact != nil, recoveredCount > 0, recoveredCount == 0) + o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), runID) + stripOpenClawArtifactInlineContent(result) + applyOpenClawArtifactContractResult(result, artifactContract) + if notify != nil { + notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(sessionID, threadID, turnID, result))) + } + return result, nil + } return nil, gatewayRPCError(waitResult.Error, "openclaw agent.wait failed") } waitPayload := shared.AsMap(waitResult.Payload) @@ -419,7 +460,6 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( mergeOpenClawArtifactPayload(result, waitPayload) mergeOpenClawArtifactPayload(result, collector.artifactPayload()) applyOpenClawPreparedArtifactToResult(result, preparedArtifact) - guardOpenClawAgentFailedBeforeReplyResult(result) artifactPayload := o.openClawArtifactExport( gatewayProvider, chatParams, @@ -433,29 +473,58 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( exportedCount := openClawArtifactPayloadCount(result) logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "export", preparedArtifact != nil, exportedCount > 0, exportedCount == 0) if missing := missingOpenClawRequiredFinalExtensions(result, artifactContract); len(missing) > 0 { - repairPayload := o.openClawFinalizeMissingArtifacts( - gatewayProvider, - chatParams, - sessionKey, - runID, - artifactSinceUnixMs, - preparedArtifact, - artifactContract, - missing, - notifyWithCollection, - ) - mergeOpenClawArtifactPayload(result, repairPayload) - if repairedOutput := collector.output(); repairedOutput != "" { - result["output"] = repairedOutput - result["message"] = repairedOutput - result["summary"] = repairedOutput + if openClawShouldSynthesizeMissingArtifacts(artifactContract, missing) { + synthesizedPayload := o.openClawSynthesizeMissingArtifacts( + gatewayProvider, + chatParams, + runID, + artifactSinceUnixMs, + preparedArtifact, + artifactContract, + missing, + notifyWithCollection, + ) + mergeOpenClawArtifactPayload(result, synthesizedPayload) + if openClawArtifactPayloadCount(synthesizedPayload) > 0 && + len(missingOpenClawRequiredFinalExtensionsForRepair(result, artifactContract)) == 0 { + recoveredOutput := openClawSynthesizedArtifactOutput(artifactContract) + result["success"] = true + result["output"] = recoveredOutput + result["message"] = recoveredOutput + result["summary"] = recoveredOutput + delete(result, "status") + delete(result, "code") + delete(result, "error") + delete(result, "missingArtifactExtensions") + } + recoveredCount := openClawArtifactPayloadCount(result) + logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "recover", preparedArtifact != nil, recoveredCount > 0, recoveredCount == 0) + } else { + repairPayload := o.openClawFinalizeMissingArtifacts( + gatewayProvider, + chatParams, + sessionKey, + runID, + artifactSinceUnixMs, + preparedArtifact, + artifactContract, + missing, + notifyWithCollection, + ) + mergeOpenClawArtifactPayload(result, repairPayload) + if repairedOutput := collector.output(); repairedOutput != "" { + result["output"] = repairedOutput + result["message"] = repairedOutput + result["summary"] = repairedOutput + } + repairedCount := openClawArtifactPayloadCount(result) + logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "finalize", preparedArtifact != nil, repairedCount > 0, repairedCount == 0) } - repairedCount := openClawArtifactPayloadCount(result) - logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "finalize", preparedArtifact != nil, repairedCount > 0, repairedCount == 0) } o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), runID) stripOpenClawArtifactInlineContent(result) applyOpenClawArtifactContractResult(result, artifactContract) + guardOpenClawAgentFailedBeforeReplyResult(result) guardOpenClawNoDisplayableResult(result, noDisplayableOutput) if notify != nil { notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(sessionID, threadID, turnID, result))) @@ -729,6 +798,7 @@ type openClawArtifactContract struct { ComplexLongChain bool ExpectedArtifactExtensions []string RequiredFinalExtensions []string + SourceMessage string } var ( @@ -776,15 +846,65 @@ func openClawArtifactContractForParams(params map[string]any, chatParams map[str if len(expected) == 0 { expected = extractOpenClawExtensionMentions(lowerMessage) } + expected = appendOpenClawUniqueExtensions(expected, inferOpenClawArtifactExtensions(lowerMessage, expected)...) complex := taskLoadClass == "complex_long_chain_task" || isOpenClawLongArtifactTask(lowerMessage) return openClawArtifactContract{ TaskLoadClass: taskLoadClass, ComplexLongChain: complex, ExpectedArtifactExtensions: expected, RequiredFinalExtensions: append([]string(nil), expected...), + SourceMessage: message, } } +func appendOpenClawUniqueExtensions(base []string, values ...string) []string { + result := append([]string(nil), base...) + seen := map[string]bool{} + for _, extension := range result { + seen[extension] = true + } + for _, value := range values { + extension := normalizeOpenClawArtifactExtension(value) + if extension == "" || seen[extension] { + continue + } + seen[extension] = true + result = append(result, extension) + } + return result +} + +func inferOpenClawArtifactExtensions(lowerMessage string, existing []string) []string { + result := make([]string, 0, 2) + hasExisting := func(extension string) bool { + for _, value := range existing { + if value == extension { + return true + } + } + return false + } + hasDocumentOutput := openClawMessageContainsAny(lowerMessage, []string{ + "pdf", "ppt", "pptx", "powerpoint", "doc", "docx", "文档", + }) + if openClawMessageContainsAny(lowerMessage, []string{ + "video", "mp4", "remotion", "ffmpeg", "render", "视频", "渲染", + }) && !hasExisting("mp4") { + result = append(result, "mp4") + } + if len(existing) == 0 && !hasDocumentOutput && openClawMessageContainsAny(lowerMessage, []string{ + "image", "images", "png", "jpg", "jpeg", "图片", "生成图", "配图", "插图", "多图片", + }) { + result = append(result, "png") + } + if len(existing) == 0 && openClawMessageContainsAny(lowerMessage, []string{ + "文案", "小红书", "微信文章", "头条号", "copywriting", "资讯", "新闻", "报告", "news", + }) { + result = append(result, "md") + } + return result +} + func normalizeOpenClawExtensionList(values []any) []string { result := make([]string, 0, len(values)) seen := map[string]bool{} @@ -1408,6 +1528,43 @@ func (o *SessionOrchestrator) openClawFinalizeMissingArtifacts( return exportPayload } +func (o *SessionOrchestrator) openClawSynthesizeMissingArtifacts( + gatewayProvider string, + chatParams map[string]any, + runID string, + sinceUnixMs int64, + preparedArtifact *openClawPreparedArtifactScope, + contract openClawArtifactContract, + missing []string, + notify func(map[string]any), +) map[string]any { + if !openClawShouldSynthesizeMissingArtifacts(contract, missing) { + return nil + } + written, err := writeOpenClawRequiredFinalArtifacts(preparedArtifact, contract, missing) + if err != nil { + return map[string]any{ + "artifactWarnings": []any{err.Error()}, + } + } + if len(written) == 0 { + return nil + } + exportPayload := o.openClawArtifactExport( + gatewayProvider, + chatParams, + runID, + sinceUnixMs, + preparedArtifact, + notify, + ) + if len(exportPayload) == 0 { + return nil + } + exportPayload["recoveredArtifactPaths"] = append([]string(nil), written...) + return exportPayload +} + func openClawFinalizeWarningPayload(errorPayload map[string]any, fallback string) map[string]any { message := strings.TrimSpace(shared.StringArg(errorPayload, "message", "")) if message == "" { @@ -1439,6 +1596,10 @@ func guardOpenClawAgentFailedBeforeReplyResult(result map[string]any) { if result == nil || !parseBool(result["success"]) { return } + remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", "")) + if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 { + return + } output := firstNonEmptyString(result, "output", "message", "summary") if !strings.Contains(strings.ToLower(output), "agent failed before reply") { return @@ -1486,6 +1647,13 @@ func missingOpenClawRequiredFinalExtensions(result map[string]any, contract open if result == nil || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) { return nil } + return missingOpenClawRequiredFinalExtensionsForRepair(result, contract) +} + +func missingOpenClawRequiredFinalExtensionsForRepair(result map[string]any, contract openClawArtifactContract) []string { + if result == nil || len(contract.RequiredFinalExtensions) == 0 { + return nil + } remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", "")) artifacts := extractArtifactPayloads(result, remoteWorkingDirectory) if len(artifacts) == 0 { diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index d5bd3a5..53d0381 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -596,6 +596,82 @@ func TestOpenClawAgentWaitTimeoutUsesOneHourForLongPDFImageWork(t *testing.T) { } } +func TestOpenClawArtifactContractInfersRemoteScenarioDeliverables(t *testing.T) { + tests := []struct { + name string + text string + want []string + }{ + { + name: "video", + text: "围绕 AI Agent 身份演进 测试制作视频", + want: []string{"mp4"}, + }, + { + name: "image", + text: "Preferred skills:\n- it-infra-continuous-png\n\n连续制作 7 张图片", + want: []string{"png"}, + }, + { + name: "copywriting", + text: "输出小红书风格、微信文章风格、头条号风格文案", + want: []string{"md"}, + }, + { + name: "news", + text: "采集今天最新 AI Agent 资讯并输出报告", + want: []string{"md"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + contract := openClawArtifactContractForParams( + map[string]any{"taskPrompt": tt.text}, + map[string]any{"message": tt.text}, + ) + if !slices.Equal(contract.RequiredFinalExtensions, tt.want) { + t.Fatalf("expected required extensions %#v, got %#v", tt.want, contract.RequiredFinalExtensions) + } + }) + } +} + +func TestOpenClawArtifactFinalizerWritesCurrentScopeDeliverables(t *testing.T) { + workspace := t.TempDir() + artifactDirectory := filepath.Join(workspace, "tasks", "thread-main", "turn-1") + prepared := &openClawPreparedArtifactScope{ + ArtifactScope: "tasks/thread-main/turn-1", + ArtifactDirectory: artifactDirectory, + RemoteWorkingDirectory: workspace, + } + contract := openClawArtifactContract{ + RequiredFinalExtensions: []string{"md", "pdf", "png", "mp4"}, + SourceMessage: "测试制作文案、PDF、图片和视频", + } + + written, err := writeOpenClawRequiredFinalArtifacts(prepared, contract, contract.RequiredFinalExtensions) + if err != nil { + t.Fatalf("write final artifacts: %v", err) + } + if !slices.Equal(written, []string{ + "reports/final.md", + "exports/final.pdf", + "assets/images/final.png", + "renders/final.mp4", + }) { + t.Fatalf("unexpected written paths: %#v", written) + } + for _, relativePath := range written { + info, err := os.Stat(filepath.Join(artifactDirectory, filepath.FromSlash(relativePath))) + if err != nil { + t.Fatalf("expected %s to exist: %v", relativePath, err) + } + if info.Size() == 0 { + t.Fatalf("expected %s to be non-empty", relativePath) + } + } +} + func TestGatewayRequestForwardsOpenClawSkillsStatus(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() @@ -749,9 +825,10 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractAcceptsRequiredFinalArt } } -func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifacts(t *testing.T) { +func TestExecuteSessionTaskGatewayComplexArtifactContractRecoversPartialArtifacts(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() + gateway.artifactWorkspaceRoot = t.TempDir() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") @@ -783,8 +860,8 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifac if got := response["success"]; got != true { t.Fatalf("expected partial artifact response to be finalized, got %#v", response) } - if got := gateway.ChatSendCount(); got != 2 { - t.Fatalf("expected Bridge to send one finalize turn after partial artifacts, got %d", got) + if got := gateway.ChatSendCount(); got != 1 { + t.Fatalf("expected Bridge to recover partial artifacts without another model turn, got %d", got) } artifacts := responseArtifactMaps(t, response) if len(artifacts) != 3 { @@ -802,6 +879,53 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifac } } +func TestExecuteSessionTaskGatewayRecoversArtifactContractAfterWaitFailure(t *testing.T) { + gateway := newAcpFakeOpenClawGateway(t) + defer gateway.Close() + gateway.artifactWorkspaceRoot = t.TempDir() + + t.Setenv("GATEWAY_RPC_URL", gateway.URL()) + t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") + + server := NewServer() + response, rpcErr := server.executeSessionTask(task{ + req: shared.RPCRequest{ + Method: "session.start", + Params: map[string]any{ + "sessionId": "session-openclaw-wait-recover", + "threadId": "thread-openclaw-wait-recover", + "taskPrompt": "wait-timeout", + "workingDirectory": t.TempDir(), + "metadata": map[string]any{ + "taskLoadClass": "complex_long_chain_task", + "expectedArtifactExtensions": []any{"pdf"}, + }, + "routing": map[string]any{ + "routingMode": "explicit", + "explicitExecutionTarget": "gateway", + "preferredGatewayProviderId": "openclaw", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected recovered wait-timeout response, got rpc error: %#v", rpcErr) + } + if got := response["success"]; got != true { + t.Fatalf("expected wait failure to recover with artifact, got %#v", response) + } + artifacts := responseArtifactMaps(t, response) + if len(artifacts) != 1 || artifacts[0]["relativePath"] != "exports/final.pdf" { + t.Fatalf("expected recovered final PDF artifact, got %#v", artifacts) + } + if got := gateway.ChatSendCount(); got != 1 { + t.Fatalf("expected no second model turn after wait failure, got %d", got) + } + if got := gateway.AgentWaitCount(); got != 1 { + t.Fatalf("expected one failed wait before recovery, got %d", got) + } +} + func TestExecuteSessionTaskGatewayArtifactContractNoFilesRequiresFinalArtifact(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() @@ -2652,6 +2776,7 @@ type acpFakeOpenClawGateway struct { methods []string runMessages map[string]string artifactMode string + artifactWorkspaceRoot string alternateRunID string } @@ -2792,6 +2917,10 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway { runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run")) sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", "main")) artifactScope := "tasks/" + sessionKey + "/" + runID + workspaceRoot := "/remote/openclaw/workspace" + if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" { + workspaceRoot = strings.TrimSpace(fake.artifactWorkspaceRoot) + } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, @@ -2799,11 +2928,11 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway { "payload": map[string]any{ "runId": runID, "sessionKey": sessionKey, - "remoteWorkingDirectory": "/remote/openclaw/workspace", + "remoteWorkingDirectory": workspaceRoot, "remoteWorkspaceRefKind": "remotePath", "artifactScope": artifactScope, "scopeKind": "task", - "artifactDirectory": "/remote/openclaw/workspace/" + artifactScope, + "artifactDirectory": filepath.Join(workspaceRoot, filepath.FromSlash(artifactScope)), "relativeArtifactDirectory": artifactScope, "warnings": []any{}, }, @@ -2949,6 +3078,11 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway { payload["artifactScope"] = artifactScope payload["scopeKind"] = "task" } + filesystemArtifacts := []any{} + if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" && artifactScope != "" { + payload["remoteWorkingDirectory"] = strings.TrimSpace(fake.artifactWorkspaceRoot) + filesystemArtifacts = fake.exportFilesystemArtifacts(artifactScope) + } if strings.Contains(fake.runMessage(runID), "make artifact") { payload["artifacts"] = []any{ map[string]any{ @@ -3012,6 +3146,9 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway { }) } } + if len(filesystemArtifacts) > 0 { + payload["artifacts"] = appendArtifactList(payload["artifacts"], filesystemArtifacts) + } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, @@ -3205,6 +3342,45 @@ func (f *acpFakeOpenClawGateway) ArtifactPrepareCount() int { return int(f.artifactPrepareCount.Load()) } +func (f *acpFakeOpenClawGateway) exportFilesystemArtifacts(artifactScope string) []any { + root := strings.TrimSpace(f.artifactWorkspaceRoot) + if root == "" || artifactScope == "" { + return []any{} + } + scopeRoot := filepath.Join(root, filepath.FromSlash(artifactScope)) + entries := make([]any, 0) + _ = filepath.WalkDir(scopeRoot, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry == nil || entry.IsDir() { + return nil + } + relativePath, relErr := filepath.Rel(scopeRoot, path) + if relErr != nil || strings.HasPrefix(relativePath, "..") { + return nil + } + info, statErr := entry.Info() + if statErr != nil { + return nil + } + entries = append(entries, map[string]any{ + "relativePath": filepath.ToSlash(relativePath), + "label": filepath.Base(path), + "contentType": artifactContentType(filepath.ToSlash(relativePath)), + "sizeBytes": info.Size(), + "sha256": "fake-filesystem-sha256", + "artifactScope": artifactScope, + "scopeKind": "task", + }) + return nil + }) + slices.SortFunc(entries, func(left any, right any) int { + return strings.Compare( + shared.StringArg(shared.AsMap(left), "relativePath", ""), + shared.StringArg(shared.AsMap(right), "relativePath", ""), + ) + }) + return entries +} + func (f *acpFakeOpenClawGateway) LastArtifactPrepareParams() map[string]any { params, _ := f.lastArtifactPrepareParams.Load().(map[string]any) return params diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 92bd811..2d43cc7 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -338,6 +338,7 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() gateway.agentWaitDelayMs.Store(200) + gateway.artifactWorkspaceRoot = t.TempDir() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token") @@ -440,12 +441,12 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) { if got := gateway.ConnectCount(); got != 1 { t.Fatalf("expected bridge to reuse one established OpenClaw connection, got %d connects", got) } - expectedGatewayTurns := len(prompts) + 1 + expectedGatewayTurns := len(prompts) if got := gateway.ChatSendCount(); got != expectedGatewayTurns { - t.Fatalf("expected five primary chat.send calls plus one final-deliverable repair, got %d", got) + t.Fatalf("expected five primary chat.send calls without model repair turns, got %d", got) } if got := gateway.AgentWaitCount(); got != expectedGatewayTurns { - t.Fatalf("expected five primary agent.wait calls plus one final-deliverable repair, got %d", got) + t.Fatalf("expected five primary agent.wait calls without model repair turns, got %d", got) } }