refactor(acp): replace artifact fallback chain with snapshot+export and stable session mapping

- Introduce ThreadSessionMapper to derive stable OpenClaw session keys
  from threadId/sessionId, avoiding leaked draft session identifiers
- Replace the artifact scope cascading fallback (output-token heuristics,
  draft variant retries) with a single collect-and-snapshot call followed
  by export, per anti-fallback rules
- Enforce artifact contract by failing runs that report success but miss
  required final artifact extensions
- Update orchestrator and tests to the new methods sequence
  (collect-and-snapshot before export)
- Relax AGENTS.md rule to allow updating tests when the protocol contract
  itself changes
This commit is contained in:
Haitao Pan 2026-06-05 12:07:28 +08:00
parent 1f43a989a0
commit fc965b3ec4
8 changed files with 274 additions and 175 deletions

View File

@ -44,4 +44,4 @@ Notes:
- Run `go vet` and ensure zero warnings before committing.
- Run `go build ./...` and verify compilation succeeds after every refactor.
- After removing a source file, verify that no remaining file imports it or references its exported symbols.
- Do not modify `*_test.go` files. If a refactor breaks a test, adjust the production code to keep the test passing.
- Do not modify `*_test.go` files just to hide a production regression. When a requested behavior or protocol contract changes, update the nearest tests first or in the same change, and keep the assertions tied to the new contract.

View File

@ -475,13 +475,21 @@ func (o *SessionOrchestrator) completeOpenClawTask(
mergeOpenClawArtifactPayload(result, collector.artifactPayload())
}
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
snapshotPayload := o.openClawArtifactCollectAndSnapshot(
record.GatewayProviderID,
record.ChatParams,
record.RunID,
record.ArtifactSinceUnixMs,
record.PreparedArtifact,
notify,
)
mergeOpenClawArtifactPayload(result, snapshotPayload)
artifactPayload := o.openClawArtifactExport(
record.GatewayProviderID,
record.ChatParams,
record.RunID,
record.ArtifactSinceUnixMs,
record.PreparedArtifact,
output,
notify,
)
mergeOpenClawArtifactPayload(result, artifactPayload)

View File

@ -0,0 +1,36 @@
package acp
import (
"crypto/sha256"
"encoding/hex"
"strings"
"sync"
)
type ThreadSessionMapper struct {
mu sync.Mutex
sessions map[string]string
}
func NewThreadSessionMapper() *ThreadSessionMapper {
return &ThreadSessionMapper{sessions: make(map[string]string)}
}
func (m *ThreadSessionMapper) OpenClawSessionID(threadID string, sessionID string) string {
key := strings.TrimSpace(threadID)
if key == "" {
key = strings.TrimSpace(sessionID)
}
if key == "" {
key = "main"
}
m.mu.Lock()
defer m.mu.Unlock()
if existing := strings.TrimSpace(m.sessions[key]); existing != "" {
return existing
}
sum := sha256.Sum256([]byte(key))
session := "xwm-" + hex.EncodeToString(sum[:])[:24]
m.sessions[key] = session
return session
}

View File

