Fix OpenClaw artifact intent for follow-up turns

This commit is contained in:
Haitao Pan 2026-05-11 17:23:56 +08:00
parent b39c9ec084
commit ee1207ebfc
3 changed files with 217 additions and 30 deletions

View File

@ -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,

View File

@ -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"

View File

@ -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)