Simplify OpenClaw artifact handoff

This commit is contained in:
Haitao Pan 2026-05-16 06:51:42 +08:00
parent 14d99646d9
commit 836b6bac44
3 changed files with 62 additions and 457 deletions

View File

@ -7,7 +7,6 @@ import (
"log"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
@ -240,25 +239,10 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
notify(update)
}
}
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
preparedArtifact, rpcErr = o.openClawArtifactPrepare(
gatewayProvider,
sessionKey,
artifactRunID,
notifyWithCollection,
)
if rpcErr != nil {
return nil, rpcErr
}
}
logOpenClawArtifactIntent(gatewayProvider, sessionKey, artifactRunID, "prepare", artifactDeliveryRequired, preparedArtifact != nil, false, false)
chatParams, rpcErr := openClawChatSendParams(params, turnID, preparedArtifact)
logOpenClawArtifactSync(gatewayProvider, sessionKey, artifactRunID, "intent", false, false, false)
chatParams, rpcErr := openClawChatSendParams(params, turnID)
if rpcErr != nil {
return nil, rpcErr
}
@ -327,29 +311,20 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
}
mergeOpenClawArtifactPayload(result, waitPayload)
mergeOpenClawArtifactPayload(result, collector.artifactPayload())
if preparedArtifact == nil {
preparedArtifact = openClawPreparedArtifactScopeFromPayload(result)
}
artifactDeliveryClaimed := !artifactDeliveryRequired && openClawArtifactDeliveryClaimedByOutput(output)
artifactPayload := o.openClawArtifactExportForDelivery(
preparedArtifact := openClawPreparedArtifactScopeFromPayload(result)
artifactPayload := o.openClawArtifactExport(
gatewayProvider,
chatParams,
artifactRunID,
artifactSinceUnixMs,
preparedArtifact,
artifactDeliveryRequired || preparedArtifact != nil,
notifyWithCollection,
)
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)
logOpenClawArtifactSync(gatewayProvider, sessionKey, artifactRunID, "export", preparedArtifact != nil, exportedCount > 0, exportedCount == 0)
o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), artifactRunID)
stripOpenClawArtifactInlineContent(result)
guardOpenClawArtifactResult(result, artifactDeliveryRequired || artifactDeliveryClaimed)
guardOpenClawNoDisplayableResult(result, noDisplayableOutput)
if notify != nil {
notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(sessionID, threadID, turnID, result)))
@ -416,23 +391,21 @@ func logOpenClawGatewayTiming(
)
}
func logOpenClawArtifactIntent(
func logOpenClawArtifactSync(
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",
"level=info component=openclaw_gateway event=artifact_sync provider=%q sessionId=%q runId=%q stage=%q prepared=%t exported=%t empty=%t",
gatewayProvider,
sessionKey,
runID,
stage,
required,
prepared,
exported,
empty,
@ -447,28 +420,6 @@ func openClawArtifactPayloadCount(payload map[string]any) int {
return len(extractArtifactPayloads(payload, remoteWorkingDirectory))
}
func (o *SessionOrchestrator) openClawArtifactExportForDelivery(
gatewayProvider string,
chatParams map[string]any,
runID string,
sinceUnixMs int64,
preparedArtifact *openClawPreparedArtifactScope,
artifactDeliveryRequired bool,
notify func(map[string]any),
) map[string]any {
if !artifactDeliveryRequired {
return nil
}
return o.openClawArtifactExport(
gatewayProvider,
chatParams,
runID,
sinceUnixMs,
preparedArtifact,
notify,
)
}
func isSessionTaskMethod(method string) bool {
switch strings.TrimSpace(method) {
case "session.start", "session.message":
@ -505,15 +456,11 @@ func openClawPreparedArtifactScopeFromPayload(payload map[string]any) *openClawP
func openClawChatSendParams(
params map[string]any,
turnID string,
preparedArtifact *openClawPreparedArtifactScope,
) (map[string]any, *shared.RPCError) {
message := openClawCurrentTurnMessage(params)
if message == "" {
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_TASK_PROMPT_REQUIRED"}
}
if openClawArtifactDeliveryRequired(params) {
message = withOpenClawArtifactDeliveryInstructions(message, preparedArtifact)
}
sessionKey := openClawSessionKey(params, turnID)
chatParams := map[string]any{
"sessionKey": sessionKey,
@ -529,102 +476,6 @@ func openClawChatSendParams(
return chatParams, nil
}
func openClawArtifactDeliveryRequired(params map[string]any) bool {
text := strings.ToLower(strings.Join(openClawArtifactDeliveryText(params), "\n"))
if strings.TrimSpace(text) == "" {
return false
}
if openClawArtifactDeliverySuppressed(text) {
return false
}
fileSignals := []string{
"ppt", "pptx", "powerpoint", "slide", "slides",
"pdf", "docx", "word", "xlsx", "excel",
"artifact", "artifacts", "file", "files", "download", "attachment", "asset", "output",
"image", "photo", "picture", "screenshot", "video", "audio", "csv", "json", "html",
"zip", "tar", "archive", "dataset", "report", "document", "markdown", "code",
"文件", "制品", "产物", "下载", "附件", "素材", "输出", "图片", "截图", "图像",
"视频", "音频", "压缩包", "数据集", "文档", "报告", "演示", "幻灯片", "表格", "代码",
}
actionSignals := []string{
"create", "generate", "build", "make", "write", "export", "output", "deliver", "download",
"save", "produce", "render", "attach", "return",
"生成", "制作", "输出", "导出", "下载", "交付", "收取", "保存", "渲染", "返回", "提供",
}
hasFileSignal := false
for _, signal := range fileSignals {
if strings.Contains(text, signal) {
hasFileSignal = true
break
}
}
if !hasFileSignal {
return false
}
for _, signal := range actionSignals {
if strings.Contains(text, signal) {
return true
}
}
return false
}
func openClawArtifactDeliverySuppressed(text string) bool {
suppressedSignals := []string{
"do not create file", "do not create files",
"don't create file", "don't create files",
"do not generate file", "do not generate files",
"don't generate file", "don't generate files",
"do not write file", "do not write files",
"don't write file", "don't write files",
"no file", "no files", "no artifact", "no artifacts",
"without file", "without files", "without artifact", "without artifacts",
"不要创建文件", "不要生成文件", "不要写入文件", "不创建文件", "不生成文件",
"无需创建文件", "无需生成文件", "不需要文件", "不要产物", "无需产物",
}
for _, signal := range suppressedSignals {
if strings.Contains(text, signal) {
return true
}
}
return false
}
func openClawArtifactDeliveryClaimedByOutput(output string) bool {
if strings.TrimSpace(output) == openClawNoDisplayableText {
return false
}
return openClawArtifactDeliveryRequired(map[string]any{"message": output})
}
func openClawArtifactDeliveryText(raw any) []string {
switch value := raw.(type) {
case string:
if text := strings.TrimSpace(value); text != "" {
return []string{text}
}
case map[string]any:
texts := make([]string, 0, len(value))
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)...)
}
}
return compactOpenClawTexts(texts)
case []any:
texts := make([]string, 0, len(value))
for _, item := range value {
texts = append(texts, openClawArtifactDeliveryText(item)...)
}
return compactOpenClawTexts(texts)
}
return nil
}
func openClawCurrentTurnMessage(params map[string]any) string {
if params == nil {
return ""
@ -720,33 +571,6 @@ func compactOpenClawTexts(texts []string) []string {
return result
}
func withOpenClawArtifactDeliveryInstructions(
message string,
preparedArtifact *openClawPreparedArtifactScope,
) string {
message = strings.TrimSpace(message)
if message == "" {
return message
}
lines := []string{
"XWorkmate artifact delivery requirements:",
"- Create the requested files as real files before finishing.",
}
if preparedArtifact != nil && strings.TrimSpace(preparedArtifact.ArtifactDirectory) != "" {
lines = append(lines,
"- Write every deliverable file into this exact directory:",
fmt.Sprintf(" `%s`", strings.TrimSpace(preparedArtifact.ArtifactDirectory)),
"- Do not write deliverable files outside that directory.",
)
}
lines = append(lines,
"- If multiple formats are requested, write each requested format as a separate file with the correct extension.",
"- Do not claim that files are ready, downloadable, or clickable unless the files actually exist on disk.",
"- In the final response, list only the real file names you created. Do not invent download links.",
)
return message + "\n\n" + strings.Join(lines, "\n")
}
func openClawSessionKey(params map[string]any, turnID string) string {
for _, key := range []string{"threadId", "sessionId"} {
if value := strings.TrimSpace(shared.StringArg(params, key, "")); value != "" {
@ -759,42 +583,6 @@ func openClawSessionKey(params map[string]any, turnID string) string {
return "main"
}
func (o *SessionOrchestrator) openClawArtifactPrepare(
gatewayProvider string,
sessionKey string,
runID string,
notify func(map[string]any),
) (*openClawPreparedArtifactScope, *shared.RPCError) {
sessionKey = strings.TrimSpace(sessionKey)
runID = strings.TrimSpace(runID)
if sessionKey == "" || runID == "" {
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_ARTIFACT_SCOPE_REQUIRED"}
}
prepareResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"xworkmate.artifacts.prepare",
map[string]any{
"sessionKey": sessionKey,
"runId": runID,
},
30*time.Second,
notify,
)
if !prepareResult.OK {
return nil, gatewayRPCError(prepareResult.Error, "openclaw artifact prepare failed")
}
payload := shared.AsMap(prepareResult.Payload)
prepared := &openClawPreparedArtifactScope{
ArtifactScope: strings.TrimSpace(shared.StringArg(payload, "artifactScope", "")),
ArtifactDirectory: strings.TrimSpace(shared.StringArg(payload, "artifactDirectory", "")),
ScopeKind: strings.TrimSpace(shared.StringArg(payload, "scopeKind", "")),
}
if prepared.ArtifactScope == "" || prepared.ArtifactDirectory == "" {
return nil, &shared.RPCError{Code: -32002, Message: "openclaw artifact prepare returned invalid scope"}
}
return prepared, nil
}
func (o *SessionOrchestrator) openClawArtifactExport(
gatewayProvider string,
chatParams map[string]any,
@ -837,28 +625,6 @@ func (o *SessionOrchestrator) openClawArtifactExport(
}
}
func guardOpenClawArtifactResult(result map[string]any, artifactDeliveryRequired bool) {
if !artifactDeliveryRequired || result == nil {
return
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
return
}
message := "未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。"
result["success"] = false
result["status"] = "artifact_missing"
result["code"] = "OPENCLAW_ARTIFACT_MISSING"
result["error"] = "OpenClaw artifact export returned no files for this run."
result["output"] = message
result["message"] = message
result["summary"] = message
result["artifactWarnings"] = appendArtifactList(
result["artifactWarnings"],
[]any{"OpenClaw artifact export returned no files for a file-delivery request."},
)
}
func guardOpenClawNoDisplayableResult(result map[string]any, noDisplayableOutput bool) {
if !noDisplayableOutput || result == nil || !parseBool(result["success"]) {
return
@ -876,58 +642,6 @@ func guardOpenClawNoDisplayableResult(result map[string]any, noDisplayableOutput
result["summary"] = openClawNoDisplayableText
}
func filterOpenClawArtifactPayloadByOutput(output string, payload map[string]any) map[string]any {
if payload == nil {
return nil
}
output = strings.ToLower(output)
if strings.TrimSpace(output) == "" {
return payload
}
filtered := map[string]any{}
for key, value := range payload {
filtered[key] = value
}
matchedAny := false
for _, key := range []string{"artifacts", "files", "attachments"} {
list := shared.ListArg(payload, key)
if len(list) == 0 {
continue
}
filteredList := make([]any, 0, len(list))
for _, item := range list {
artifact := shared.AsMap(item)
relativePath := strings.TrimSpace(shared.StringArg(artifact, "relativePath", ""))
if relativePath == "" {
relativePath = strings.TrimSpace(shared.StringArg(artifact, "path", ""))
}
if relativePath == "" {
relativePath = strings.TrimSpace(shared.StringArg(artifact, "name", ""))
}
if openClawOutputMentionsArtifactPath(output, relativePath) {
filteredList = append(filteredList, item)
matchedAny = true
}
}
filtered[key] = filteredList
}
if !matchedAny {
return payload
}
return filtered
}
func openClawOutputMentionsArtifactPath(output string, relativePath string) bool {
relativePath = strings.TrimSpace(strings.ReplaceAll(relativePath, "\\", "/"))
if relativePath == "" {
return false
}
normalizedPath := strings.ToLower(relativePath)
base := strings.ToLower(path.Base(normalizedPath))
return strings.Contains(output, normalizedPath) ||
(base != "." && base != "" && strings.Contains(output, base))
}
func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any) {
if result == nil || len(source) == 0 {
return
@ -1347,13 +1061,12 @@ func (o *SessionOrchestrator) completeOpenClawScopedArtifactExport(
sessionKey := openClawSessionKey(params, turnID)
runID := strings.TrimSpace(shared.StringArg(result, "runId", turnID))
chatParams := map[string]any{"sessionKey": sessionKey}
mergeOpenClawArtifactPayload(result, o.openClawArtifactExportForDelivery(
mergeOpenClawArtifactPayload(result, o.openClawArtifactExport(
gatewayProvider,
chatParams,
runID,
0,
preparedArtifact,
true,
nil,
))
o.server.decorateOpenClawArtifactDownloadURLs(result, sessionKey, runID)

View File

@ -509,11 +509,11 @@ func TestExecuteSessionTaskGatewayAutoConnectsLocalOpenClaw(t *testing.T) {
if got := int64(timeoutMs); got <= 120000 {
t.Fatalf("expected OpenClaw agent.wait timeout to exceed the previous 120s cap, got %#v", waitParams)
}
if gateway.ArtifactExportCount() != 0 {
t.Fatalf("expected no OpenClaw artifact export for text-only prompt, got %d", gateway.ArtifactExportCount())
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one OpenClaw artifact export sync after run, 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)
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)
}
client := gateway.LastConnectClient()
if got := client["id"]; got != "openclaw-macos" {
@ -563,8 +563,8 @@ func TestExecuteSessionTaskGatewayNoDisplayableOutputFails(t *testing.T) {
if got := response["output"]; got != openClawNoDisplayableText {
t.Fatalf("expected no-displayable output message, got %#v", response)
}
if gateway.ArtifactExportCount() != 0 {
t.Fatalf("expected no artifact export for no-output text prompt, got %d", gateway.ArtifactExportCount())
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one artifact export sync even when no displayable text is returned, got %d", gateway.ArtifactExportCount())
}
}
@ -604,11 +604,11 @@ func TestExecuteSessionMessageGatewayUsesOpenClawChatSend(t *testing.T) {
if gateway.AgentWaitCount() != 1 {
t.Fatalf("expected one OpenClaw agent.wait request, got %d", gateway.AgentWaitCount())
}
if gateway.ArtifactExportCount() != 0 {
t.Fatalf("expected no OpenClaw artifact export for text-only prompt, got %d", gateway.ArtifactExportCount())
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one OpenClaw artifact export sync after message run, 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)
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)
}
}
@ -834,9 +834,8 @@ func TestExecuteSessionTaskGatewayExportsOpenClawArtifacts(t *testing.T) {
if got := parsedDownloadURL.Query().Get("relativePath"); got != "reports/final.md" {
t.Fatalf("expected artifact relativePath in downloadUrl, got %q", got)
}
artifactScope := parsedDownloadURL.Query().Get("artifactScope")
if !strings.HasPrefix(artifactScope, "tasks/") {
t.Fatalf("expected artifact scope in downloadUrl, got %q", artifactScope)
if artifactScope := parsedDownloadURL.Query().Get("artifactScope"); artifactScope != "" {
t.Fatalf("expected no bridge-imposed artifact scope in downloadUrl, got %q", artifactScope)
}
if parsedDownloadURL.Query().Get("sig") == "" {
t.Fatalf("expected signed downloadUrl, got %q", downloadURL)
@ -848,12 +847,12 @@ func TestExecuteSessionTaskGatewayExportsOpenClawArtifacts(t *testing.T) {
if got := shared.BoolArg(shared.StringArg(exportParams, "includeContent", ""), true); got {
t.Fatalf("expected OpenClaw artifact export to omit content, got %#v", exportParams)
}
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)
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)
}
}
func TestExecuteSessionTaskGatewayDoesNotExportStaleWorkspaceArtifactsWhenScopedDirectoryEmpty(t *testing.T) {
func TestExecuteSessionTaskGatewayDoesNotTreatPromptTextAsArtifactContract(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -878,23 +877,17 @@ func TestExecuteSessionTaskGatewayDoesNotExportStaleWorkspaceArtifactsWhenScoped
},
})
if rpcErr != nil {
t.Fatalf("expected artifact guard response, got rpc error: %#v", rpcErr)
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
}
if got := response["success"]; got != false {
t.Fatalf("expected artifact_missing failure, got %#v", response)
}
if got := response["status"]; got != "artifact_missing" {
t.Fatalf("expected artifact_missing status, got %#v", response)
}
if got := response["code"]; got != "OPENCLAW_ARTIFACT_MISSING" {
t.Fatalf("expected structured artifact missing code, got %#v", response)
if got := response["success"]; got != true {
t.Fatalf("expected prompt text not to be converted into bridge artifact failure, got %#v", response)
}
if _, ok := response["artifacts"]; ok {
t.Fatalf("expected no stale artifacts when scoped directory is empty, got %#v", response["artifacts"])
t.Fatalf("expected no stale artifacts when gateway exported none, got %#v", response["artifacts"])
}
exportParams := gateway.LastArtifactExportParams()
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); !strings.HasPrefix(got, "tasks/thread-openclaw-latest-artifact/") {
t.Fatalf("expected scoped artifact export params, got %#v", exportParams)
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got != "" {
t.Fatalf("expected bridge not to impose scoped artifact export params, got %#v", exportParams)
}
if _, ok := exportParams["latestIfEmpty"]; ok {
t.Fatalf("expected no latestIfEmpty fallback export param, got %#v", exportParams)
@ -905,12 +898,12 @@ func TestExecuteSessionTaskGatewayDoesNotExportStaleWorkspaceArtifactsWhenScoped
if got := shared.BoolArg(shared.StringArg(exportParams, "includeContent", ""), true); got {
t.Fatalf("expected latest workspace export to omit content, got %#v", exportParams)
}
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)
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)
}
}
func TestExecuteSessionMessageGatewayRejectsClaimedArtifactsWithoutScopedFiles(t *testing.T) {
func TestExecuteSessionMessageGatewayDoesNotRewriteClaimedArtifactsWithoutGatewayFiles(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -935,26 +928,23 @@ func TestExecuteSessionMessageGatewayRejectsClaimedArtifactsWithoutScopedFiles(t
},
})
if rpcErr != nil {
t.Fatalf("expected claimed artifact guard response, got rpc error: %#v", rpcErr)
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
}
if got := response["success"]; got != false {
t.Fatalf("expected claimed artifact_missing failure, got %#v", response)
if got := response["success"]; got != true {
t.Fatalf("expected bridge to preserve gateway terminal state, got %#v", response)
}
if got := response["status"]; got != "artifact_missing" {
t.Fatalf("expected artifact_missing status, got %#v", response)
if output := strings.TrimSpace(shared.StringArg(response, "output", "")); !strings.Contains(output, "文件已就绪") {
t.Fatalf("expected bridge not to rewrite gateway text output, got %q", output)
}
if got := response["code"]; got != "OPENCLAW_ARTIFACT_MISSING" {
t.Fatalf("expected structured artifact missing code, got %#v", response)
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one post-run artifact export sync, got %d", gateway.ArtifactExportCount())
}
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)
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)
}
}
func TestExecuteSessionMessageGatewayPreparesArtifactsFromMessagesPrompt(t *testing.T) {
func TestExecuteSessionMessageGatewayExportsArtifactsWithoutPromptHeuristic(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -996,36 +986,11 @@ func TestExecuteSessionMessageGatewayPreparesArtifactsFromMessagesPrompt(t *test
t.Fatalf("expected artifact response success, got %#v", response)
}
exportParams := gateway.LastArtifactExportParams()
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 got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got != "" {
t.Fatalf("expected bridge not to impose artifact scope from message prompt, got %#v", exportParams)
}
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)
}
}
func TestFilterOpenClawArtifactPayloadByOutputKeepsMentionedFiles(t *testing.T) {
payload := map[string]any{
"remoteWorkingDirectory": "/remote/openclaw/workspace",
"artifacts": []any{
map[string]any{"relativePath": "k8s-networking.pdf"},
map[string]any{"relativePath": "k8s-networking.docx"},
map[string]any{"relativePath": "generate_all.py"},
},
}
filtered := filterOpenClawArtifactPayloadByOutput(
"文件已经生成好了k8s-networking.pdf, k8s-networking.docx",
payload,
)
artifacts := shared.ListArg(filtered, "artifacts")
if len(artifacts) != 2 {
t.Fatalf("expected only mentioned artifacts, got %#v", artifacts)
}
if got := shared.StringArg(shared.AsMap(artifacts[0]), "relativePath", ""); got != "k8s-networking.pdf" {
t.Fatalf("expected pdf artifact first, got %#v", artifacts)
}
if got := shared.StringArg(shared.AsMap(artifacts[1]), "relativePath", ""); got != "k8s-networking.docx" {
t.Fatalf("expected docx artifact second, got %#v", artifacts)
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)
}
}
@ -1407,7 +1372,7 @@ func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidArtifactScope(t *testi
}
}
func TestOpenClawChatSendParamsAddsArtifactDeliveryInstructions(t *testing.T) {
func TestOpenClawChatSendParamsPreservesRawPrompt(t *testing.T) {
for _, prompt := range []string{
"输出 PPT PDF docx 文件",
"生成一张图片并返回制品",
@ -1418,61 +1383,18 @@ func TestOpenClawChatSendParamsAddsArtifactDeliveryInstructions(t *testing.T) {
chatParams, rpcErr := openClawChatSendParams(map[string]any{
"threadId": "thread-artifact-instructions",
"taskPrompt": prompt,
}, "turn-artifact-instructions", &openClawPreparedArtifactScope{
ArtifactScope: "tasks/thread-artifact-instructions/turn-artifact-instructions",
ArtifactDirectory: "/remote/openclaw/workspace/tasks/thread-artifact-instructions/turn-artifact-instructions",
ScopeKind: "task",
})
}, "turn-artifact-instructions")
if rpcErr != nil {
t.Fatalf("expected chat params, got rpc error: %#v", rpcErr)
}
message := strings.TrimSpace(shared.StringArg(chatParams, "message", ""))
if !strings.Contains(message, prompt) {
t.Fatalf("expected original prompt to be preserved, got %q", message)
}
if !strings.Contains(message, "Create the requested files as real files") {
t.Fatalf("expected artifact delivery instructions, got %q", message)
}
if !strings.Contains(message, "/remote/openclaw/workspace/tasks/thread-artifact-instructions/turn-artifact-instructions") {
t.Fatalf("expected scoped artifact directory instruction, got %q", message)
}
if !strings.Contains(message, "Do not claim that files are ready") {
t.Fatalf("expected anti-hallucination download instruction, got %q", message)
if message != prompt {
t.Fatalf("expected bridge to preserve raw prompt without artifact instructions, got %q", message)
}
})
}
}
func TestOpenClawArtifactDeliveryRequiredScansNestedParams(t *testing.T) {
params := map[string]any{
"request": map[string]any{
"params": map[string]any{
"taskPrompt": "请在当前任务制品目录中真实生成一个文件",
},
},
}
if !openClawArtifactDeliveryRequired(params) {
t.Fatal("expected nested artifact delivery prompt to be detected")
}
}
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"
@ -1523,9 +1445,8 @@ func TestExecuteSessionTaskGatewayCollectsOpenClawEventArtifacts(t *testing.T) {
}
}
func TestExecuteSessionTaskGatewaySkipsArtifactExportForTextOnlyPrompt(t *testing.T) {
func TestExecuteSessionTaskGatewayAlwaysSyncsGatewayArtifactsAfterRun(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
gateway.artifactMode = "unknown"
defer gateway.Close()
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
@ -1554,34 +1475,15 @@ func TestExecuteSessionTaskGatewaySkipsArtifactExportForTextOnlyPrompt(t *testin
if got := response["output"]; got != "gateway pong" {
t.Fatalf("expected gateway pong output, got %#v", response)
}
if gateway.ArtifactExportCount() != 0 {
t.Fatalf("expected no OpenClaw artifact export request, got %d", gateway.ArtifactExportCount())
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one OpenClaw artifact export sync, got %d", gateway.ArtifactExportCount())
}
if warnings := shared.ListArg(response, "artifactWarnings"); len(warnings) != 0 {
t.Fatalf("expected no artifact warnings for text-only prompt, got %#v", warnings)
t.Fatalf("expected no artifact warnings when gateway export succeeds empty, got %#v", warnings)
}
}
func TestOpenClawNoDisplayableOutputDoesNotClaimArtifacts(t *testing.T) {
if openClawArtifactDeliveryClaimedByOutput(openClawNoDisplayableText) {
t.Fatalf("fallback no-output text must not trigger artifact delivery guard")
}
}
func TestOpenClawArtifactDeliverySuppressionKeepsTextOnlyPrompt(t *testing.T) {
if openClawArtifactDeliveryRequired(map[string]any{
"taskPrompt": "Reply exactly pong. Do not create files.",
}) {
t.Fatalf("negative file directive must not trigger artifact delivery")
}
if openClawArtifactDeliveryRequired(map[string]any{
"taskPrompt": "只回答 pong不要生成文件。",
}) {
t.Fatalf("Chinese negative file directive must not trigger artifact delivery")
}
}
func TestExecuteSessionTaskGatewayRejectsMissingOpenClawFilesForDeliveryRequest(t *testing.T) {
func TestExecuteSessionTaskGatewayDoesNotFailMissingFilesFromPromptHeuristic(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -1608,28 +1510,18 @@ func TestExecuteSessionTaskGatewayRejectsMissingOpenClawFilesForDeliveryRequest(
if rpcErr != nil {
t.Fatalf("expected bridge response, got rpc error: %#v", rpcErr)
}
if success, _ := response["success"].(bool); success {
t.Fatalf("expected missing artifact delivery to be marked unsuccessful, got %#v", response)
}
if got := response["status"]; got != "artifact_missing" {
t.Fatalf("expected artifact_missing status, got %#v", response)
}
if got := response["code"]; got != "OPENCLAW_ARTIFACT_MISSING" {
t.Fatalf("expected structured artifact missing code, got %#v", response)
if success, _ := response["success"].(bool); !success {
t.Fatalf("expected bridge to preserve gateway success without prompt heuristic failure, got %#v", response)
}
output := strings.TrimSpace(shared.StringArg(response, "output", ""))
if strings.Contains(output, "点击直接下载") || strings.Contains(output, "文件已就绪") {
t.Fatalf("expected hallucinated download text to be replaced, got %q", output)
}
if !strings.Contains(output, "未检测到 OpenClaw 本轮导出的实际文件") {
t.Fatalf("expected explicit missing artifact message, got %q", output)
if !strings.Contains(output, "点击直接下载") || !strings.Contains(output, "文件已就绪") {
t.Fatalf("expected bridge to preserve gateway output, got %q", output)
}
if _, ok := response["artifacts"]; ok {
t.Fatalf("expected no artifacts when export returned none, got %#v", response["artifacts"])
}
warnings := response["artifactWarnings"].([]any)
if len(warnings) != 1 || !strings.Contains(fmt.Sprint(warnings[0]), "returned no files") {
t.Fatalf("expected missing artifact warning, got %#v", response["artifactWarnings"])
if warnings := shared.ListArg(response, "artifactWarnings"); len(warnings) != 0 {
t.Fatalf("expected no bridge artifact-missing warning, got %#v", warnings)
}
}

View File

@ -561,7 +561,7 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t
if !sawFinal {
t.Fatalf("expected final result envelope, got %q", bodyText)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected artifact workflow methods to stay unchanged, got %#v", got)
}
}