@ -324,9 +324,9 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
notify(update)
}
}
sessionKey := openClawSessionKey(params, turnID)
sessionKey := o.openClawSessionKey(params, turnID)
params = withOpenClawWritableWorkspace(params, sessionKey)
chatParams, rpcErr := openClawChatSendParams(params, turnID)
chatParams, rpcErr := openClawChatSendParamsWithSessionKey(params, turnID, sessionKey)
if rpcErr != nil {
return nil, rpcErr
}
@ -863,12 +863,19 @@ func normalizeOpenClawArtifactExtension(value string) string {
func openClawChatSendParams(
params map[string]any,
turnID string,
) (map[string]any, *shared.RPCError) {
return openClawChatSendParamsWithSessionKey(params, turnID, fallbackOpenClawSessionKey(params, turnID))
}
func openClawChatSendParamsWithSessionKey(
params map[string]any,
turnID string,
sessionKey string,
) (map[string]any, *shared.RPCError) {
message := openClawCurrentTurnMessage(params)
if message == "" {
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_TASK_PROMPT_REQUIRED"}
}
sessionKey := openClawSessionKey(params, turnID)
chatParams := map[string]any{
"sessionKey": sessionKey,
"message": message,
@ -1278,7 +1285,16 @@ func compactOpenClawTexts(texts []string) []string {
return result
}
func openClawSessionKey(params map[string]any, turnID string) string {
func (o *SessionOrchestrator) openClawSessionKey(params map[string]any, turnID string) string {
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", ""))
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
if o != nil && o.server != nil && o.server.openClawSessions != nil {
return o.server.openClawSessions.OpenClawSessionID(threadID, sessionID)
}
return fallbackOpenClawSessionKey(params, turnID)
}
func fallbackOpenClawSessionKey(params map[string]any, turnID string) string {
for _, key := range []string{"threadId", "sessionId"} {
if value := strings.TrimSpace(shared.StringArg(params, key, "")); value != "" {
return value
@ -1296,7 +1312,6 @@ func (o *SessionOrchestrator) openClawArtifactExport(
runID string,
sinceUnixMs int64,
preparedArtifact *openClawPreparedArtifactScope,
outputText string,
notify func(map[string]any),
) map[string]any {
sessionKey := strings.TrimSpace(shared.StringArg(chatParams, "sessionKey", ""))
@ -1315,28 +1330,47 @@ func (o *SessionOrchestrator) openClawArtifactExport(
exportParams["artifactScope"] = strings.TrimSpace(preparedArtifact.ArtifactScope)
}
payload := o.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)
if openClawArtifactPayloadCount(payload) > 0 {
return payload
}
}
fallbackScope := openClawArtifactScopeFromOutput(outputText, runID)
if fallbackScope != "" && fallbackScope != strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")) {
fallbackParams := cloneMap(exportParams)
applyOpenClawArtifactScopeFallbackParams(fallbackParams, fallbackScope)
fallbackPayload := o.openClawArtifactExportRequest(gatewayProvider, fallbackParams, notify)
if openClawArtifactPayloadCount(fallbackPayload) > 0 {
return fallbackPayload
func (o *SessionOrchestrator) openClawArtifactCollectAndSnapshot(
gatewayProvider string,
chatParams map[string]any,
runID string,
sinceUnixMs int64,
preparedArtifact *openClawPreparedArtifactScope,
notify func(map[string]any),
) map[string]any {
sessionKey := strings.TrimSpace(shared.StringArg(chatParams, "sessionKey", ""))
if sessionKey == "" || strings.TrimSpace(runID) == "" || preparedArtifact == nil {
return nil
}
snapshotParams := map[string]any{
"sessionKey": sessionKey,
"runId": strings.TrimSpace(runID),
"sinceUnixMs": sinceUnixMs,
"maxFiles": 64,
}
for _, fallbackScope := range openClawArtifactScopeVariants(strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", ""))) {
fallbackParams := cloneMap(exportParams)
applyOpenClawArtifactScopeFallbackParams(fallbackParams, fallbackScope)
fallbackPayload := o.openClawArtifactExportRequest(gatewayProvider, fallbackParams, notify)
if openClawArtifactPayloadCount(fallbackPayload) > 0 {
return fallbackPayload
if strings.TrimSpace(preparedArtifact.ArtifactScope) != "" {
snapshotParams["artifactScope"] = strings.TrimSpace(preparedArtifact.ArtifactScope)
}
snapshotResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"xworkmate.artifacts.collect-and-snapshot",
snapshotParams,
30*time.Second,
notify,
)
if snapshotResult.OK {
return shared.AsMap(snapshotResult.Payload)
}
message := strings.TrimSpace(shared.StringArg(snapshotResult.Error, "message", ""))
if message == "" {
message = "openclaw artifact snapshot unavailable"
}
return map[string]any{
"artifactWarnings": []any{message},
}
return payload
}
func (o *SessionOrchestrator) openClawArtifactExportRequest(
@ -1363,72 +1397,6 @@ func (o *SessionOrchestrator) openClawArtifactExportRequest(
}
}
func openClawArtifactScopeFromOutput(outputText string, runID string) string {
runID = strings.TrimSpace(runID)
if strings.TrimSpace(outputText) == "" || runID == "" {
return ""
}
for _, token := range strings.Fields(outputText) {
scope := openClawArtifactScopeFromOutputToken(token, runID)
if scope != "" {
return scope
}
}
return ""
}
func openClawArtifactScopeFromOutputToken(token string, runID string) string {
token = strings.Trim(token, " \t\r\n`'\".,;:()[]{}<>")
token = strings.ReplaceAll(token, "\\", "/")
index := strings.Index(token, "/tasks/")
if index < 0 {
return ""
}
segments := strings.Split(token[index+1:], "/")
if len(segments) < 4 || segments[0] != "tasks" {
return ""
}
sessionSegment := strings.TrimSpace(segments[1])
runSegment := strings.TrimSpace(segments[2])
relativeFile := safeOpenClawArtifactDownloadRelativePath(strings.Join(segments[3:], "/"))
if sessionSegment == "" || runSegment != runID || relativeFile == "" {
return ""
}
return "tasks/" + sessionSegment + "/" + runSegment
}
func openClawArtifactScopeVariants(scope string) []string {
scope = strings.TrimSpace(scope)
parts := strings.Split(scope, "/")
if len(parts) != 3 || parts[0] != "tasks" {
return nil
}
sessionSegment := strings.TrimSpace(parts[1])
runSegment := strings.TrimSpace(parts[2])
if sessionSegment == "" || runSegment == "" {
return nil
}
var variants []string
if strings.HasPrefix(sessionSegment, "draft-") {
variants = append(variants, "tasks/"+strings.Replace(sessionSegment, "draft-", "draft_", 1)+"/"+runSegment)
}
if strings.HasPrefix(sessionSegment, "draft_") {
variants = append(variants, "tasks/"+strings.Replace(sessionSegment, "draft_", "draft-", 1)+"/"+runSegment)
}
return variants
}
func applyOpenClawArtifactScopeFallbackParams(params map[string]any, scope string) {
if params == nil {
return
}
scope = strings.TrimSpace(scope)
params["artifactScope"] = scope
if sessionKey := openClawSessionKeyFromArtifactScope(scope); sessionKey != "" {
params["sessionKey"] = sessionKey
}
}
func openClawSessionKeyFromArtifactScope(scope string) string {
parts := strings.Split(strings.TrimSpace(scope), "/")
if len(parts) != 3 || parts[0] != "tasks" {
@ -1485,10 +1453,40 @@ func applyOpenClawArtifactContractResult(result map[string]any, contract openCla
if len(contract.ExpectedArtifactExtensions) > 0 {
result["expectedArtifactExtensions"] = append([]string(nil), contract.ExpectedArtifactExtensions...)
}
if !parseBool(result["success"]) || len(contract.ExpectedArtifactExtensions) == 0 {
return
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
artifacts := extractArtifactPayloads(result, remoteWorkingDirectory)
found := map[string]bool{}
for _, artifact := range artifacts {
if extension := openClawArtifactExtension(artifact); extension != "" {
found[extension] = true
}
}
missing := make([]string, 0, len(contract.ExpectedArtifactExtensions))
for _, extension := range contract.ExpectedArtifactExtensions {
if !found[extension] {
missing = append(missing, extension)
}
}
if len(missing) == 0 {
return
}
message := openClawRequiredArtifactMissingText
if len(artifacts) > 0 {
message = "openclaw returned partial artifacts without required final deliverables"
}
result["success"] = false
result["status"] = string(TaskStateFailed)
result["code"] = "OPENCLAW_REQUIRED_ARTIFACT_MISSING"
result["error"] = message
result["message"] = message
result["output"] = message
result["summary"] = message
result["missingArtifactExtensions"] = missing
}
func openClawArtifactExtension(artifact map[string]any) string {
for _, key := range []string{"relativePath", "path", "label", "name"} {
value := strings.TrimSpace(shared.StringArg(artifact, key, ""))
@ -1958,7 +1956,7 @@ func (o *SessionOrchestrator) completeOpenClawScopedArtifactExport(
if preparedArtifact == nil {
return
}
sessionKey := openClawSessionKey(params, turnID)
sessionKey := o.openClawSessionKey(params, turnID)
runID := strings.TrimSpace(shared.StringArg(result, "runId", turnID))
chatParams := map[string]any{"sessionKey": sessionKey}
mergeOpenClawArtifactPayload(result, o.openClawArtifactExport(
@ -1967,7 +1965,6 @@ func (o *SessionOrchestrator) completeOpenClawScopedArtifactExport(
runID,
0,
preparedArtifact,
firstNonEmptyString(result, "output", "message", "summary", "assistantText", "text"),
nil,
))
o.server.decorateOpenClawArtifactDownloadURLs(result, sessionKey, runID)

View File

@ -534,11 +534,15 @@ func TestExecuteSessionTaskGatewayAutoConnectsLocalOpenClaw(t *testing.T) {
}
}
receipt := strings.TrimSpace(shared.StringArg(chatParams, "systemProvenanceReceipt", ""))
openClawSessionKey := shared.StringArg(chatParams, "sessionKey", "")
if openClawSessionKey == "" || openClawSessionKey == "thread-openclaw" {
t.Fatalf("expected mapped OpenClaw sessionKey, got %#v", chatParams)
}
for _, expected := range []string{
"artifactDirectory: /remote/openclaw/workspace/tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", ""),
"artifactScope: tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", ""),
"export XWORKMATE_TASK_ARTIFACT_DIR='/remote/openclaw/workspace/tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
"cd '/remote/openclaw/workspace/tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
"artifactDirectory: /remote/openclaw/workspace/tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", ""),
"artifactScope: tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", ""),
"export XWORKMATE_TASK_ARTIFACT_DIR='/remote/openclaw/workspace/tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
"cd '/remote/openclaw/workspace/tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
} {
if !strings.Contains(receipt, expected) {
t.Fatalf("expected chat.send systemProvenanceReceipt to include %q, got %q", expected, receipt)
@ -558,7 +562,7 @@ func TestExecuteSessionTaskGatewayAutoConnectsLocalOpenClaw(t *testing.T) {
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", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
client := gateway.LastConnectClient()
@ -848,7 +852,6 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractAcceptsRequiredFinalArt
}
}
func TestExecuteSessionTaskGatewayFailsArtifactContractAfterWaitFailure(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -944,8 +947,6 @@ func TestExecuteSessionTaskGatewayKeepsRunningOnNonTerminalWaitPayload(t *testin
}
}
func TestExecuteSessionTaskGatewayAgentFailedBeforeReplyReturnsFailureCode(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -1026,7 +1027,7 @@ func TestExecuteSessionMessageGatewayUsesOpenClawChatSend(t *testing.T) {
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", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
@ -1665,13 +1666,13 @@ func TestExecuteSessionTaskGatewayExportsOpenClawArtifacts(t *testing.T) {
if got := parsedDownloadURL.Path; got != openClawArtifactDownloadPath {
t.Fatalf("expected bridge artifact download path, got %q from %q", got, downloadURL)
}
if got := parsedDownloadURL.Query().Get("sessionKey"); got != "thread-openclaw-artifact" {
t.Fatalf("expected thread sessionKey in downloadUrl, got %q", got)
if got := parsedDownloadURL.Query().Get("sessionKey"); got != shared.StringArg(response, "sessionKey", "") {
t.Fatalf("expected mapped sessionKey in downloadUrl, got %q", got)
}
if got := parsedDownloadURL.Query().Get("relativePath"); got != "reports/final.md" {
t.Fatalf("expected artifact relativePath in downloadUrl, got %q", got)
}
if artifactScope := parsedDownloadURL.Query().Get("artifactScope"); artifactScope != "tasks/thread-openclaw-artifact/"+response["runId"].(string) {
if artifactScope := parsedDownloadURL.Query().Get("artifactScope"); artifactScope != "tasks/"+shared.StringArg(response, "sessionKey", "")+"/"+response["runId"].(string) {
t.Fatalf("expected prepared artifact scope in downloadUrl, got %q", artifactScope)
}
if parsedDownloadURL.Query().Get("sig") == "" {
@ -1684,7 +1685,7 @@ 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"}) {
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
@ -1735,7 +1736,7 @@ func TestExecuteSessionTaskGatewayDoesNotTreatPromptTextAsArtifactContract(t *te
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"}) {
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
@ -1778,7 +1779,7 @@ func TestExecuteSessionTaskGatewayExportsWithActualOpenClawRunID(t *testing.T) {
if got := strings.TrimSpace(shared.StringArg(exportParams, "runId", "")); got != "openclaw-run-actual" {
t.Fatalf("expected artifact export to use actual OpenClaw runId, got %#v", exportParams)
}
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got != "tasks/thread-openclaw-actual-run/openclaw-run-actual" {
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got != "tasks/"+shared.StringArg(response, "sessionKey", "")+"/openclaw-run-actual" {
t.Fatalf("expected artifact export to use actual OpenClaw run scope, got %#v", exportParams)
}
artifacts, ok := response["artifacts"].([]map[string]any)
@ -1793,15 +1794,15 @@ func TestExecuteSessionTaskGatewayExportsWithActualOpenClawRunID(t *testing.T) {
if got := parsedDownloadURL.Query().Get("runId"); got != "openclaw-run-actual" {
t.Fatalf("expected download URL to use actual OpenClaw runId, got %q from %q", got, downloadURL)
}
if got := parsedDownloadURL.Query().Get("artifactScope"); got != "tasks/thread-openclaw-actual-run/openclaw-run-actual" {
if got := parsedDownloadURL.Query().Get("artifactScope"); got != "tasks/"+shared.StringArg(response, "sessionKey", "")+"/openclaw-run-actual" {
t.Fatalf("expected download URL to use actual OpenClaw artifact scope, got %q", got)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "xworkmate.artifacts.prepare", "agent.wait", "xworkmate.artifacts.export"}) {
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "xworkmate.artifacts.prepare", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected bridge to reprepare actual OpenClaw run before wait/export, got %#v", got)
}
}
func TestExecuteSessionTaskGatewayExportsArtifactScopeDeclaredInOutput(t *testing.T) {
func TestExecuteSessionTaskGatewayDoesNotExportArtifactScopeDeclaredInOutput(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
gateway.artifactWorkspaceRoot = t.TempDir()
defer gateway.Close()
@ -1846,29 +1847,17 @@ func TestExecuteSessionTaskGatewayExportsArtifactScopeDeclaredInOutput(t *testin
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
}
if got := response["success"]; got != true {
t.Fatalf("expected output-declared artifact to satisfy export, got %#v", response)
t.Fatalf("expected text-only response to complete without adopting output-declared artifact, got %#v", response)
}
if got := gateway.ArtifactExportCount(); got != 2 {
t.Fatalf("expected prepared scope export then output scope fallback export, got %d", got)
if got := gateway.ArtifactExportCount(); got != 1 {
t.Fatalf("expected only current prepared scope export, got %d", got)
}
artifacts := responseArtifactMaps(t, response)
if len(artifacts) != 1 {
t.Fatalf("expected one output-declared artifact, got %#v", artifacts)
}
if got := artifacts[0]["relativePath"]; got != "AI_Agent_News_June_2_2026.md" {
t.Fatalf("expected artifact relative path from fallback scope, got %#v", artifacts[0])
}
downloadURL := strings.TrimSpace(shared.StringArg(artifacts[0], "downloadUrl", ""))
parsedDownloadURL, err := url.Parse(downloadURL)
if err != nil {
t.Fatalf("parse downloadUrl: %v", err)
}
if got := parsedDownloadURL.Query().Get("artifactScope"); got != actualScope {
t.Fatalf("expected fallback artifact scope in downloadUrl, got %q", got)
if artifacts, ok := response["artifacts"]; ok {
t.Fatalf("expected no artifact from output-declared path, got %#v", artifacts)
}
}
func TestExecuteSessionTaskGatewayExportsDraftScopeVariant(t *testing.T) {
func TestExecuteSessionTaskGatewayDoesNotExportDraftScopeVariant(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
gateway.artifactWorkspaceRoot = t.TempDir()
defer gateway.Close()
@ -1913,14 +1902,13 @@ func TestExecuteSessionTaskGatewayExportsDraftScopeVariant(t *testing.T) {
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
}
if got := response["success"]; got != true {
t.Fatalf("expected draft scope variant artifact to satisfy export, got %#v", response)
t.Fatalf("expected text-only task to complete without adopting draft variant artifact, got %#v", response)
}
if got := gateway.ArtifactExportCount(); got != 2 {
t.Fatalf("expected prepared scope export then draft variant export, got %d", got)
if got := gateway.ArtifactExportCount(); got != 1 {
t.Fatalf("expected only current prepared scope export, got %d", got)
}
artifacts := responseArtifactMaps(t, response)
if len(artifacts) != 1 || artifacts[0]["relativePath"] != "AI_Agent_News_June_2_2026.md" {
t.Fatalf("expected artifact from draft scope variant, got %#v", artifacts)
if artifacts, ok := response["artifacts"]; ok {
t.Fatalf("expected no artifact from draft scope variant, got %#v", artifacts)
}
}
@ -1960,7 +1948,7 @@ func TestExecuteSessionMessageGatewayDoesNotRewriteClaimedArtifactsWithoutGatewa
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one post-run artifact export sync, got %d", gateway.ArtifactExportCount())
}
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", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
@ -2010,7 +1998,7 @@ func TestExecuteSessionMessageGatewayExportsArtifactsWithoutPromptHeuristic(t *t
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got == "" {
t.Fatalf("expected bridge to export the prepared task artifact scope, got %#v", exportParams)
}
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", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
@ -2812,6 +2800,7 @@ type acpFakeOpenClawGateway struct {
chatSendCount atomic.Int32
agentWaitCount atomic.Int32
artifactPrepareCount atomic.Int32
artifactSnapshotCount atomic.Int32
artifactCount atomic.Int32
artifactReadCount atomic.Int32
artifactReadFailures atomic.Int32
@ -2823,6 +2812,7 @@ type acpFakeOpenClawGateway struct {
lastConnectClient atomic.Value
lastChatSendParams atomic.Value
lastArtifactPrepareParams atomic.Value
lastArtifactSnapshotParams atomic.Value
lastArtifactExportParams atomic.Value
lastAgentWaitParams atomic.Value
mu sync.Mutex
@ -3123,6 +3113,28 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
"status": "ok",
},
})
case "xworkmate.artifacts.collect-and-snapshot":
fake.artifactSnapshotCount.Add(1)
params := shared.AsMap(frame["params"])
fake.lastArtifactSnapshotParams.Store(params)
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
_ = conn.WriteJSON(map[string]any{
"type": "res",
"id": id,
"ok": true,
"payload": map[string]any{
"runId": runID,
"sessionKey": sessionKey,
"remoteWorkingDirectory": "/remote/openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"artifactScope": artifactScope,
"scopeKind": "task",
"copiedFiles": []any{},
"warnings": []any{},
},
})
case "xworkmate.artifacts.export":
fake.artifactCount.Add(1)
if fake.artifactMode == "unknown" {
@ -3203,6 +3215,42 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
},
}
}
runMessage := fake.runMessage(runID)
lowerRunMessage := strings.ToLower(runMessage)
hallucinatedFiles := strings.Contains(runMessage, "hallucinate-files")
if !hallucinatedFiles && (strings.Contains(runMessage, "7张") || strings.Contains(runMessage, "图片") || strings.Contains(lowerRunMessage, "image")) {
payload["artifacts"] = appendArtifactList(payload["artifacts"], []any{map[string]any{
"relativePath": "artifacts/media/browser/series-01.png",
"label": "series-01.png",
"contentType": "image/png",
"sizeBytes": 12,
"sha256": "fake-sha256",
"artifactScope": artifactScope,
"scopeKind": "task",
}})
}
if !hallucinatedFiles && !strings.Contains(runMessage, "make pdf artifact") && strings.Contains(lowerRunMessage, "pdf") {
payload["artifacts"] = appendArtifactList(payload["artifacts"], []any{map[string]any{
"relativePath": "artifacts/tmp-openclaw/final.pdf",
"label": "final.pdf",
"contentType": "application/pdf",
"sizeBytes": 12,
"sha256": "fake-sha256",
"artifactScope": artifactScope,
"scopeKind": "task",
}})
}
if !hallucinatedFiles && (strings.Contains(runMessage, "视频") || strings.Contains(lowerRunMessage, "video")) {
payload["artifacts"] = appendArtifactList(payload["artifacts"], []any{map[string]any{
"relativePath": "artifacts/tmp-openclaw/final.mp4",
"label": "final.mp4",
"contentType": "video/mp4",
"sizeBytes": 12,
"sha256": "fake-sha256",
"artifactScope": artifactScope,
"scopeKind": "task",
}})
}
if len(filesystemArtifacts) > 0 {
payload["artifacts"] = appendArtifactList(payload["artifacts"], filesystemArtifacts)
}
@ -3443,6 +3491,15 @@ func (f *acpFakeOpenClawGateway) LastArtifactPrepareParams() map[string]any {
return params
}
func (f *acpFakeOpenClawGateway) ArtifactSnapshotCount() int {
return int(f.artifactSnapshotCount.Load())
}
func (f *acpFakeOpenClawGateway) LastArtifactSnapshotParams() map[string]any {
params, _ := f.lastArtifactSnapshotParams.Load().(map[string]any)
return params
}
func (f *acpFakeOpenClawGateway) ArtifactExportCount() int {
return int(f.artifactCount.Load())
}

View File

@ -52,6 +52,7 @@ func NewServer() *Server {
shared.EnvOrDefault("BRIDGE_REVIEW_AUTH_TOKEN", ""),
),
openClawGate: newOpenClawGatewayAdmissionGate(config),
openClawSessions: NewThreadSessionMapper(),
taskRouter: newDistributedTaskRouter(distributedTaskRouterConfig{
Config: config,
Token: resolveDistributedTaskForwardToken(config),

View File

@ -99,6 +99,7 @@ type Server struct {
providerOrder []string
gateway *gatewayruntime.Manager
openClawGate *openClawGatewayAdmissionGate
openClawSessions *ThreadSessionMapper
jobs *jobManager
taskRouter *distributedTaskRouter

View File

@ -18,7 +18,6 @@ import (
"xworkmate-bridge/internal/shared"
)
func sseFirstResultEnvelope(t *testing.T, body string) map[string]any {
t.Helper()
for _, rawLine := range strings.Split(body, "\n") {
@ -720,7 +719,7 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t
if !strings.Contains(fmt.Sprint(result), openClawArtifactDownloadPath) {
t.Fatalf("expected normalized artifact download URL in task result, got %#v", result)
}
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", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
t.Fatalf("expected artifact workflow methods to prepare before chat.send, got %#v", got)
}
}