diff --git a/internal/acp/orchestrator.go b/internal/acp/orchestrator.go index 8a73a45..904e200 100644 --- a/internal/acp/orchestrator.go +++ b/internal/acp/orchestrator.go @@ -243,6 +243,7 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( artifactDeliveryRequired := openClawArtifactDeliveryRequired(params) sessionKey := openClawSessionKey(params, turnID) artifactRunID := turnID + logOpenClawArtifactIntent(gatewayProvider, sessionKey, artifactRunID, "intent", artifactDeliveryRequired, false, false, false) var preparedArtifact *openClawPreparedArtifactScope if artifactDeliveryRequired { var rpcErr *shared.RPCError @@ -256,6 +257,7 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( return nil, rpcErr } } + logOpenClawArtifactIntent(gatewayProvider, sessionKey, artifactRunID, "prepare", artifactDeliveryRequired, preparedArtifact != nil, false, false) chatParams, rpcErr := openClawChatSendParams(params, turnID, preparedArtifact) if rpcErr != nil { return nil, rpcErr @@ -335,13 +337,16 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat( artifactRunID, artifactSinceUnixMs, preparedArtifact, - artifactDeliveryRequired || artifactDeliveryClaimed || preparedArtifact != nil, + artifactDeliveryRequired || preparedArtifact != nil, notifyWithCollection, ) - if artifactDeliveryClaimed { + if artifactDeliveryClaimed && preparedArtifact != nil { artifactPayload = filterOpenClawArtifactPayloadByOutput(output, artifactPayload) } mergeOpenClawArtifactPayload(result, artifactPayload) + exportedCount := openClawArtifactPayloadCount(result) + artifactExpected := artifactDeliveryRequired || artifactDeliveryClaimed || preparedArtifact != nil + logOpenClawArtifactIntent(gatewayProvider, sessionKey, artifactRunID, "export", artifactDeliveryRequired, preparedArtifact != nil, exportedCount > 0, artifactExpected && exportedCount == 0) o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), artifactRunID) stripOpenClawArtifactInlineContent(result) guardOpenClawArtifactResult(result, artifactDeliveryRequired || artifactDeliveryClaimed) @@ -368,6 +373,37 @@ func logOpenClawGatewayTiming( ) } +func logOpenClawArtifactIntent( + gatewayProvider string, + sessionKey string, + runID string, + stage string, + required bool, + prepared bool, + exported bool, + empty bool, +) { + log.Printf( + "level=info component=openclaw_gateway event=artifact_intent provider=%q sessionId=%q runId=%q stage=%q required=%t prepared=%t exported=%t empty=%t", + gatewayProvider, + sessionKey, + runID, + stage, + required, + prepared, + exported, + empty, + ) +} + +func openClawArtifactPayloadCount(payload map[string]any) int { + if payload == nil { + return 0 + } + remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")) + return len(extractArtifactPayloads(payload, remoteWorkingDirectory)) +} + func (o *SessionOrchestrator) openClawArtifactExportForDelivery( gatewayProvider string, chatParams map[string]any, @@ -428,7 +464,7 @@ func openClawChatSendParams( turnID string, preparedArtifact *openClawPreparedArtifactScope, ) (map[string]any, *shared.RPCError) { - message := firstNonEmptyString(params, "taskPrompt", "prompt", "message") + message := openClawCurrentTurnMessage(params) if message == "" { return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_TASK_PROMPT_REQUIRED"} } @@ -526,30 +562,121 @@ func openClawArtifactDeliveryText(raw any) []string { } case map[string]any: texts := make([]string, 0, len(value)) - for key, item := range value { - switch strings.TrimSpace(key) { - case "taskPrompt", "prompt", "message": + for _, key := range []string{"taskPrompt", "prompt", "message", "text", "content", "input"} { + texts = append(texts, openClawTextFragments(value[key])...) + } + texts = append(texts, openClawLatestUserMessageText(value["messages"])...) + for _, key := range []string{"request", "params", "payload", "body"} { + if item, ok := value[key]; ok { texts = append(texts, openClawArtifactDeliveryText(item)...) - default: - if _, ok := item.(map[string]any); ok { - texts = append(texts, openClawArtifactDeliveryText(item)...) - } - if _, ok := item.([]any); ok { - texts = append(texts, openClawArtifactDeliveryText(item)...) - } } } - return texts + return compactOpenClawTexts(texts) case []any: texts := make([]string, 0, len(value)) for _, item := range value { texts = append(texts, openClawArtifactDeliveryText(item)...) } - return texts + return compactOpenClawTexts(texts) } return nil } +func openClawCurrentTurnMessage(params map[string]any) string { + if params == nil { + return "" + } + for _, key := range []string{"taskPrompt", "prompt", "message"} { + if text := strings.TrimSpace(strings.Join(openClawTextFragments(params[key]), "\n")); text != "" { + return text + } + } + if text := strings.TrimSpace(strings.Join(openClawLatestUserMessageText(params["messages"]), "\n")); text != "" { + return text + } + for _, key := range []string{"input", "content"} { + if text := strings.TrimSpace(strings.Join(openClawTextFragments(params[key]), "\n")); text != "" { + return text + } + } + return "" +} + +func openClawLatestUserMessageText(raw any) []string { + messages, ok := raw.([]any) + if !ok || len(messages) == 0 { + return nil + } + var fallback []string + for index := len(messages) - 1; index >= 0; index-- { + message := shared.AsMap(messages[index]) + if len(message) == 0 { + continue + } + text := compactOpenClawTexts(openClawMessageText(message)) + if len(text) == 0 { + continue + } + if fallback == nil { + fallback = text + } + role := strings.ToLower(strings.TrimSpace(shared.StringArg(message, "role", ""))) + switch role { + case "user", "human", "client": + return text + } + } + return fallback +} + +func openClawMessageText(message map[string]any) []string { + if len(message) == 0 { + return nil + } + texts := make([]string, 0, 4) + for _, key := range []string{"content", "parts", "text", "message"} { + texts = append(texts, openClawTextFragments(message[key])...) + } + return texts +} + +func openClawTextFragments(raw any) []string { + switch value := raw.(type) { + case nil: + return nil + case string: + if text := strings.TrimSpace(value); text != "" { + return []string{text} + } + case []any: + texts := make([]string, 0, len(value)) + for _, item := range value { + texts = append(texts, openClawTextFragments(item)...) + } + return compactOpenClawTexts(texts) + case map[string]any: + texts := make([]string, 0, len(value)) + for _, key := range []string{"text", "content", "message", "value"} { + texts = append(texts, openClawTextFragments(value[key])...) + } + return compactOpenClawTexts(texts) + } + return nil +} + +func compactOpenClawTexts(texts []string) []string { + if len(texts) == 0 { + return nil + } + result := make([]string, 0, len(texts)) + for _, text := range texts { + if trimmed := strings.TrimSpace(text); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + func withOpenClawArtifactDeliveryInstructions( message string, preparedArtifact *openClawPreparedArtifactScope, diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index 77c1410..a6cff9d 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -934,18 +934,61 @@ func TestExecuteSessionMessageGatewayRejectsClaimedArtifactsWithoutScopedFiles(t if got := response["status"]; got != "artifact_missing" { t.Fatalf("expected artifact_missing status, got %#v", response) } + if gateway.ArtifactExportCount() != 0 { + t.Fatalf("expected no artifact export for unprepared claimed output, got %d", gateway.ArtifactExportCount()) + } + if got := gateway.Methods(); !sameMethods(got, []string{"connect", "chat.send", "agent.wait"}) { + t.Fatalf("expected connect, chat.send, then agent.wait, got %#v", got) + } +} + +func TestExecuteSessionMessageGatewayPreparesArtifactsFromMessagesPrompt(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.message", + Params: map[string]any{ + "sessionId": "session-openclaw-message-artifact", + "threadId": "thread-openclaw-message-artifact", + "workingDirectory": t.TempDir(), + "messages": []any{ + map[string]any{ + "role": "assistant", + "content": "上一轮只是分析。", + }, + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "继续,把这些内容 make artifact 输出为 Markdown 文件。"}, + }, + }, + }, + "routing": map[string]any{ + "routingMode": "explicit", + "explicitExecutionTarget": "gateway", + "preferredGatewayProviderId": "openclaw", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected message artifact response, got rpc error: %#v", rpcErr) + } + if got := response["success"]; got != true { + t.Fatalf("expected artifact response success, got %#v", response) + } exportParams := gateway.LastArtifactExportParams() - if _, ok := exportParams["latestIfEmpty"]; ok { - t.Fatalf("expected no latestIfEmpty fallback export param, got %#v", exportParams) + if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); !strings.HasPrefix(got, "tasks/thread-openclaw-message-artifact/") { + t.Fatalf("expected scoped artifact export params for message prompt, got %#v", exportParams) } - if _, ok := exportParams["latestTaskScopeIfEmpty"]; ok { - t.Fatalf("expected no latestTaskScopeIfEmpty fallback export param, got %#v", exportParams) - } - if _, ok := exportParams["artifactScope"]; ok { - t.Fatalf("expected no new prepared artifact scope for claimed follow-up, got %#v", exportParams) - } - if got := gateway.Methods(); !sameMethods(got, []string{"connect", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) { - t.Fatalf("expected connect, chat.send, agent.wait, then artifact export, got %#v", got) + if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) { + t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got) } } @@ -1401,6 +1444,23 @@ func TestOpenClawArtifactDeliveryRequiredScansNestedParams(t *testing.T) { } } +func TestOpenClawArtifactDeliveryRequiredScansMessageContentParts(t *testing.T) { + params := map[string]any{ + "messages": []any{ + map[string]any{"role": "assistant", "content": "上一轮只是分析。"}, + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "请输出 Markdown 文件并保存到 workspace。"}, + }, + }, + }, + } + if !openClawArtifactDeliveryRequired(params) { + t.Fatal("expected artifact delivery prompt in message content parts to be detected") + } +} + func TestExecuteSessionTaskGatewayCollectsOpenClawEventArtifacts(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.artifactMode = "unknown" diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 265e271..15cf902 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -224,7 +224,7 @@ func TestHTTPHandlerGatewayOpenClawSSEKeepaliveBeforeFinalEnvelopeAndDone(t *tes if err != nil { t.Fatalf("send request: %v", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(response.Body) if err != nil { t.Fatalf("read response: %v", err) @@ -322,7 +322,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing results <- result{err: err} return } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(response.Body) if err != nil { results <- result{err: err} @@ -401,7 +401,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) { firstDone <- err return } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() _, err = io.ReadAll(response.Body) firstDone <- err }() @@ -422,7 +422,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) { if err != nil { t.Fatalf("send second request: %v", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(response.Body) if err != nil { t.Fatalf("read second response: %v", err) @@ -471,7 +471,7 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t if err != nil { t.Fatalf("send request: %v", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(response.Body) if err != nil { t.Fatalf("read response: %v", err)