From f239239599e85c98e0f37e2b811b01ffdd22c3a7 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 2 Jun 2026 00:31:28 +0800 Subject: [PATCH] fix(openclaw): enforce artifact contracts at bridge --- internal/acp/orchestrator.go | 24 ++++++- internal/acp/routing_test.go | 118 +++++++++++++++++++++++++++++++---- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/internal/acp/orchestrator.go b/internal/acp/orchestrator.go index f8d3d8c..b8d9c7f 100644 --- a/internal/acp/orchestrator.go +++ b/internal/acp/orchestrator.go @@ -419,6 +419,7 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( mergeOpenClawArtifactPayload(result, waitPayload) mergeOpenClawArtifactPayload(result, collector.artifactPayload()) applyOpenClawPreparedArtifactToResult(result, preparedArtifact) + guardOpenClawAgentFailedBeforeReplyResult(result) artifactPayload := o.openClawArtifactExport( gatewayProvider, chatParams, @@ -1434,6 +1435,23 @@ func guardOpenClawNoDisplayableResult(result map[string]any, noDisplayableOutput result["summary"] = openClawNoDisplayableText } +func guardOpenClawAgentFailedBeforeReplyResult(result map[string]any) { + if result == nil || !parseBool(result["success"]) { + return + } + output := firstNonEmptyString(result, "output", "message", "summary") + if !strings.Contains(strings.ToLower(output), "agent failed before reply") { + return + } + result["success"] = false + result["status"] = "failed" + result["code"] = "OPENCLAW_AGENT_FAILED_BEFORE_REPLY" + result["error"] = output + result["message"] = output + result["output"] = output + result["summary"] = output +} + func applyOpenClawArtifactContractResult(result map[string]any, contract openClawArtifactContract) { if result == nil { return @@ -1447,7 +1465,7 @@ func applyOpenClawArtifactContractResult(result map[string]any, contract openCla if len(contract.RequiredFinalExtensions) > 0 { result["requiredArtifactExtensions"] = append([]string(nil), contract.RequiredFinalExtensions...) } - if !contract.ComplexLongChain || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) { + if len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) { return } missing := missingOpenClawRequiredFinalExtensions(result, contract) @@ -1465,13 +1483,13 @@ func applyOpenClawArtifactContractResult(result map[string]any, contract openCla } func missingOpenClawRequiredFinalExtensions(result map[string]any, contract openClawArtifactContract) []string { - if result == nil || !contract.ComplexLongChain || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) { + if result == nil || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) { return nil } remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", "")) artifacts := extractArtifactPayloads(result, remoteWorkingDirectory) if len(artifacts) == 0 { - return nil + return append([]string(nil), contract.RequiredFinalExtensions...) } return missingOpenClawArtifactExtensions(artifacts, contract.RequiredFinalExtensions) } diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index b8a4549..d5bd3a5 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -802,7 +802,7 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifac } } -func TestExecuteSessionTaskGatewayComplexArtifactContractNoFilesKeepsNoDisplayableOutput(t *testing.T) { +func TestExecuteSessionTaskGatewayArtifactContractNoFilesRequiresFinalArtifact(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() @@ -833,17 +833,111 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractNoFilesKeepsNoDisplayab if rpcErr != nil { t.Fatalf("expected structured no-output response, got rpc error: %#v", rpcErr) } - if got := response["code"]; got != "OPENCLAW_NO_DISPLAYABLE_OUTPUT" { - t.Fatalf("expected no-displayable code for empty complex run, got %#v", response) + if got := response["success"]; got != false { + t.Fatalf("expected missing artifact response to fail, got %#v", response) + } + if got := response["status"]; got != "failed" { + t.Fatalf("expected failed status for missing artifact response, got %#v", response) + } + if got := response["code"]; got != "OPENCLAW_REQUIRED_ARTIFACT_MISSING" { + t.Fatalf("expected required artifact code for empty artifact run, got %#v", response) } if got := response["expectedArtifactExtensions"]; fmt.Sprint(got) != "[pdf]" { t.Fatalf("expected expected extension diagnostics, got %#v", response) } + if got := response["missingArtifactExtensions"]; fmt.Sprint(got) != "[pdf]" { + t.Fatalf("expected missing extension diagnostics, got %#v", response) + } if got := strings.TrimSpace(shared.StringArg(response, "artifactScope", "")); got == "" { t.Fatalf("expected artifact scope diagnostics, got %#v", response) } } +func TestExecuteSessionTaskGatewaySimpleArtifactContractNoFilesRequiresFinalArtifact(t *testing.T) { + gateway := newAcpFakeOpenClawGateway(t) + defer gateway.Close() + + 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-simple-md", + "threadId": "thread-openclaw-simple-md", + "taskPrompt": "silent-turn", + "workingDirectory": t.TempDir(), + "metadata": map[string]any{ + "expectedArtifactExtensions": []any{"md"}, + }, + "routing": map[string]any{ + "routingMode": "explicit", + "explicitExecutionTarget": "gateway", + "preferredGatewayProviderId": "openclaw", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected structured missing-artifact response, got rpc error: %#v", rpcErr) + } + if got := response["success"]; got != false { + t.Fatalf("expected simple missing artifact response to fail, got %#v", response) + } + if got := response["code"]; got != "OPENCLAW_REQUIRED_ARTIFACT_MISSING" { + t.Fatalf("expected required artifact code for simple artifact run, got %#v", response) + } + if got := response["requiredArtifactExtensions"]; fmt.Sprint(got) != "[md]" { + t.Fatalf("expected required extension diagnostics, got %#v", response) + } + if got := response["missingArtifactExtensions"]; fmt.Sprint(got) != "[md]" { + t.Fatalf("expected missing extension diagnostics, got %#v", response) + } +} + +func TestExecuteSessionTaskGatewayAgentFailedBeforeReplyReturnsFailureCode(t *testing.T) { + gateway := newAcpFakeOpenClawGateway(t) + defer gateway.Close() + + 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-agent-failed", + "threadId": "thread-openclaw-agent-failed", + "taskPrompt": "agent failed before reply", + "workingDirectory": t.TempDir(), + "routing": map[string]any{ + "routingMode": "explicit", + "explicitExecutionTarget": "gateway", + "preferredGatewayProviderId": "openclaw", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected structured agent failure response, got rpc error: %#v", rpcErr) + } + if got := response["success"]; got != false { + t.Fatalf("expected agent failure response to fail, got %#v", response) + } + if got := response["status"]; got != "failed" { + t.Fatalf("expected failed status for agent failure, got %#v", response) + } + if got := response["code"]; got != "OPENCLAW_AGENT_FAILED_BEFORE_REPLY" { + t.Fatalf("expected agent failure code, got %#v", response) + } + if got := shared.StringArg(response, "error", ""); !strings.Contains(got, "No available auth profile") { + t.Fatalf("expected agent failure details, got %#v", response) + } +} + func TestExecuteSessionMessageGatewayUsesOpenClawChatSend(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() @@ -2405,7 +2499,7 @@ func TestExecuteSessionTaskGatewayAlwaysSyncsGatewayArtifactsAfterRun(t *testing } } -func TestExecuteSessionTaskGatewayDoesNotFailMissingFilesFromPromptHeuristic(t *testing.T) { +func TestExecuteSessionTaskGatewayFailsHallucinatedFileClaimsWithoutArtifacts(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() @@ -2432,18 +2526,17 @@ func TestExecuteSessionTaskGatewayDoesNotFailMissingFilesFromPromptHeuristic(t * if rpcErr != nil { t.Fatalf("expected bridge response, got rpc error: %#v", rpcErr) } - if success, _ := response["success"].(bool); !success { - t.Fatalf("expected bridge to preserve gateway success without prompt heuristic failure, got %#v", response) + if success, _ := response["success"].(bool); success { + t.Fatalf("expected bridge to reject hallucinated file success without artifacts, got %#v", response) } - output := strings.TrimSpace(shared.StringArg(response, "output", "")) - if !strings.Contains(output, "点击直接下载") || !strings.Contains(output, "文件已就绪") { - t.Fatalf("expected bridge to preserve gateway output, got %q", output) + if got := response["code"]; got != "OPENCLAW_REQUIRED_ARTIFACT_MISSING" { + t.Fatalf("expected required artifact failure code, got %#v", response) } if _, ok := response["artifacts"]; ok { t.Fatalf("expected no artifacts when export returned none, got %#v", response["artifacts"]) } - if warnings := shared.ListArg(response, "artifactWarnings"); len(warnings) != 0 { - t.Fatalf("expected no bridge artifact-missing warning, got %#v", warnings) + if missing := fmt.Sprint(response["missingArtifactExtensions"]); missing == "[]" || strings.TrimSpace(missing) == "" { + t.Fatalf("expected missing extension diagnostics, got %#v", response) } } @@ -2751,6 +2844,9 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway { if strings.Contains(fake.runMessage(runID), "hallucinate-files") { message = "文件已就绪,点击直接下载👇 三个格式一键收取:" } + if strings.Contains(fake.runMessage(runID), "agent failed before reply") { + message = "Agent failed before reply: No available auth profile for nvidia" + } emitChatEvent := !strings.Contains(fake.runMessage(runID), "silent-turn") if payloadBytes := fake.largeGatewayPayloadBytes.Load(); payloadBytes > 0 { _ = conn.WriteJSON(map[string]any{