package acp import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "slices" "strings" "sync" "sync/atomic" "testing" "time" "github.com/gorilla/websocket" "xworkmate-bridge/internal/shared" ) func handleRoutingResolve(params map[string]any) map[string]any { server := NewServer() res, _ := server.routingEngine.Resolve(context.Background(), params) // Convert to map for tests m := map[string]any{ "resolvedExecutionTarget": res.TargetID, "resolvedProviderId": res.ProviderID, "resolvedGatewayProviderId": res.GatewayProviderID, "resolvedModel": res.Model, "resolvedSkills": res.Skills, "status": res.Status, "unavailable": res.Status == "unavailable", "unavailableCode": res.UnavailableCode, "unavailableMessage": res.UnavailableMsg, "skillResolutionSource": res.SkillResolutionSource, "needsSkillInstall": res.NeedsSkillInstall, "skillInstallRequestId": res.SkillInstallRequestID, } return m } type task struct { req shared.RPCRequest notify func(map[string]any) } func (s *Server) executeSessionTask(t task) (map[string]any, *shared.RPCError) { if t.req.Method == "" { t.req.Method = "session.start" } response, rpcErr := s.handleRequest(t.req, t.notify) if rpcErr != nil || strings.TrimSpace(shared.StringArg(response, "status", "")) != string(TaskStateRunning) { return response, rpcErr } return s.handleRequest(shared.RPCRequest{ Method: "xworkmate.tasks.get", Params: map[string]any{ "sessionId": shared.StringArg(response, "sessionId", ""), "threadId": shared.StringArg(response, "threadId", ""), "turnId": shared.StringArg(response, "turnId", ""), "runId": shared.StringArg(response, "runId", ""), "appThreadKey": shared.StringArg(response, "appThreadKey", ""), "openclawSessionKey": shared.StringArg(response, "openclawSessionKey", ""), "artifactScope": shared.StringArg(response, "artifactScope", ""), "artifactDirectory": shared.StringArg(response, "artifactDirectory", ""), "gatewayProviderId": shared.StringArg(response, "resolvedGatewayProviderId", ""), "runtimeBudgetMinutes": shared.StringArg(response, "runtimeBudgetMinutes", ""), "taskLoadClass": shared.StringArg(response, "taskLoadClass", ""), "expectedArtifactExtensions": shared.ListArg(response, "expectedArtifactExtensions"), "requiredArtifactExtensions": shared.ListArg(response, "requiredArtifactExtensions"), }, }, t.notify) } func TestOpenClawTaskLookupParamsIncludesWorkspaceDir(t *testing.T) { params := openClawTaskLookupParams(map[string]any{ "appThreadKey": "draft:sample-task", "openclawSessionKey": "agent:main:draft:sample-task", "runId": "turn-sample", "includeArtifacts": true, }) if got := shared.StringArg(params, "workspaceDir", ""); got != "~/.openclaw/workspace" { t.Fatalf("expected default OpenClaw workspaceDir, got %#v", params) } } func newExternalSingleAgentProvider( t *testing.T, providerID string, output string, ) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/acp/rpc" { http.NotFound(w, r) return } defer func() { _ = r.Body.Close() }() var request map[string]any if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatalf("decode request: %v", err) } method := strings.TrimSpace(shared.StringArg(request, "method", "")) result := map[string]any{ "success": true, "output": output, "turnId": "turn-" + providerID, "provider": providerID, "mode": "single-agent", } switch method { case "thread/start", "thread/resume": result = map[string]any{"id": "provider-thread-" + providerID} case "turn/start": result["summary"] = output } _ = json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": request["id"], "result": result, }) })) } func TestHandleRoutingResolveCoversNineScenarioBuckets(t *testing.T) { localAvailableSkills := []map[string]any{ {"id": "pptx", "label": "PPTX", "description": "slides", "installed": true}, {"id": "docx", "label": "DOCX", "description": "docs", "installed": true}, {"id": "xlsx", "label": "XLSX", "description": "sheets", "installed": true}, {"id": "pdf", "label": "PDF", "description": "pdf", "installed": true}, {"id": "image-resizer", "label": "image-resizer", "description": "image resize", "installed": true}, {"id": "browser-automation", "label": "Browser Automation", "description": "browser", "installed": true}, } cases := []struct { name string prompt string expectedExecutionTarget string expectedGatewayProviderID string expectedSkillSource string expectedResolvedSkill string expectedNeedsSkillInstall bool }{ { name: "powerpoint-pptx", prompt: "create a powerpoint deck for this launch", expectedExecutionTarget: "single-agent", expectedSkillSource: "local_match", expectedResolvedSkill: "PPTX", }, { name: "word-docx", prompt: "draft a word document memo", expectedExecutionTarget: "single-agent", expectedSkillSource: "local_match", expectedResolvedSkill: "DOCX", }, { name: "excel-xlsx", prompt: "build an excel workbook with formulas", expectedExecutionTarget: "single-agent", expectedSkillSource: "local_match", expectedResolvedSkill: "XLSX", }, { name: "pdf", prompt: "merge and fill this pdf form", expectedExecutionTarget: "single-agent", expectedSkillSource: "local_match", expectedResolvedSkill: "PDF", }, { name: "image-resizer", prompt: "batch resize image assets", expectedExecutionTarget: "single-agent", expectedSkillSource: "local_match", expectedResolvedSkill: "image-resizer", }, { name: "image-cog", prompt: "use image-cog to generate consistent characters", expectedExecutionTarget: "gateway", expectedGatewayProviderID: "openclaw", expectedSkillSource: "find_skills", expectedNeedsSkillInstall: true, }, { name: "image-video-generation-editting", prompt: "wan 图生视频并做视频编辑", expectedExecutionTarget: "gateway", expectedGatewayProviderID: "openclaw", expectedSkillSource: "find_skills", expectedNeedsSkillInstall: true, }, { name: "video-translator", prompt: "translate video subtitles and dub the clip", expectedExecutionTarget: "gateway", expectedGatewayProviderID: "openclaw", expectedSkillSource: "find_skills", expectedNeedsSkillInstall: true, }, { name: "browser-search-news", prompt: "跨浏览器执行并搜索最新资讯采集结果", expectedExecutionTarget: "gateway", expectedGatewayProviderID: "openclaw", expectedSkillSource: "local_match", expectedResolvedSkill: "Browser Automation", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { result := handleRoutingResolve(map[string]any{ "taskPrompt": tc.prompt, "workingDirectory": "/tmp/workspace", "routing": map[string]any{ "routingMode": "auto", "preferredGatewayProviderId": "openclaw", "allowSkillInstall": false, "availableSkills": func() []any { values := make([]any, 0, len(localAvailableSkills)) for _, item := range localAvailableSkills { values = append(values, item) } return values }(), }, }) if got := result["resolvedExecutionTarget"]; got != tc.expectedExecutionTarget { t.Fatalf("expected execution target %q, got %#v", tc.expectedExecutionTarget, got) } if tc.expectedGatewayProviderID != "" { if got := result["resolvedGatewayProviderId"]; got != tc.expectedGatewayProviderID { t.Fatalf("expected gateway provider %q, got %#v", tc.expectedGatewayProviderID, got) } } if _, exists := result["resolvedEndpointTarget"]; exists { t.Fatalf("expected resolvedEndpointTarget compatibility field to be removed, got %#v", result) } if got := result["skillResolutionSource"]; got != tc.expectedSkillSource { t.Fatalf("expected skill source %q, got %#v", tc.expectedSkillSource, got) } if tc.expectedResolvedSkill != "" { resolvedSkills, _ := result["resolvedSkills"].([]string) if len(resolvedSkills) == 0 || resolvedSkills[0] != tc.expectedResolvedSkill { t.Fatalf("expected resolved skill %q, got %#v", tc.expectedResolvedSkill, result["resolvedSkills"]) } } if got := result["needsSkillInstall"]; got != tc.expectedNeedsSkillInstall { t.Fatalf("expected needsSkillInstall=%v, got %#v", tc.expectedNeedsSkillInstall, got) } }) } } func TestHandleRoutingResolveAcceptsTopLevelGatewayContract(t *testing.T) { server := NewServer() server.mu.Lock() server.providerOrder = []string{"codex"} server.providers = map[string]ProviderCompat{ "codex": newProviderCompat(syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: "ws://127.0.0.1:9001/acp", Enabled: true, }), } server.mu.Unlock() res, err := server.routingEngine.Resolve(context.Background(), map[string]any{ "taskPrompt": "openclaw gateway task", "executionTarget": "gateway", "gatewayProviderId": "openclaw", }) if err != nil { t.Fatalf("resolve routing: %v", err) } if got := res.TargetID; got != "gateway" { t.Fatalf("expected gateway execution target, got %#v", got) } if got := res.GatewayProviderID; got != "openclaw" { t.Fatalf("expected openclaw gateway provider, got %#v", got) } if got := res.ProviderID; got != "" { t.Fatalf("expected no single-agent provider for gateway, got %#v", got) } } func TestExecuteSessionTaskAutoRoutingRecordsProjectMemory(t *testing.T) { homeDir := t.TempDir() workspaceDir := filepath.Join(t.TempDir(), "workspace") if err := os.MkdirAll(workspaceDir, 0o755); err != nil { t.Fatalf("create workspace: %v", err) } t.Setenv("HOME", homeDir) server := NewServer() providerServer := newExternalSingleAgentProvider(t, "codex", "done") defer providerServer.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: providerServer.URL, Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Params: map[string]any{ "sessionId": "session-auto", "threadId": "thread-auto", "provider": "codex", "taskPrompt": "create a powerpoint deck for launch", "workingDirectory": workspaceDir, "routing": map[string]any{ "routingMode": "auto", "availableSkills": []any{ map[string]any{ "id": "pptx", "label": "PPTX", "description": "slides", "installed": true, }, }, }, }, }, }) if rpcErr != nil { t.Fatalf("expected success, got rpc error: %v", rpcErr) } if success, _ := response["success"].(bool); !success { t.Fatalf("expected success response, got %#v", response) } projectLocalMemory := filepath.Join(workspaceDir, ".xworkmate", "memory.md") content, err := os.ReadFile(projectLocalMemory) if err != nil { t.Fatalf("expected memory file %s: %v", projectLocalMemory, err) } text := string(content) if !strings.Contains(text, "preferred-route: single-agent") { t.Fatalf("expected preferred route in %s, got %q", projectLocalMemory, text) } if !strings.Contains(text, "preferred-skills: PPTX") { t.Fatalf("expected preferred skills in %s, got %q", projectLocalMemory, text) } projectHomeMemory := filepath.Join( homeDir, "self-improving", "projects", filepath.Base(workspaceDir)+".md", ) if _, err := os.Stat(projectHomeMemory); !os.IsNotExist(err) { t.Fatalf("expected auto memory write to stay project-local only, got stat err=%v", err) } } func TestExecuteSessionTaskExplicitRoutingDoesNotRecordProjectMemory(t *testing.T) { homeDir := t.TempDir() workspaceDir := filepath.Join(t.TempDir(), "workspace") if err := os.MkdirAll(workspaceDir, 0o755); err != nil { t.Fatalf("create workspace: %v", err) } t.Setenv("HOME", homeDir) server := NewServer() providerServer := newExternalSingleAgentProvider(t, "codex", "done") defer providerServer.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: providerServer.URL, Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Params: map[string]any{ "sessionId": "session-explicit", "threadId": "thread-explicit", "provider": "codex", "taskPrompt": "create a powerpoint deck for launch", "workingDirectory": workspaceDir, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", "explicitProviderId": "codex", "availableSkills": []any{ map[string]any{ "id": "pptx", "label": "PPTX", "description": "slides", "installed": true, }, }, }, }, }, }) if rpcErr != nil { t.Fatalf("expected success, got rpc error: %v", rpcErr) } if success, _ := response["success"].(bool); !success { t.Fatalf("expected success response, got %#v", response) } projectHomeMemory := filepath.Join( homeDir, "self-improving", "projects", filepath.Base(workspaceDir)+".md", ) projectLocalMemory := filepath.Join(workspaceDir, ".xworkmate", "memory.md") for _, target := range []string{projectHomeMemory, projectLocalMemory} { if _, err := os.Stat(target); !os.IsNotExist(err) { t.Fatalf("expected no memory write for explicit routing at %s, err=%v", target, err) } } } func TestExecuteSessionTaskExplicitProviderRequiresAdvertisedBridgeProvider(t *testing.T) { server := NewServer() response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-explicit-provider", "threadId": "thread-explicit-provider", "taskPrompt": "create a powerpoint deck for launch", "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", "explicitProviderId": "claude", }, }, }, }) if rpcErr != nil { t.Fatalf("expected structured response, got rpc error: %v", rpcErr) } if success, _ := response["success"].(bool); success { t.Fatalf("expected unavailable response, got %#v", response) } if got := response["unavailableCode"]; got != "PROVIDER_UNAVAILABLE" { t.Fatalf("expected PROVIDER_UNAVAILABLE, got %#v", response) } if got := response["unavailableMessage"]; got != "explicit provider is unavailable" { t.Fatalf("expected explicit provider unavailable message, got %#v", response) } } func TestExecuteSessionTaskExplicitGatewayUsesResolvedGatewayProvider(t *testing.T) { server := NewServer() response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-explicit-gateway", "threadId": "thread-explicit-gateway", "taskPrompt": "search latest news", "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "explicitProviderId": "claude", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr == nil { t.Fatalf("expected gateway connectivity rpc error, got response: %v", response) } else if rpcErr.Message == "GATEWAY_PROVIDER_REQUIRED" { t.Fatalf("expected resolved gateway provider to be reused, got %q", rpcErr.Message) } } func TestExecuteSessionTaskGatewayAutoConnectsLocalOpenClaw(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", "threadId": "thread-openclaw", "taskPrompt": "say pong", "workingDirectory": t.TempDir(), "metadata": map[string]any{ "xworkmateTaskArtifactContract": map[string]any{ "expectedArtifactDirs": []any{"assets/images/", "reports/"}, }, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["output"]; got != "gateway pong" { t.Fatalf("expected gateway pong output, got %#v", response) } if got := response["resolvedGatewayProviderId"]; got != "openclaw" { t.Fatalf("expected openclaw gateway provider, got %#v", response) } if gateway.ConnectCount() != 1 { t.Fatalf("expected one automatic gateway connect, got %d", gateway.ConnectCount()) } if gateway.ChatSendCount() != 1 { t.Fatalf("expected one OpenClaw chat.send request, got %d", gateway.ChatSendCount()) } if gateway.ArtifactPrepareCount() != 1 { t.Fatalf("expected one OpenClaw artifact prepare request before chat.send, got %d", gateway.ArtifactPrepareCount()) } prepareParams := gateway.LastArtifactPrepareParams() if got := shared.StringArg(prepareParams, "appThreadKey", ""); got != "thread-openclaw" { t.Fatalf("expected prepare appThreadKey to match app thread, got %#v", prepareParams) } if got := shared.StringArg(prepareParams, "openclawSessionKey", ""); got != "agent:main:thread-openclaw" { t.Fatalf("expected readable OpenClaw session key, got %#v", prepareParams) } if _, ok := prepareParams["sessionKey"]; ok { t.Fatalf("expected prepare params to omit legacy sessionKey, got %#v", prepareParams) } if _, ok := prepareParams["sessionId"]; ok { t.Fatalf("expected prepare params to omit app sessionId, got %#v", prepareParams) } if _, ok := prepareParams["threadId"]; ok { t.Fatalf("expected prepare params to omit app threadId, got %#v", prepareParams) } if got := shared.StringArg(prepareParams, "workspaceDir", ""); got != "~/.openclaw/workspace" { t.Fatalf("expected bridge to supply OpenClaw workspaceDir, got %#v", prepareParams) } if got := shared.ListArg(prepareParams, "expectedArtifactDirs"); !sameAnyStringSlice(got, []string{"assets/images/", "reports/"}) { t.Fatalf("expected prepare expectedArtifactDirs from app contract, got %#v", prepareParams) } chatParams := gateway.LastChatSendParams() if got, want := shared.StringArg(prepareParams, "requestId", ""), shared.StringArg(chatParams, "idempotencyKey", ""); got == "" || got != want { t.Fatalf("expected prepare requestId to match chat idempotencyKey %q, got %#v", want, prepareParams) } if got, want := shared.StringArg(prepareParams, "externalTaskId", ""), shared.StringArg(chatParams, "idempotencyKey", ""); got == "" || got != want { t.Fatalf("expected prepare externalTaskId to match chat idempotencyKey %q, got %#v", want, prepareParams) } for _, key := range []string{ "artifactDirectory", "artifactScope", "artifactScopeKind", "relativeArtifactDirectory", "remoteWorkingDirectory", "remoteWorkspaceRefKind", "xworkmateArtifacts", "expectedArtifactDirs", } { if _, ok := chatParams[key]; ok { t.Fatalf("expected chat.send params to omit bridge artifact/workspace field %q, got %#v", key, chatParams) } } receipt := strings.TrimSpace(shared.StringArg(chatParams, "systemProvenanceReceipt", "")) openClawSessionKey := shared.StringArg(chatParams, "sessionKey", "") if openClawSessionKey == "" { t.Fatalf("expected mapped OpenClaw sessionKey, got %#v", chatParams) } for _, expected := range []string{ "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) } } if gateway.AgentWaitCount() != 0 { t.Fatalf("expected native task lookup to avoid Bridge-owned agent.wait, got %d", gateway.AgentWaitCount()) } if gateway.ArtifactExportCount() != 1 { t.Fatalf("expected empty terminal task lookup to fall back to artifact export, got %d", gateway.ArtifactExportCount()) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get", "xworkmate.artifacts.export"}) { t.Fatalf("expected connect, prepare, chat.send, native task lookup, then artifact export fallback, got %#v", got) } client := gateway.LastConnectClient() if got := client["id"]; got != "openclaw-macos" { t.Fatalf("expected OpenClaw-compatible client id, got %#v", client) } if got := strings.TrimSpace(shared.StringArg(client, "modelIdentifier", "")); got == "" { t.Fatalf("expected non-empty modelIdentifier, got %#v", client) } } func TestExecuteSessionTaskOpenClawVideoWithInlineImagesReturnsArtifact(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() gateway.artifactWorkspaceRoot = t.TempDir() 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-video-attachments", "threadId": "thread-video-attachments", "taskPrompt": "制作视频:使用附件图片生成 final.mp4 视频制品", "workingDirectory": t.TempDir(), "attachments": []any{ map[string]any{"name": "01-frame.png", "description": "image/png", "path": ""}, map[string]any{"name": "02-frame.png", "description": "image/png", "path": ""}, }, "inlineAttachments": []any{ map[string]any{ "name": "01-frame.png", "mimeType": "image/png", "content": base64.StdEncoding.EncodeToString([]byte("image-1")), }, map[string]any{ "name": "02-frame.png", "mimeType": "image/png", "content": base64.StdEncoding.EncodeToString([]byte("image-2")), }, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } chatParams := gateway.LastChatSendParams() if _, ok := chatParams["attachments"]; ok { t.Fatalf("chat.send params must not forward attachments; use message paths instead, got %#v", chatParams) } if message := shared.StringArg(chatParams, "message", ""); !strings.Contains(message, "01-frame.png") || !strings.Contains(message, "02-frame.png") { t.Fatalf("expected chat.send message to include materialized image paths, got %q", message) } artifacts := shared.ListArg(response, "artifacts") if len(artifacts) == 0 { t.Fatalf("expected video artifact output, got %#v", response) } foundVideo := false for _, raw := range artifacts { artifact := shared.AsMap(raw) if shared.StringArg(artifact, "contentType", "") == "video/mp4" || strings.HasSuffix(strings.ToLower(shared.StringArg(artifact, "relativePath", "")), ".mp4") { foundVideo = true break } } if !foundVideo { t.Fatalf("expected mp4 artifact output, got %#v", artifacts) } } func TestOpenClawAgentWaitTimeoutAdaptsToVideoWork(t *testing.T) { base := openClawAgentWaitTimeout( map[string]any{"taskPrompt": "say pong"}, map[string]any{"message": "say pong"}, ) video := openClawAgentWaitTimeout( map[string]any{ "taskPrompt": "测试制作 云原生ServiceMesh网络 主题的 科普视频,使用 it-infra-evolution-video skill 渲染 mp4", "attachments": []any{ map[string]any{"path": "assets/images/001.png"}, map[string]any{"path": "assets/images/002.png"}, }, }, map[string]any{"message": "测试制作 云原生ServiceMesh网络 主题的 科普视频,使用 it-infra-evolution-video skill 渲染 mp4"}, ) if base != openClawAgentWaitDefaultTimeout { t.Fatalf("expected simple task to use default timeout, got %s", base) } if video <= base { t.Fatalf("expected video task timeout %s to exceed simple task timeout %s", video, base) } if video > openClawAgentWaitMaxTimeout { t.Fatalf("expected video task timeout %s to stay within max %s", video, openClawAgentWaitMaxTimeout) } } func TestOpenClawAgentWaitTimeoutUsesOneHourForLongPDFImageWork(t *testing.T) { prompt := "拆分为多章节。每个章节调用 Codex 使用 GPT images2 模型制作插图,最后输出为更新版本 PDF 文件" timeout := openClawAgentWaitTimeout( map[string]any{"taskPrompt": prompt}, map[string]any{"message": prompt}, ) if openClawAgentWaitMaxTimeout != time.Hour { t.Fatalf("expected OpenClaw max agent.wait timeout to be one hour, got %s", openClawAgentWaitMaxTimeout) } if timeout != openClawAgentWaitMaxTimeout { t.Fatalf("expected long PDF image task to use max timeout %s, got %s", openClawAgentWaitMaxTimeout, timeout) } } func TestGatewayRequestForwardsOpenClawSkillsStatus(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() connected, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.gateway.connect", Params: map[string]any{ "runtimeId": "app-runtime", "gatewayProviderId": "openclaw", }, }, nil) if rpcErr != nil { t.Fatalf("expected gateway connect, got rpc error: %#v", rpcErr) } if connected["ok"] != true { t.Fatalf("expected gateway connect ok, got %#v", connected) } response, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.gateway.request", Params: map[string]any{ "runtimeId": "app-runtime", "method": "skills.status", "params": map[string]any{ "agentId": "main", }, "timeoutMs": float64(15000), }, }, nil) if rpcErr != nil { t.Fatalf("expected skills.status gateway response, got rpc error: %#v", rpcErr) } if response["ok"] != true { t.Fatalf("expected skills.status ok, got %#v", response) } payload := shared.AsMap(response["payload"]) if got := shared.StringArg(payload, "workspaceDir", ""); got != "/remote/openclaw/workspace" { t.Fatalf("expected workspaceDir from OpenClaw payload, got %#v", payload) } if got := shared.StringArg(payload, "managedSkillsDir", ""); got != "/remote/openclaw/skills" { t.Fatalf("expected managedSkillsDir from OpenClaw payload, got %#v", payload) } skills := shared.ListArg(payload, "skills") if len(skills) != 2 { t.Fatalf("expected two OpenClaw skill records, got %#v", payload["skills"]) } first := shared.AsMap(skills[0]) if got := shared.StringArg(first, "skillKey", ""); got != "it-infra-continuous-png" { t.Fatalf("expected bridge to preserve skillKey, got %#v", first) } if first["eligible"] != true { t.Fatalf("expected eligible OpenClaw skill, got %#v", first) } second := shared.AsMap(skills[1]) if second["blockedByAgentFilter"] != true || second["disabled"] != true { t.Fatalf("expected disabled/blocked skill metadata to be preserved, got %#v", second) } if !slices.Contains(gateway.Methods(), "skills.status") { t.Fatalf("expected fake OpenClaw gateway to receive skills.status, got %#v", gateway.Methods()) } } func TestGatewayRequestSkillsStatusAutoConnectsOpenClaw(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml")) t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json")) resetBridgeGatewayIdentityForTest() t.Cleanup(resetBridgeGatewayIdentityForTest) server := NewServer() response, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.gateway.request", Params: map[string]any{ "runtimeId": "app-runtime", "method": "skills.status", "params": map[string]any{ "agentId": "main", }, "timeoutMs": float64(15000), }, }, nil) if rpcErr != nil { t.Fatalf("expected skills.status gateway response, got rpc error: %#v", rpcErr) } if response["ok"] != true { t.Fatalf("expected skills.status ok, got %#v", response) } payload := shared.AsMap(response["payload"]) skills := shared.ListArg(payload, "skills") if len(skills) != 2 { t.Fatalf("expected two OpenClaw skill records, got %#v", payload["skills"]) } if got := gateway.ConnectCount(); got != 1 { t.Fatalf("expected skills.status to auto-connect OpenClaw once, got %d", got) } if !slices.Contains(gateway.Methods(), "skills.status") { t.Fatalf("expected fake OpenClaw gateway to receive skills.status, got %#v", gateway.Methods()) } } func TestExecuteSessionTaskGatewayFailsWhenPrepareUnsupported(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.unsupportedSessionPrepare.Store(true) 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-legacy-prepare", "threadId": "thread-openclaw-legacy-prepare", "taskPrompt": "say pong", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr == nil { t.Fatalf("expected prepare error without legacy fallback, got response: %#v", response) return } if rpcErr.Code != -32002 || !strings.Contains(rpcErr.Message, "unknown method: xworkmate.session.prepare") { t.Fatalf("expected surfaced prepare unsupported error, got %#v", rpcErr) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare"}) { t.Fatalf("expected bridge to stop before chat.send when prepare is unsupported, got %#v", got) } } func TestExecuteSessionTaskGatewayNoDisplayableOutputFails(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-no-output", "threadId": "thread-openclaw-no-output", "taskPrompt": "completed-empty", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected structured no-output response, got rpc error: %#v", rpcErr) } if success, _ := response["success"].(bool); success { t.Fatalf("expected no-output gateway response to fail, got %#v", response) } if got := response["status"]; got != "failed" { t.Fatalf("expected failed status for no-output gateway response, got %#v", response) } if got := response["code"]; got != "OPENCLAW_NO_DISPLAYABLE_OUTPUT" { t.Fatalf("expected structured no-displayable code, got %#v", response) } if got := response["output"]; got != openClawNoDisplayableText { t.Fatalf("expected no-displayable output message, got %#v", response) } if gateway.ArtifactExportCount() != 0 { t.Fatalf("expected native task-registry failure to avoid Bridge artifact export sync, got %d", gateway.ArtifactExportCount()) } } func TestExecuteSessionTaskGatewayFailsClosedWhenOpenClawAcceptsDifferentSession(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.alternateSessionKey = "dashboard:c061bfeb-ad08-45f5-971d-d9018f745d7a" 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": "draft:1780669943199412-3", "threadId": "draft:1780669943199412-3", "taskPrompt": "say pong", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr == nil { t.Fatalf("expected OpenClaw session mismatch rpc error, got response %#v", response) return } if !strings.Contains(rpcErr.Message, "OPENCLAW_SESSION_MISMATCH") { t.Fatalf("expected structured session mismatch error, got %#v", rpcErr) } if gateway.AgentWaitCount() != 0 { t.Fatalf("session mismatch must fail before agent.wait, got %d waits", gateway.AgentWaitCount()) } if gateway.ArtifactExportCount() != 0 { t.Fatalf("session mismatch must fail before artifact export, got %d exports", gateway.ArtifactExportCount()) } chatParams := gateway.LastChatSendParams() if got := shared.StringArg(chatParams, "sessionKey", ""); got != "agent:main:draft:1780669943199412-3" { t.Fatalf("expected Bridge to request the app-mapped OpenClaw session, got %#v", chatParams) } } func TestExecuteSessionTaskGatewayFailsArtifactContractAfterWaitFailure(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() gateway.artifactWorkspaceRoot = t.TempDir() 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-wait-recover", "threadId": "thread-openclaw-wait-recover", "taskPrompt": "wait-timeout", "workingDirectory": t.TempDir(), "metadata": map[string]any{ "taskLoadClass": "complex_long_chain_task", "expectedArtifactExtensions": []any{"pdf"}, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected wait-timeout probe to keep task running, got rpc error: %#v", rpcErr) } if got := response["status"]; got != string(TaskStateRunning) { t.Fatalf("expected wait-timeout probe to keep running status, got %#v", response) } if got := gateway.ChatSendCount(); got != 1 { t.Fatalf("expected no automatic repair model turn, got %d", got) } if got := gateway.AgentWaitCount(); got != 0 { t.Fatalf("expected native task-registry lookup without Bridge-owned status probe, got %d", got) } if got := gateway.ArtifactExportCount(); got != 0 { t.Fatalf("expected no artifact export before terminal state, got %d", got) } } func TestExecuteSessionTaskGatewayKeepsRunningOnNonTerminalWaitPayload(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() gateway.artifactWorkspaceRoot = t.TempDir() 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-running-wait", "threadId": "thread-openclaw-running-wait", "taskPrompt": "wait-running", "workingDirectory": t.TempDir(), "metadata": map[string]any{ "taskLoadClass": "complex_long_chain_task", "expectedArtifactExtensions": []any{"pdf"}, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected running wait payload to keep task running, got rpc error: %#v", rpcErr) } if got := response["status"]; got != string(TaskStateRunning) { t.Fatalf("expected running status from non-terminal wait payload, got %#v", response) } if got := gateway.ChatSendCount(); got != 1 { t.Fatalf("expected no repair turn, got %d", got) } if got := gateway.AgentWaitCount(); got != 0 { t.Fatalf("expected native task-registry lookup without Bridge-owned status probe, got %d", got) } if got := gateway.ArtifactExportCount(); got != 0 { t.Fatalf("expected no artifact export before terminal wait payload, got %d", got) } if _, ok := response["code"]; ok { t.Fatalf("expected no terminal failure code, 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() 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", "threadId": "thread-openclaw", "taskPrompt": "continue", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["output"]; got != "gateway pong" { t.Fatalf("expected gateway pong output, got %#v", response) } if gateway.ChatSendCount() != 1 { t.Fatalf("expected one OpenClaw chat.send request, got %d", gateway.ChatSendCount()) } if gateway.AgentWaitCount() != 0 { t.Fatalf("expected native task-registry lookup without Bridge-owned agent.wait, got %d", gateway.AgentWaitCount()) } if gateway.ArtifactExportCount() != 0 { t.Fatalf("expected native task-registry lookup without Bridge artifact export sync, got %d", gateway.ArtifactExportCount()) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } } func TestInternalJobsSubmitCompletesAndReportsStats(t *testing.T) { server := NewServer() providerServer := newExternalSingleAgentProvider(t, "opencode", "job-output") defer providerServer.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "opencode", Label: "OpenCode", Endpoint: providerServer.URL, Enabled: true, }) submitted, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.jobs.submit", Params: map[string]any{ "providerId": "opencode", "sessionId": "job-session", "threadId": "job-thread", "taskPrompt": "run async job", "workingDirectory": t.TempDir(), "timeoutMs": 30_000, "orchestrationMode": "sequence", }, }, nil) if rpcErr != nil { t.Fatalf("expected job submission, got %#v", rpcErr) } jobID := strings.TrimSpace(shared.StringArg(submitted, "jobId", "")) if jobID == "" { t.Fatalf("expected job id, got %#v", submitted) } var job map[string]any waitForCondition(t, func() bool { job, _ = server.handleJobMethod(context.Background(), "xworkmate.jobs.get", map[string]any{"jobId": jobID}, nil) return shared.StringArg(job, "status", "") == "completed" }) result := shared.AsMap(job["result"]) if got := strings.TrimSpace(shared.StringArg(result, "output", "")); got != "job-output" { t.Fatalf("expected job output, got %#v", job) } stats, rpcErr := server.handleJobMethod(context.Background(), "xworkmate.jobs.stats", nil, nil) if rpcErr != nil { t.Fatalf("expected stats, got %#v", rpcErr) } summary := shared.AsMap(stats["summary"]) if got := fmt.Sprint(summary["completed"]); got != "1" { t.Fatalf("expected completed job stats, got %#v", stats) } } func TestInternalJobWebhookRetriesUntilSuccess(t *testing.T) { server := NewServer() providerServer := newExternalSingleAgentProvider(t, "opencode", "job-output") defer providerServer.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "opencode", Label: "OpenCode", Endpoint: providerServer.URL, Enabled: true, }) var callbackCount atomic.Int32 callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { count := callbackCount.Add(1) if count == 1 { http.Error(w, "retry", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) })) defer callbackServer.Close() submitted, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.jobs.submit", Params: map[string]any{ "providerId": "opencode", "sessionId": "job-webhook-session", "threadId": "job-webhook-thread", "taskPrompt": "run async job", "workingDirectory": t.TempDir(), "callbackUrl": callbackServer.URL, "timeoutMs": 30_000, }, }, nil) if rpcErr != nil { t.Fatalf("expected job submission, got %#v", rpcErr) } jobID := strings.TrimSpace(shared.StringArg(submitted, "jobId", "")) waitForCondition(t, func() bool { job, _ := server.handleJobMethod(context.Background(), "xworkmate.jobs.get", map[string]any{"jobId": jobID}, nil) return parseBool(job["webhookSent"]) && callbackCount.Load() >= 2 }) } func TestInternalToolsInvokeUsesOpenClawHTTPProxy(t *testing.T) { var received map[string]any toolsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer tool-token" { t.Fatalf("expected bearer token, got %q", got) } if err := json.NewDecoder(r.Body).Decode(&received); err != nil { t.Fatalf("decode tool request: %v", err) } _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) })) defer toolsServer.Close() t.Setenv("OPENCLAW_TOOLS_INVOKE_URL", toolsServer.URL) t.Setenv("OPENCLAW_TOOLS_TOKEN", "tool-token") server := NewServer() response, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.tools.invoke", Params: map[string]any{ "tool": "message", "action": "send", "args": map[string]any{ "target": "channel:1", "message": "hello", }, }, }, nil) if rpcErr != nil { t.Fatalf("expected tools response, got %#v", rpcErr) } if !parseBool(response["ok"]) { t.Fatalf("expected ok tools response, got %#v", response) } if got := shared.StringArg(received, "tool", ""); got != "message" { t.Fatalf("expected message tool payload, got %#v", received) } } func TestExternalACPWebSocketAutoApprovesPermissionRequests(t *testing.T) { var sawApproval atomic.Bool upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { t.Fatalf("upgrade: %v", err) } defer func() { _ = conn.Close() }() var request map[string]any if err := conn.ReadJSON(&request); err != nil { t.Fatalf("read request: %v", err) } requestID := request["id"] if err := conn.WriteJSON(map[string]any{ "jsonrpc": "2.0", "id": "perm-1", "method": "session/request_permission", "params": map[string]any{ "reason": "test permission", }, }); err != nil { t.Fatalf("write permission request: %v", err) } var approval map[string]any if err := conn.ReadJSON(&approval); err != nil { t.Fatalf("read approval: %v", err) } if approval["id"] == "perm-1" && parseBool(shared.AsMap(approval["result"])["approved"]) { sawApproval.Store(true) } _ = conn.WriteJSON(map[string]any{ "jsonrpc": "2.0", "id": requestID, "result": map[string]any{ "success": true, "output": "approved output", }, }) })) defer wsServer.Close() compat := newProviderCompat(syncedProvider{ ProviderID: "hermes", Label: "Hermes", Endpoint: "ws" + strings.TrimPrefix(wsServer.URL, "http") + "/acp", Enabled: true, }) result, err := compat.StartSession(context.Background(), "permission-session", "permission-thread", map[string]any{ "taskPrompt": "needs permission", }, nil) if err != nil { t.Fatalf("expected permission auto approval, got %v", err) } if !sawApproval.Load() { t.Fatalf("expected permission approval response") } if got := shared.StringArg(result, "output", ""); got != "approved output" { t.Fatalf("expected approved output, got %#v", result) } } func TestStructuredExternalACPEventClassifiesNativeStreams(t *testing.T) { cases := []struct { name string raw map[string]any want string }{ { name: "thinking", raw: map[string]any{ "method": "session.update", "params": map[string]any{"update": map[string]any{"thinking": "planning"}}, }, want: "thinking", }, { name: "tool_call", raw: map[string]any{ "method": "item/tool_call", "params": map[string]any{"item": map[string]any{"toolCall": map[string]any{"name": "read"}}}, }, want: "tool_call", }, { name: "text", raw: map[string]any{ "method": "session.update", "params": map[string]any{"update": map[string]any{"text": "hello"}}, }, want: "text", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { event := structuredExternalACPEvent(tc.raw) if got := shared.StringArg(event, "type", ""); got != tc.want { t.Fatalf("expected event type %q, got %#v", tc.want, event) } }) } } func TestExecuteSessionTaskGatewaySurfacesOpenClawChatSendError(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-fail", "threadId": "thread-openclaw-fail", "taskPrompt": "fail", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr == nil { t.Fatalf("expected OpenClaw chat.send error, got response: %#v", response) } else if rpcErr.Code != -32002 || !strings.Contains(rpcErr.Message, "openclaw chat failed") { t.Fatalf("expected surfaced chat.send failure, got %#v", rpcErr) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send"}) { t.Fatalf("expected connect, artifact prepare, then chat.send, got %#v", got) } } func TestExecuteSessionTaskGatewayRetriesOpenClawChatSendSocketClose(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.closeNextChatSend.Store(true) 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-retry", "threadId": "thread-openclaw-retry", "taskPrompt": "retry after socket close", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected retry success, got rpc error: %#v", rpcErr) } if got := response["output"]; got != "gateway pong" { t.Fatalf("expected gateway pong output after retry, got %#v", response) } if gateway.ConnectCount() != 2 { t.Fatalf("expected reconnect after socket close, got %d connects", gateway.ConnectCount()) } if gateway.ChatSendCount() != 2 { t.Fatalf("expected chat.send to be retried once, got %d", gateway.ChatSendCount()) } } func TestExecuteSessionTaskGatewayReturnsStructuredOpenClawSocketCloseAfterRetry(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.alwaysCloseChatSend.Store(true) 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-retry-fail", "threadId": "thread-openclaw-retry-fail", "taskPrompt": "retry fails", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr == nil { t.Fatalf("expected OpenClaw socket close error, got response: %#v", response) } else if rpcErr.Code != -32002 || !strings.Contains(rpcErr.Message, "OPENCLAW_GATEWAY_SOCKET_CLOSED") { t.Fatalf("expected structured socket close failure, got %#v", rpcErr) } data := shared.AsMap(rpcErr.Data) if got := shared.StringArg(data, "code", ""); got != "OPENCLAW_GATEWAY_SOCKET_CLOSED" { t.Fatalf("expected socket close detail code, got %#v", rpcErr.Data) } if gateway.ChatSendCount() != 2 { t.Fatalf("expected one retry only, got %d chat.send attempts", gateway.ChatSendCount()) } } func TestExecuteSessionTaskGatewaySurfacesOpenClawAgentWaitError(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-wait-fail", "threadId": "thread-openclaw-wait-fail", "taskPrompt": "wait-error", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected OpenClaw agent.wait error as task result, got rpc error: %#v", rpcErr) } if got := response["status"]; got != string(TaskStateFailed) { t.Fatalf("expected failed task result, got %#v", response) } if got := response["code"]; got != "OPENCLAW_WAIT_FAILED" { t.Fatalf("expected OpenClaw wait failure code, got %#v", response) } if got := shared.StringArg(response, "message", ""); !strings.Contains(got, "openclaw wait failed") { t.Fatalf("expected surfaced agent.wait failure, got %#v", response) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } snapshot := server.handleTaskGet(context.Background(), map[string]any{ "appThreadKey": "thread-openclaw-wait-fail", "openclawSessionKey": "agent:main:thread-openclaw-wait-fail", "runId": shared.StringArg(response, "runId", ""), }, nil) if got := snapshot["status"]; got != string(TaskStateFailed) { t.Fatalf("expected failed session snapshot, got %#v from %#v", got, snapshot) } result := snapshot if got := result["success"]; got != false { t.Fatalf("expected failed result snapshot, got %#v", result) } if got := shared.StringArg(snapshot, "code", ""); got != "OPENCLAW_WAIT_FAILED" { t.Fatalf("expected OpenClaw wait code in snapshot, got %#v", result) } if got := shared.StringArg(snapshot, "message", ""); !strings.Contains(got, "openclaw wait failed") { t.Fatalf("expected OpenClaw wait message in snapshot, got %#v", result) } } func TestSessionCloseReturnsAcceptedAndClosedState(t *testing.T) { server := NewServer() sessionID := "session-close-contract" threadID := "thread-close-contract" _ = server.getOrCreateSession(sessionID, threadID) response, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "session.close", Params: map[string]any{ "sessionId": sessionID, "threadId": threadID, }, }, nil) if rpcErr != nil { t.Fatalf("expected close response, got rpc error: %#v", rpcErr) } if got := response["accepted"]; got != true { t.Fatalf("expected accepted close, got %#v", response) } if got := response["closed"]; got != true { t.Fatalf("expected closed=true for existing session, got %#v", response) } response, rpcErr = server.handleRequest(shared.RPCRequest{ Method: "session.close", Params: map[string]any{ "sessionId": sessionID, "threadId": threadID, }, }, nil) if rpcErr != nil { t.Fatalf("expected idempotent close response, got rpc error: %#v", rpcErr) } if got := response["accepted"]; got != true { t.Fatalf("expected accepted idempotent close, got %#v", response) } if got := response["closed"]; got != false { t.Fatalf("expected closed=false for missing session, got %#v", response) } } func TestExecuteSessionTaskGatewayExportsOpenClawArtifacts(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-artifact", "threadId": "thread-openclaw-artifact", "taskPrompt": "make artifact", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if gateway.ArtifactExportCount() != 0 { t.Fatalf("expected native task-registry artifact payload without Bridge export, got %d", gateway.ArtifactExportCount()) } if got := response["remoteWorkingDirectory"]; got != "/remote/openclaw/workspace" { t.Fatalf("expected remote working directory from manifest, got %#v", response) } artifacts, ok := response["artifacts"].([]map[string]any) if !ok { raw, ok := response["artifacts"].([]any) if !ok { t.Fatalf("expected artifacts payload, got %#v", response["artifacts"]) } artifacts = make([]map[string]any, 0, len(raw)) for _, item := range raw { artifacts = append(artifacts, shared.AsMap(item)) } } if len(artifacts) != 1 { t.Fatalf("expected one artifact from manifest, got %#v", artifacts) } if got := artifacts[0]["relativePath"]; got != "reports/final.md" { t.Fatalf("expected manifest artifact relative path, got %#v", artifacts[0]) } if _, ok := artifacts[0]["encoding"]; ok { t.Fatalf("expected OpenClaw task response to omit inline artifact encoding, got %#v", artifacts[0]) } if _, ok := artifacts[0]["content"]; ok { t.Fatalf("expected OpenClaw task response to omit inline artifact content, got %#v", artifacts[0]) } downloadURL := strings.TrimSpace(shared.StringArg(artifacts[0], "downloadUrl", "")) if downloadURL == "" { t.Fatalf("expected bridge downloadUrl on artifact, got %#v", artifacts[0]) } parsedDownloadURL, err := url.Parse(downloadURL) if err != nil { t.Fatalf("parse downloadUrl: %v", err) } 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 != shared.StringArg(response, "openclawSessionKey", "") { 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/"+shared.StringArg(response, "openclawSessionKey", "")+"/"+response["runId"].(string) { t.Fatalf("expected prepared artifact scope in downloadUrl, got %q", artifactScope) } if parsedDownloadURL.Query().Get("sig") == "" { t.Fatalf("expected signed downloadUrl, got %q", downloadURL) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } } func TestExecuteSessionTaskGatewayDecoratesFallbackArtifacts(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-fallback-artifact", "threadId": "thread-openclaw-fallback-artifact", "taskPrompt": "fallback artifact", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } artifacts := []map[string]any{} rawArtifacts, ok := response["artifacts"].([]any) if !ok { t.Fatalf("expected artifacts payload, got %#v", response["artifacts"]) } for _, item := range rawArtifacts { artifacts = append(artifacts, shared.AsMap(item)) } if len(artifacts) != 1 { t.Fatalf("expected one fallback artifact, got %#v", response["artifacts"]) } if got := shared.StringArg(artifacts[0], "relativePath", ""); got != "ai-news-2026-06.md" { t.Fatalf("expected fallback artifact relative path, got %#v", artifacts[0]) } downloadURL := strings.TrimSpace(shared.StringArg(artifacts[0], "downloadUrl", "")) if downloadURL == "" { t.Fatalf("expected bridge to decorate fallback artifact downloadUrl, got %#v", artifacts[0]) } parsedDownloadURL, err := url.Parse(downloadURL) if err != nil { t.Fatalf("parse downloadUrl: %v", err) } 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 != shared.StringArg(response, "openclawSessionKey", "") { t.Fatalf("expected mapped sessionKey in downloadUrl, got %q", got) } if got := parsedDownloadURL.Query().Get("runId"); got != response["runId"].(string) { t.Fatalf("expected runId in downloadUrl, got %q", got) } if got := parsedDownloadURL.Query().Get("relativePath"); got != "ai-news-2026-06.md" { t.Fatalf("expected artifact relativePath in downloadUrl, got %q", got) } if parsedDownloadURL.Query().Get("sig") == "" { t.Fatalf("expected signed downloadUrl, got %q", downloadURL) } if _, ok := artifacts[0]["content"]; ok { t.Fatalf("expected OpenClaw task response to omit inline artifact content, got %#v", artifacts[0]) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } } func TestExecuteSessionTaskGatewayDoesNotTreatPromptTextAsArtifactContract(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-latest-artifact", "threadId": "thread-openclaw-latest-artifact", "taskPrompt": "检查 workspace 已有真实制品,输出 artifacts files download。不要生成新文件,只简短说明。", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } 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 gateway exported none, got %#v", response["artifacts"]) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } } func TestExecuteSessionTaskGatewayExportsWithActualOpenClawRunID(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.alternateRunID = "openclaw-run-actual" 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-actual-run", "threadId": "thread-openclaw-actual-run", "taskPrompt": "make artifact", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["runId"]; got != "openclaw-run-actual" { t.Fatalf("expected response to keep actual OpenClaw runId, got %#v", response) } if gateway.ArtifactPrepareCount() != 2 { t.Fatalf("expected bridge to prepare initial turn scope and actual OpenClaw run scope, got %d", gateway.ArtifactPrepareCount()) } artifacts, ok := response["artifacts"].([]map[string]any) if !ok { raw, rawOK := response["artifacts"].([]any) if !rawOK { t.Fatalf("expected actual-run artifact manifest, got %#v", response["artifacts"]) } artifacts = make([]map[string]any, 0, len(raw)) for _, item := range raw { artifacts = append(artifacts, shared.AsMap(item)) } } if len(artifacts) != 1 { t.Fatalf("expected actual-run artifact manifest, got %#v", response["artifacts"]) } 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("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/"+shared.StringArg(response, "openclawSessionKey", "")+"/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.session.prepare", "chat.send", "xworkmate.session.prepare", "xworkmate.tasks.get"}) { t.Fatalf("expected bridge to reprepare actual OpenClaw run before native task lookup, got %#v", got) } } func TestExecuteSessionTaskGatewayDoesNotExportArtifactScopeDeclaredInOutput(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.artifactWorkspaceRoot = t.TempDir() defer gateway.Close() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") const sessionKey = "draft-1780110089528457-2" const actualRunID = "turn-1780353431540965793" actualScope := "tasks/draft_1780110089528457-2/" + actualRunID actualFile := filepath.Join( gateway.artifactWorkspaceRoot, filepath.FromSlash(actualScope), "AI_Agent_News_June_2_2026.md", ) if err := os.MkdirAll(filepath.Dir(actualFile), 0o755); err != nil { t.Fatalf("create actual artifact dir: %v", err) } if err := os.WriteFile(actualFile, []byte("news artifact"), 0o644); err != nil { t.Fatalf("write actual artifact: %v", err) } gateway.alternateRunID = actualRunID server := NewServer() response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": sessionKey, "threadId": sessionKey, "taskPrompt": "declare output artifact path", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["success"]; got != true { t.Fatalf("expected text-only response to complete without adopting output-declared artifact, got %#v", response) } if got := gateway.ArtifactExportCount(); got != 0 { t.Fatalf("expected no Bridge export from output-declared artifact path, got %d", got) } if artifacts, ok := response["artifacts"]; ok { t.Fatalf("expected no artifact from output-declared path, got %#v", artifacts) } } func TestExecuteSessionTaskGatewayDoesNotExportDraftScopeVariant(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.artifactWorkspaceRoot = t.TempDir() defer gateway.Close() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") const sessionKey = "draft-1780110089528457-2" const actualRunID = "turn-1780353431540965793" actualScope := "tasks/draft_1780110089528457-2/" + actualRunID actualFile := filepath.Join( gateway.artifactWorkspaceRoot, filepath.FromSlash(actualScope), "AI_Agent_News_June_2_2026.md", ) if err := os.MkdirAll(filepath.Dir(actualFile), 0o755); err != nil { t.Fatalf("create actual artifact dir: %v", err) } if err := os.WriteFile(actualFile, []byte("news artifact"), 0o644); err != nil { t.Fatalf("write actual artifact: %v", err) } gateway.alternateRunID = actualRunID server := NewServer() response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": sessionKey, "threadId": sessionKey, "taskPrompt": "plain done", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["success"]; got != true { t.Fatalf("expected text-only task to complete without adopting draft variant artifact, got %#v", response) } if got := gateway.ArtifactExportCount(); got != 0 { t.Fatalf("expected no Bridge export from draft scope variant, got %d", got) } if artifacts, ok := response["artifacts"]; ok { t.Fatalf("expected no artifact from draft scope variant, got %#v", artifacts) } } func TestExecuteSessionMessageGatewayDoesNotRewriteClaimedArtifactsWithoutGatewayFiles(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-claimed-artifact", "threadId": "thread-openclaw-claimed-artifact", "taskPrompt": "hi hallucinate-files", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["success"]; got != true { t.Fatalf("expected bridge to preserve gateway terminal state, 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 gateway.ArtifactExportCount() != 0 { t.Fatalf("expected native task-registry lookup without Bridge artifact export, got %d", gateway.ArtifactExportCount()) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } } func TestExecuteSessionMessageGatewayVerifiesClaimedArtifactsWhenExportRequired(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-claimed-required-artifact", "threadId": "thread-openclaw-claimed-required-artifact", "taskPrompt": "fallback artifact hallucinate-files", "workingDirectory": t.TempDir(), "metadata": map[string]any{ "xworkmateTaskArtifactContract": map[string]any{ "requiresExportBeforeFinalResponse": true, "expectedArtifactDirs": []any{"exports/"}, }, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["status"]; got != string(TaskStateRunning) { t.Fatalf("expected required but unverified artifact claim to stay syncing, got %#v", response) } if artifacts := extractArtifactPayloads(response, shared.StringArg(response, "remoteWorkingDirectory", "")); len(artifacts) != 0 { t.Fatalf("expected bridge to remove unverified native artifact claims, got %#v", artifacts) } if got := shared.StringArg(shared.AsMap(response["progress"]), "stage", ""); got != "syncing-artifacts" { t.Fatalf("expected syncing-artifacts progress, got %#v", response) } if gateway.ArtifactExportCount() != 1 { t.Fatalf("expected Bridge artifact export verification, got %d", gateway.ArtifactExportCount()) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get", "xworkmate.artifacts.export"}) { t.Fatalf("expected connect, prepare, chat.send, task lookup, then artifact export, got %#v", got) } } func TestExecuteSessionMessageGatewayExportsArtifactsWithoutPromptHeuristic(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) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) { t.Fatalf("expected connect, prepare, chat.send, then native task lookup, got %#v", got) } } func TestNormalizeResultStripsOpenClawInlineArtifactsAfterRecordNormalization(t *testing.T) { server := NewServer() orchestrator := NewSessionOrchestrator(server) sess := server.getOrCreateSession("session-openclaw-normalize", "thread-openclaw-normalize") response := orchestrator.normalizeResult( sess, map[string]any{ "success": true, "output": "created files", "artifacts": []any{ map[string]any{ "relativePath": "reports/final.md", "label": "final.md", "contentType": "text/markdown", "sizeBytes": 12, "sha256": "fake-sha256", "downloadUrl": "https://xworkmate-bridge.svc.plus/artifacts/openclaw/download?relativePath=reports%2Ffinal.md", "encoding": "base64", "content": "ZmluYWwgcmVwb3J0", }, }, }, RoutingResult{ TargetID: "gateway", ProviderID: "gateway", }, "turn-openclaw-normalize", map[string]any{}, ) artifacts, ok := response["artifacts"].([]map[string]any) if !ok { t.Fatalf("expected normalized artifacts, got %#v", response["artifacts"]) } if len(artifacts) != 1 { t.Fatalf("expected one artifact, got %#v", artifacts) } if _, ok := artifacts[0]["encoding"]; ok { t.Fatalf("expected normalized OpenClaw artifact to omit encoding, got %#v", artifacts[0]) } if _, ok := artifacts[0]["content"]; ok { t.Fatalf("expected normalized OpenClaw artifact to omit content, got %#v", artifacts[0]) } if got := strings.TrimSpace(shared.StringArg(artifacts[0], "downloadUrl", "")); got == "" { t.Fatalf("expected normalized artifact to keep downloadUrl, got %#v", artifacts[0]) } } func TestOpenClawArtifactResponseUsesRequestRoutingProvider(t *testing.T) { result := map[string]any{ "success": true, "output": "created files", "artifactScope": "tasks/thread-openclaw/run-openclaw", "artifactDirectory": "/remote/openclaw/workspace/tasks/thread-openclaw/run-openclaw", } routing := RoutingResult{ TargetID: "gateway", ProviderID: "gateway", } params := map[string]any{ "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, } if !openClawArtifactResponse(result, routing, params) { t.Fatalf("expected scoped result with request routing to be treated as OpenClaw artifact response") } if got := openClawGatewayProviderForArtifacts(result, routing, params); got != "openclaw" { t.Fatalf("expected OpenClaw provider from request routing, got %q", got) } } func TestHTTPHandlerOpenClawArtifactDownloadReadsViaGateway(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() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", time.Now(), ) if downloadURL == "" { t.Fatal("expected signed download URL") } parsedDownloadURL, err := url.Parse(downloadURL) if err != nil { t.Fatalf("parse downloadUrl: %v", err) } if got := parsedDownloadURL.Query().Get("openclawSessionKey"); got != "thread-openclaw-artifact" { t.Fatalf("expected openclawSessionKey in downloadUrl, got %q", got) } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, downloadURL, nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%q", recorder.Code, recorder.Body.String()) } if got := recorder.Body.String(); got != "final report" { t.Fatalf("expected artifact content from OpenClaw read, got %q", got) } if got := recorder.Header().Get("Content-Type"); got != "text/markdown" { t.Fatalf("expected content type from artifact metadata, got %q", got) } if gateway.ArtifactReadCount() != 1 { t.Fatalf("expected one OpenClaw artifact read request, got %d", gateway.ArtifactReadCount()) } readParams := gateway.LastArtifactReadParams() if got := shared.StringArg(readParams, "openclawSessionKey", ""); got != "thread-openclaw-artifact" { t.Fatalf("expected OpenClaw artifact read to use openclawSessionKey, got %#v", readParams) } if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.read"}) { t.Fatalf("expected connect, then artifact read, got %#v", got) } } func TestHTTPHandlerOpenClawArtifactDownloadSupportsRangeResume(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() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", time.Now(), ) if downloadURL == "" { t.Fatal("expected signed download URL") } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, downloadURL, nil) request.Header.Set("Authorization", "Bearer bridge-token") request.Header.Set("Range", "bytes=6-") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusPartialContent { t.Fatalf("expected 206, got %d body=%q", recorder.Code, recorder.Body.String()) } if got := recorder.Body.String(); got != "report" { t.Fatalf("expected resumed artifact content, got %q", got) } if got := recorder.Header().Get("Accept-Ranges"); got != "bytes" { t.Fatalf("expected byte ranges to be advertised, got %q", got) } if got := recorder.Header().Get("Content-Range"); got != "bytes 6-11/12" { t.Fatalf("expected content range, got %q", got) } if got := recorder.Header().Get("Content-Length"); got != "6" { t.Fatalf("expected partial content length, got %q", got) } } func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidRange(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() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", time.Now(), ) if downloadURL == "" { t.Fatal("expected signed download URL") } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, downloadURL, nil) request.Header.Set("Authorization", "Bearer bridge-token") request.Header.Set("Range", "bytes=99-") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusRequestedRangeNotSatisfiable { t.Fatalf("expected 416, got %d body=%q", recorder.Code, recorder.Body.String()) } if got := recorder.Header().Get("Content-Range"); got != "bytes */12" { t.Fatalf("expected unsatisfied content range, got %q", got) } } func TestHTTPHandlerOpenClawArtifactDownloadRetriesTransientReadFailure(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.FailNextArtifactReads(1) defer gateway.Close() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") server := NewServer() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", time.Now(), ) if downloadURL == "" { t.Fatal("expected signed download URL") } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, downloadURL, nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("expected retry to return 200, got %d body=%q", recorder.Code, recorder.Body.String()) } if got := recorder.Body.String(); got != "final report" { t.Fatalf("expected artifact content from retry, got %q", got) } if gateway.ArtifactReadCount() != 2 { t.Fatalf("expected one failed read and one retry, got %d", gateway.ArtifactReadCount()) } } func TestHTTPHandlerOpenClawArtifactDownloadReturnsArtifactMissing(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() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "missing.txt", time.Now(), ) if downloadURL == "" { t.Fatal("expected signed download URL") } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, downloadURL, nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d body=%q", recorder.Code, recorder.Body.String()) } if !strings.Contains(recorder.Body.String(), "artifact_missing") { t.Fatalf("expected artifact_missing response, got %q", recorder.Body.String()) } if gateway.ArtifactReadCount() != 1 { t.Fatalf("expected one OpenClaw artifact read request, got %d", gateway.ArtifactReadCount()) } } func TestHTTPHandlerOpenClawArtifactDownloadRequiresBearer(t *testing.T) { t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") server := NewServer() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", time.Now(), ) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, downloadURL, nil) server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d body=%q", recorder.Code, recorder.Body.String()) } } func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidSignature(t *testing.T) { t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") server := NewServer() downloadURL := server.openClawArtifactDownloadURL( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", time.Now(), ) parsed, err := url.Parse(downloadURL) if err != nil { t.Fatalf("parse downloadUrl: %v", err) } query := parsed.Query() query.Set("sig", "bad") parsed.RawQuery = query.Encode() recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, parsed.String(), nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d body=%q", recorder.Code, recorder.Body.String()) } } func TestHTTPHandlerOpenClawArtifactDownloadRejectsExpiredSignature(t *testing.T) { t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") values := url.Values{} expires := fmt.Sprintf("%d", time.Now().Add(-time.Minute).Unix()) values.Set("sessionKey", "thread-openclaw-artifact") values.Set("runId", "run-1") values.Set("artifactScope", "tasks/thread-openclaw-artifact/run-1") values.Set("relativePath", "reports/final.md") values.Set("expires", expires) values.Set("sig", signOpenClawArtifactDownload( "thread-openclaw-artifact", "run-1", "tasks/thread-openclaw-artifact/run-1", "reports/final.md", expires, )) server := NewServer() recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, openClawArtifactDownloadPath+"?"+values.Encode(), nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusGone { t.Fatalf("expected 410, got %d body=%q", recorder.Code, recorder.Body.String()) } } func TestHTTPHandlerOpenClawArtifactDownloadRejectsTraversalPath(t *testing.T) { t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") values := url.Values{} values.Set("sessionKey", "thread-openclaw-artifact") values.Set("runId", "run-1") values.Set("relativePath", "../secret.txt") values.Set("expires", fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix())) values.Set("sig", "irrelevant") server := NewServer() recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, openClawArtifactDownloadPath+"?"+values.Encode(), nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%q", recorder.Code, recorder.Body.String()) } } func TestHTTPHandlerOpenClawArtifactDownloadRejectsInvalidArtifactScope(t *testing.T) { t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") values := url.Values{} values.Set("sessionKey", "thread-openclaw-artifact") values.Set("runId", "run-1") values.Set("artifactScope", "../outside") values.Set("relativePath", "reports/final.md") values.Set("expires", fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix())) values.Set("sig", "irrelevant") server := NewServer() recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, openClawArtifactDownloadPath+"?"+values.Encode(), nil) request.Header.Set("Authorization", "Bearer bridge-token") server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%q", recorder.Code, recorder.Body.String()) } } func TestOpenClawChatSendParamsPreservesRawPrompt(t *testing.T) { for _, prompt := range []string{ "输出 PPT PDF docx 文件", "生成一张图片并返回制品", "render a video artifact for download", "write a csv dataset file", } { t.Run(prompt, func(t *testing.T) { chatParams, rpcErr := openClawChatSendParams(map[string]any{ "threadId": "thread-artifact-instructions", "taskPrompt": prompt, }, "turn-artifact-instructions") if rpcErr != nil { t.Fatalf("expected chat params, got rpc error: %#v", rpcErr) } message := strings.TrimSpace(shared.StringArg(chatParams, "message", "")) if message != prompt { t.Fatalf("expected bridge to preserve raw prompt without artifact instructions, got %q", message) } }) } } func TestOpenClawChatSendParamsMaterializesInlineAttachments(t *testing.T) { workspace := t.TempDir() chatParams, rpcErr := openClawChatSendParams(map[string]any{ "threadId": "thread-attachments", "taskPrompt": "inspect uploaded image", "workingDirectory": workspace, "attachments": []any{ map[string]any{"name": "empty-placeholder.txt", "path": ""}, }, "inlineAttachments": []any{ map[string]any{ "name": "prompt.png", "mimeType": "image/png", "content": base64.StdEncoding.EncodeToString([]byte("image-bytes")), }, }, }, "turn-inline-attachments") if rpcErr != nil { t.Fatalf("expected chat params, got rpc error: %#v", rpcErr) } if _, ok := chatParams["attachments"]; ok { t.Fatalf("chat.send params must not forward attachments; use message paths instead, got %#v", chatParams) } attachmentDirectory := filepath.Join(workspace, ".xworkmate", "attachments", "turn-inline-attachments") path := filepath.Join(attachmentDirectory, "01-prompt.png") content, err := os.ReadFile(path) if err != nil { t.Fatalf("expected materialized file to exist: %v", err) } if string(content) != "image-bytes" { t.Fatalf("expected materialized content, got %q", string(content)) } if got := shared.StringArg(chatParams, "message", ""); !strings.Contains(got, path) { t.Fatalf("expected message to include materialized attachment path, got %q", got) } if _, ok := chatParams["inlineAttachments"]; ok { t.Fatalf("chat.send params must not forward raw inlineAttachments, got %#v", chatParams) } } func TestOpenClawChatSendParamsVideoInlineImagesUsePromptPathsOnly(t *testing.T) { workspace := t.TempDir() chatParams, rpcErr := openClawChatSendParams(map[string]any{ "threadId": "thread-video-attachments", "taskPrompt": "制作视频", "workingDirectory": workspace, "attachments": []any{ map[string]any{ "name": "01-single-machine.png", "description": "image/png", "path": "/Users/shenlan/Pictures/01-single-machine.png", }, }, "inlineAttachments": []any{ map[string]any{ "name": "01-single-machine.png", "mimeType": "image/png", "content": base64.StdEncoding.EncodeToString([]byte("image-1")), }, map[string]any{ "name": "02-lan-era.png", "mimeType": "image/png", "content": base64.StdEncoding.EncodeToString([]byte("image-2")), }, map[string]any{ "name": "03-web-era.png", "mimeType": "image/png", "content": base64.StdEncoding.EncodeToString([]byte("image-3")), }, }, }, "turn-video-inline-images") if rpcErr != nil { t.Fatalf("expected chat params, got rpc error: %#v", rpcErr) } if _, ok := chatParams["attachments"]; ok { t.Fatalf("chat.send params must not forward attachments; use message paths instead, got %#v", chatParams) } if _, ok := chatParams["inlineAttachments"]; ok { t.Fatalf("chat.send params must not forward raw inlineAttachments, got %#v", chatParams) } message := shared.StringArg(chatParams, "message", "") for index, name := range []string{"01-single-machine.png", "02-lan-era.png", "03-web-era.png"} { path := filepath.Join( workspace, ".xworkmate", "attachments", "turn-video-inline-images", fmt.Sprintf("%02d-%s", index+1, name), ) content, err := os.ReadFile(path) if err != nil { t.Fatalf("expected materialized image %s to exist: %v", path, err) } if got := string(content); got != fmt.Sprintf("image-%d", index+1) { t.Fatalf("expected materialized image content, got %q", got) } if !strings.Contains(message, path) { t.Fatalf("expected message to include materialized image path %q, got %q", path, message) } } if !strings.Contains(message, "制作视频") { t.Fatalf("expected message to preserve video prompt, got %q", message) } if strings.Contains(message, "/Users/shenlan/Pictures/01-single-machine.png") { t.Fatalf("OpenClaw message must use materialized remote paths, got %q", message) } } func TestOpenClawChatSendParamsMaterializesInlineAttachmentsInRemoteHint(t *testing.T) { remoteWorkspace := t.TempDir() chatParams, rpcErr := openClawChatSendParams(map[string]any{ "threadId": "thread-remote-attachments", "taskPrompt": "inspect uploaded file", "workingDirectory": "/Users/local/.xworkmate/threads/thread-remote-attachments", "remoteWorkingDirectoryHint": remoteWorkspace, "inlineAttachments": []any{ map[string]any{ "name": "note.txt", "mimeType": "text/plain", "content": base64.StdEncoding.EncodeToString([]byte("note body")), }, }, }, "turn-remote-attachments") if rpcErr != nil { t.Fatalf("expected chat params, got rpc error: %#v", rpcErr) } if _, ok := chatParams["attachments"]; ok { t.Fatalf("chat.send params must not forward attachments; use message paths instead, got %#v", chatParams) } path := filepath.Join(remoteWorkspace, ".xworkmate", "attachments", "turn-remote-attachments", "01-note.txt") if !strings.HasPrefix(path, remoteWorkspace) { t.Fatalf("expected attachment under remote workspace %q, got %q", remoteWorkspace, path) } if strings.Contains(path, "/Users/local/") { t.Fatalf("attachment path must not use desktop local workspace, got %q", path) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("expected materialized file to exist: %v", err) } if string(content) != "note body" { t.Fatalf("expected materialized content, got %q", string(content)) } } func TestOpenClawChatSendParamsMapsOwnerScopedWorkspaceToWritableRoot(t *testing.T) { writableRoot := t.TempDir() t.Setenv("OPENCLAW_WRITABLE_WORKSPACE_ROOT", writableRoot) ownerWorkspace := "/owners/local/device/demo/threads/draft-1" params := withOpenClawWritableWorkspace(map[string]any{ "sessionId": "draft-1", "threadId": "draft-1", "taskPrompt": "write into currentTaskWorkspace: " + ownerWorkspace, "workingDirectory": ownerWorkspace, "remoteWorkingDirectoryHint": ownerWorkspace, "inlineAttachments": []any{ map[string]any{ "name": "note.txt", "mimeType": "text/plain", "content": base64.StdEncoding.EncodeToString([]byte("note body")), }, }, }, "draft-1") chatParams, rpcErr := openClawChatSendParams(params, "turn-owner-workspace") if rpcErr != nil { t.Fatalf("expected chat params, got rpc error: %#v", rpcErr) } writableWorkspace := filepath.Join(writableRoot, "draft-1") if got := shared.StringArg(params, "workingDirectory", ""); got != writableWorkspace { t.Fatalf("expected writable working directory %q, got %q", writableWorkspace, got) } if got := shared.StringArg(params, "remoteWorkingDirectoryHint", ""); got != writableWorkspace { t.Fatalf("expected writable remote hint %q, got %q", writableWorkspace, got) } message := shared.StringArg(chatParams, "message", "") if strings.Contains(message, "/owners/") { t.Fatalf("message must not reference owner-scoped workspace, got %q", message) } if !strings.Contains(message, writableWorkspace) { t.Fatalf("message should reference writable workspace %q, got %q", writableWorkspace, message) } if _, ok := chatParams["attachments"]; ok { t.Fatalf("chat.send params must not forward attachments; use message paths instead, got %#v", chatParams) } path := filepath.Join(writableWorkspace, ".xworkmate", "attachments", "turn-owner-workspace", "01-note.txt") if !strings.HasPrefix(path, writableWorkspace) { t.Fatalf("expected materialized attachment under writable workspace %q, got %q", writableWorkspace, path) } } func TestOpenClawWritableWorkspaceDoesNotDefaultToLegacyTaskArtifacts(t *testing.T) { t.Setenv("OPENCLAW_WRITABLE_WORKSPACE_ROOT", "") params := withOpenClawWritableWorkspace(map[string]any{ "sessionId": "draft-1", "threadId": "draft-1", "taskPrompt": "write into /owners/local/device/demo/threads/draft-1", "workingDirectory": "/owners/local/device/demo/threads/draft-1", "remoteWorkingDirectoryHint": "/owners/local/device/demo/threads/draft-1", }, "draft-1") if got := shared.StringArg(params, "workingDirectory", ""); strings.Contains(got, "task_artifacts") { t.Fatalf("must not synthesize legacy task_artifacts workspace, got %q", got) } if got := shared.StringArg(params, "remoteWorkingDirectoryHint", ""); strings.Contains(got, "task_artifacts") { t.Fatalf("must not synthesize legacy task_artifacts remote hint, got %q", got) } } func TestOpenClawArtifactWorkspaceDirIgnoresOwnerScopedRemoteHint(t *testing.T) { t.Setenv("OPENCLAW_WORKSPACE_DIR", "") got := openClawArtifactWorkspaceDir(map[string]any{ "workingDirectory": "/owners/local/device/demo/threads/draft-1", "remoteWorkingDirectoryHint": "/owners/local/device/demo/threads/draft-1", }) if got != "~/.openclaw/workspace" { t.Fatalf("expected default OpenClaw workspace root, got %q", got) } } func TestOpenClawPreparedArtifactWorkspaceRewritesOwnerPromptToArtifactDirectory(t *testing.T) { ownerWorkspace := "/owners/local/device/demo/threads/draft-1" artifactDirectory := "/home/ubuntu/.openclaw/workspace/tasks/agent-main-draft-1/turn-1" params := withOpenClawPreparedArtifactWorkspace(map[string]any{ "taskPrompt": "" + "TaskThread workspace context:\n" + "- remoteWorkspaceHint: " + ownerWorkspace + "\n" + "- currentTaskWorkspace: " + ownerWorkspace + "\n" + "User request:\nwrite a report", "workingDirectory": ownerWorkspace, "remoteWorkingDirectoryHint": ownerWorkspace, "metadata": map[string]any{ "xworkmateTaskArtifactContract": map[string]any{ "currentTaskWorkspace": ownerWorkspace, "remoteWorkspaceHint": ownerWorkspace, }, }, }, &openClawPreparedArtifactScope{ ArtifactDirectory: artifactDirectory, ArtifactScope: "tasks/agent-main-draft-1/turn-1", }) if got := shared.StringArg(params, "workingDirectory", ""); got != artifactDirectory { t.Fatalf("expected workingDirectory rewritten to artifact directory, got %q", got) } if got := shared.StringArg(params, "remoteWorkingDirectoryHint", ""); got != artifactDirectory { t.Fatalf("expected remote hint rewritten to artifact directory, got %q", got) } message := shared.StringArg(params, "taskPrompt", "") if strings.Contains(message, ownerWorkspace) { t.Fatalf("prompt must not keep owner-scoped workspace, got %q", message) } if count := strings.Count(message, artifactDirectory); count != 2 { t.Fatalf("expected artifact directory to replace both workspace prompt references, count=%d message=%q", count, message) } } func TestExecuteSessionTaskGatewayRejectsOversizedInlineAttachmentBeforeChatSend(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.artifactWorkspaceRoot = t.TempDir() defer gateway.Close() t.Setenv("GATEWAY_RPC_URL", gateway.URL()) t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") oversized := make([]byte, openClawInlineAttachmentMaxFileBytes+1) server := NewServer() response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-oversized-attachment", "threadId": "thread-oversized-attachment", "taskPrompt": "inspect attachment", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, "inlineAttachments": []any{ map[string]any{ "name": "too-large.bin", "mimeType": "application/octet-stream", "content": base64.StdEncoding.EncodeToString(oversized), }, }, }, }, }) if rpcErr == nil { t.Fatalf("expected oversized attachment rpc error, got response: %#v", response) } else if !strings.Contains(rpcErr.Message, "OPENCLAW_ATTACHMENT_FILE_TOO_LARGE") { t.Fatalf("expected attachment size error, got %#v", rpcErr) } if gateway.ChatSendCount() != 0 { t.Fatalf("oversized attachment must not reach chat.send, got %d", gateway.ChatSendCount()) } } func TestExecuteSessionTaskGatewayCollectsOpenClawEventArtifacts(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) gateway.artifactMode = "unknown" 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-event-artifact", "threadId": "thread-openclaw-event-artifact", "taskPrompt": "event artifact", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } artifacts, ok := response["artifacts"].([]map[string]any) if !ok { raw, ok := response["artifacts"].([]any) if !ok { t.Fatalf("expected artifacts payload, got %#v", response["artifacts"]) } artifacts = make([]map[string]any, 0, len(raw)) for _, item := range raw { artifacts = append(artifacts, shared.AsMap(item)) } } if len(artifacts) != 1 { t.Fatalf("expected one event artifact, got %#v", artifacts) } if got := artifacts[0]["relativePath"]; got != "events/live.txt" { t.Fatalf("expected event artifact relative path, got %#v", artifacts[0]) } if got := response["remoteWorkingDirectory"]; got != "/remote/openclaw/events" { t.Fatalf("expected remote working directory from event, got %#v", response) } } func TestExecuteSessionTaskGatewayKeepsRunningWhenTerminalLookupExportsNoArtifacts(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-artifact-missing", "threadId": "thread-openclaw-artifact-missing", "taskPrompt": "say pong", "workingDirectory": t.TempDir(), "metadata": map[string]any{ "xworkmateTaskArtifactContract": map[string]any{ "requiresExportBeforeFinalResponse": true, }, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, }) if rpcErr != nil { t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) } if got := response["status"]; got != string(TaskStateRunning) { t.Fatalf("expected empty terminal artifact export to keep task running, got %#v", response) } if got := shared.StringArg(shared.AsMap(response["progress"]), "stage", ""); got != "syncing-artifacts" { t.Fatalf("expected syncing-artifacts progress, got %#v", response) } if gateway.ArtifactExportCount() != 1 { t.Fatalf("expected artifact export fallback after empty terminal lookup, got %d", gateway.ArtifactExportCount()) } } func TestTaskGetBackfillsArtifactScopeFromBridgeSession(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() start, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-openclaw-scope-backfill", "threadId": "thread-openclaw-scope-backfill", "taskPrompt": "say pong", "workingDirectory": t.TempDir(), "metadata": map[string]any{ "xworkmateTaskArtifactContract": map[string]any{ "requiresExportBeforeFinalResponse": true, }, }, "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", "preferredGatewayProviderId": "openclaw", }, }, }, nil) if rpcErr != nil { t.Fatalf("expected running task handle, got rpc error: %#v", rpcErr) } if got := start["status"]; got != string(TaskStateRunning) { t.Fatalf("expected running start response, got %#v", start) } response, rpcErr := server.handleRequest(shared.RPCRequest{ Method: "xworkmate.tasks.get", Params: map[string]any{ "runId": shared.StringArg(start, "runId", ""), "appThreadKey": shared.StringArg(start, "appThreadKey", ""), "openclawSessionKey": shared.StringArg(start, "openclawSessionKey", ""), "includeArtifacts": true, }, }, nil) if rpcErr != nil { t.Fatalf("expected task lookup response, got rpc error: %#v", rpcErr) } if got := response["status"]; got != string(TaskStateRunning) { t.Fatalf("expected empty terminal lookup to remain running, got %#v", response) } exportParams := gateway.LastArtifactExportParams() if got := shared.StringArg(exportParams, "artifactScope", ""); got == "" { t.Fatalf("expected Bridge to backfill artifactScope for export, got %#v", exportParams) } if got, want := shared.StringArg(exportParams, "artifactScope", ""), shared.StringArg(start, "artifactScope", ""); got != want { t.Fatalf("expected export artifactScope %q, got %#v", want, exportParams) } } func TestExtractArtifactPayloadsDoesNotScanRemoteDirectoryFallback(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "stale.txt"), []byte("stale"), 0o644); err != nil { t.Fatalf("write stale file: %v", err) } artifacts := extractArtifactPayloads(map[string]any{ "remoteWorkingDirectory": root, "artifacts": []any{}, }, root) if len(artifacts) != 0 { t.Fatalf("expected no directory fallback artifacts, got %#v", artifacts) } } func TestExecuteSessionTaskDefaultsExplicitGatewayToOpenClaw(t *testing.T) { server := NewServer() _, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-gateway-missing-provider", "threadId": "thread-gateway-missing-provider", "taskPrompt": "search latest news", "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "gateway", }, }, }, }) if rpcErr == nil { t.Fatal("expected gateway connectivity error") } else if rpcErr.Message == "GATEWAY_PROVIDER_REQUIRED" { t.Fatalf("expected openclaw default from routing result, got %#v", rpcErr) } } func TestExtractArtifactPayloadsPreservesDownloadURLOnlyArtifacts(t *testing.T) { artifacts := extractArtifactPayloads(map[string]any{ "artifacts": []any{ map[string]any{ "name": "reports/final.txt", "downloadURL": "https://xworkmate-bridge.svc.plus/artifacts/final.txt", }, map[string]any{ "download_url": "https://xworkmate-bridge.svc.plus/artifacts/from-url.md", }, }, }, "") if len(artifacts) != 2 { t.Fatalf("expected two artifacts, got %#v", artifacts) } if got := artifacts[0]["relativePath"]; got != "reports/final.txt" { t.Fatalf("expected name-derived path, got %#v", got) } if got := artifacts[0]["downloadUrl"]; got != "https://xworkmate-bridge.svc.plus/artifacts/final.txt" { t.Fatalf("expected normalized downloadUrl, got %#v", got) } if _, ok := artifacts[0]["downloadURL"]; ok { t.Fatalf("expected downloadURL alias to be removed: %#v", artifacts[0]) } if got := artifacts[1]["relativePath"]; got != "from-url.md" { t.Fatalf("expected URL basename path, got %#v", got) } if got := artifacts[1]["contentType"]; got != "text/plain" { t.Fatalf("expected markdown content type, got %#v", got) } } func TestExtractArtifactPayloadsRejectsUnsafeDownloadURLArtifactNames(t *testing.T) { artifacts := extractArtifactPayloads(map[string]any{ "artifacts": []any{ map[string]any{ "name": "../secrets.txt", "downloadUrl": "https://xworkmate-bridge.svc.plus/artifacts/secrets.txt", }, }, }, "") if len(artifacts) != 0 { t.Fatalf("expected unsafe artifact to be dropped, got %#v", artifacts) } } type acpFakeOpenClawGateway struct { server *http.Server listener net.Listener connectCount atomic.Int32 chatSendCount atomic.Int32 agentWaitCount atomic.Int32 artifactPrepareCount atomic.Int32 artifactSnapshotCount atomic.Int32 artifactCount atomic.Int32 artifactReadCount atomic.Int32 artifactReadFailures atomic.Int32 rejectDeviceTokenOnce atomic.Bool closeNextChatSend atomic.Bool alwaysCloseChatSend atomic.Bool agentWaitDelayMs atomic.Int64 largeGatewayPayloadBytes atomic.Int64 emitAgentDelta atomic.Bool lastConnectClient atomic.Value lastChatSendParams atomic.Value lastArtifactPrepareParams atomic.Value lastArtifactSnapshotParams atomic.Value lastArtifactExportParams atomic.Value lastArtifactReadParams atomic.Value lastAgentWaitParams atomic.Value mu sync.Mutex methods []string runMessages map[string]string artifactMode string artifactWorkspaceRoot string alternateRunID string alternateSessionKey string unsupportedSessionPrepare atomic.Bool } func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen fake openclaw gateway: %v", err) } fake := &acpFakeOpenClawGateway{listener: listener, runMessages: map[string]string{}} upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } defer func() { _ = conn.Close() }() _ = conn.WriteJSON(map[string]any{ "type": "event", "event": "connect.challenge", "payload": map[string]any{ "nonce": "nonce-1", }, }) for { _, payload, err := conn.ReadMessage() if err != nil { return } var frame map[string]any if err := json.Unmarshal(payload, &frame); err != nil { continue } if strings.TrimSpace(shared.StringArg(frame, "type", "")) != "req" { continue } id := frame["id"] method := strings.TrimSpace(shared.StringArg(frame, "method", "")) fake.recordMethod(method) switch method { case "connect": fake.connectCount.Add(1) params := shared.AsMap(frame["params"]) device := shared.AsMap(params["device"]) publicKey, err := base64.RawURLEncoding.DecodeString( strings.TrimSpace(shared.StringArg(device, "publicKey", "")), ) sum := sha256.Sum256(publicKey) if err != nil || shared.StringArg(device, "id", "") != hex.EncodeToString(sum[:]) { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "INVALID_REQUEST", "message": "device identity mismatch", "details": map[string]any{ "code": "DEVICE_AUTH_DEVICE_ID_MISMATCH", }, }, }) return } auth := shared.AsMap(params["auth"]) if fake.rejectDeviceTokenOnce.Swap(false) && strings.TrimSpace(shared.StringArg(auth, "deviceToken", "")) != "" { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "INVALID_REQUEST", "message": "unauthorized: device token mismatch (rotate/reissue device token)", "details": map[string]any{ "code": "AUTH_DEVICE_TOKEN_MISMATCH", }, }, }) return } if got, want := shared.StringArg(auth, "token", ""), bridgeSharedAuthToken(); got != want { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "INVALID_REQUEST", "message": "unauthorized: gateway token mismatch", }, }) return } fake.lastConnectClient.Store(shared.AsMap(params["client"])) _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "server": map[string]any{"host": "127.0.0.1"}, "snapshot": map[string]any{ "sessionDefaults": map[string]any{"mainSessionKey": "main"}, }, "auth": map[string]any{ "role": "operator", "scopes": []string{"operator.read", "operator.write"}, "deviceToken": "device-token-1", }, }, }) case "chat.send": fake.chatSendCount.Add(1) if fake.alwaysCloseChatSend.Load() || fake.closeNextChatSend.Swap(false) { _ = conn.Close() return } params := shared.AsMap(frame["params"]) fake.lastChatSendParams.Store(params) if strings.TrimSpace(shared.StringArg(params, "message", "")) == "fail" { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "OPENCLAW_CHAT_FAILED", "message": "openclaw chat failed", }, }) continue } runID := strings.TrimSpace(shared.StringArg(params, "idempotencyKey", "fake-run")) if strings.TrimSpace(fake.alternateRunID) != "" { runID = strings.TrimSpace(fake.alternateRunID) } sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", "")) if strings.TrimSpace(fake.alternateSessionKey) != "" { sessionKey = strings.TrimSpace(fake.alternateSessionKey) } message := strings.TrimSpace(shared.StringArg(params, "message", "")) fake.recordRunMessage(runID, message) _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "runId": runID, "sessionKey": sessionKey, "status": "started", }, }) case "xworkmate.session.prepare": fake.artifactPrepareCount.Add(1) params := shared.AsMap(frame["params"]) fake.lastArtifactPrepareParams.Store(params) if fake.unsupportedSessionPrepare.Load() { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "INVALID_REQUEST", "message": "unknown method: xworkmate.session.prepare", }, }) continue } runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run")) sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "main")) artifactScope := "tasks/" + sessionKey + "/" + runID workspaceRoot := "/remote/openclaw/workspace" if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" { workspaceRoot = strings.TrimSpace(fake.artifactWorkspaceRoot) } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "runId": runID, "sessionKey": sessionKey, "openclawSessionKey": sessionKey, "appThreadKey": strings.TrimSpace(shared.StringArg(params, "appThreadKey", "")), "mapping": map[string]any{"schemaVersion": 1, "appThreadKey": strings.TrimSpace(shared.StringArg(params, "appThreadKey", "")), "openclawSessionKey": sessionKey, "expectedArtifactDirs": shared.ListArg(params, "expectedArtifactDirs")}, "expectedArtifactDirs": shared.ListArg(params, "expectedArtifactDirs"), "remoteWorkingDirectory": workspaceRoot, "remoteWorkspaceRefKind": "remotePath", "artifactScope": artifactScope, "scopeKind": "task", "artifactDirectory": filepath.Join(workspaceRoot, filepath.FromSlash(artifactScope)), "relativeArtifactDirectory": artifactScope, "warnings": []any{}, }, }) case "agent.wait": fake.agentWaitCount.Add(1) if delayMs := fake.agentWaitDelayMs.Load(); delayMs > 0 { time.Sleep(time.Duration(delayMs) * time.Millisecond) } params := shared.AsMap(frame["params"]) fake.lastAgentWaitParams.Store(params) runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run")) switch fake.runMessage(runID) { case "wait-error": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "OPENCLAW_WAIT_FAILED", "message": "openclaw wait failed", }, }) continue case "wait-timeout": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "TIMEOUT", "message": "openclaw wait timeout", }, }) continue case "wait-running": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "runId": runID, "status": "running", "terminal": false, }, }) continue case "completed-empty": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "runId": runID, "status": "completed", }, }) continue } message := "gateway pong" if strings.Contains(fake.runMessage(runID), "hallucinate-files") { message = "文件已就绪,点击直接下载👇 三个格式一键收取:" } if strings.Contains(fake.runMessage(runID), "declare output artifact path") { message = "I've saved the requested file at `/remote/openclaw/workspace/tasks/draft_1780110089528457-2/" + runID + "/AI_Agent_News_June_2_2026.md`" } 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{ "type": "event", "event": "health", "seq": 1, "payload": map[string]any{ "status": "ok", "blob": strings.Repeat("x", int(payloadBytes)), }, }) } if fake.emitAgentDelta.Load() { _ = conn.WriteJSON(map[string]any{ "type": "event", "event": "agent", "seq": 2, "payload": map[string]any{ "runId": runID, "sessionKey": "main", "stream": "assistant", "data": map[string]any{"text": "streamed delta"}, "largeIgnored": strings.Repeat("y", 1024), }, }) } if emitChatEvent { _ = conn.WriteJSON(map[string]any{ "type": "event", "event": "chat", "seq": 3, "payload": map[string]any{ "runId": runID, "state": "final", "message": map[string]any{ "role": "assistant", "content": message, }, }, }) } if strings.Contains(fake.runMessage(runID), "event artifact") { _ = conn.WriteJSON(map[string]any{ "type": "event", "event": "chat", "seq": 2, "payload": map[string]any{ "runId": runID, "state": "final", "remoteWorkingDirectory": "/remote/openclaw/events", "remoteWorkspaceRefKind": "remotePath", "artifacts": []any{ map[string]any{ "relativePath": "events/live.txt", "contentType": "text/plain", "encoding": "base64", "content": "bGl2ZSBldmVudA==", }, }, }, }) } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "runId": runID, "status": "ok", }, }) case "xworkmate.tasks.get": params := shared.AsMap(frame["params"]) runID := strings.TrimSpace(shared.StringArg(params, "runId", "")) sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")) appThreadKey := strings.TrimSpace(shared.StringArg(params, "appThreadKey", "")) if runID == "" || sessionKey == "" { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "ok": false, "code": "invalid_lookup", "status": "not_found", "message": "openclawSessionKey and runId required", }, }) continue } status := "completed" taskStatus := "succeeded" success := true message := "gateway pong" code := "" errorMessage := "" runMessage := fake.runMessage(runID) lowerRunMessage := strings.ToLower(runMessage) switch { case strings.Contains(runMessage, "wait-error"): status = "failed" taskStatus = "failed" success = false message = "openclaw wait failed" code = "OPENCLAW_WAIT_FAILED" errorMessage = "openclaw wait failed" case strings.Contains(runMessage, "wait-timeout"), strings.Contains(runMessage, "wait-running"): status = "running" taskStatus = "running" message = "" case strings.Contains(runMessage, "completed-empty"): status = "failed" taskStatus = "failed" success = false message = openClawNoDisplayableText code = "OPENCLAW_NO_DISPLAYABLE_OUTPUT" errorMessage = openClawNoDisplayableText case strings.Contains(runMessage, "agent failed before reply"): status = "failed" taskStatus = "failed" success = false message = "Agent failed before reply: No available auth profile for nvidia" code = "OPENCLAW_AGENT_FAILED_BEFORE_REPLY" errorMessage = message case strings.Contains(runMessage, "hallucinate-files"): message = "文件已就绪,点击直接下载👇 三个格式一键收取:" } artifactScope := "tasks/" + sessionKey + "/" + runID artifacts := []any{} hallucinatedFiles := strings.Contains(runMessage, "hallucinate-files") downloadURL := func(relativePath string) string { query := url.Values{} query.Set("sessionKey", sessionKey) query.Set("runId", runID) query.Set("artifactScope", artifactScope) query.Set("relativePath", relativePath) query.Set("sig", "fake-signature") return openClawArtifactDownloadPath + "?" + query.Encode() } if strings.Contains(runMessage, "make artifact") { artifacts = append(artifacts, map[string]any{ "relativePath": "reports/final.md", "label": "final.md", "contentType": "text/markdown", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "downloadUrl": downloadURL("reports/final.md"), }) } if strings.Contains(runMessage, "fallback artifact") { artifacts = append(artifacts, map[string]any{ "relativePath": "ai-news-2026-06.md", "label": "ai-news-2026-06.md", "contentType": "text/markdown", "sizeBytes": 5089, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "artifactRef": "tasks://fallback/ai-news-2026-06.md", }) } if strings.Contains(runMessage, "make pdf artifact") { artifacts = append(artifacts, map[string]any{ "relativePath": "exports/final.pdf", "label": "final.pdf", "contentType": "application/pdf", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "downloadUrl": downloadURL("exports/final.pdf"), }) } if strings.Contains(runMessage, "make partial artifact") { artifacts = append(artifacts, map[string]any{ "relativePath": "chapters/intro.md", "label": "intro.md", "contentType": "text/markdown", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "downloadUrl": downloadURL("chapters/intro.md"), }) } if !hallucinatedFiles && (strings.Contains(runMessage, "7张") || strings.Contains(runMessage, "图片") || strings.Contains(lowerRunMessage, "image")) { artifacts = append(artifacts, 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", "downloadUrl": downloadURL("artifacts/media/browser/series-01.png"), }) } if !hallucinatedFiles && !strings.Contains(runMessage, "make pdf artifact") && strings.Contains(lowerRunMessage, "pdf") { artifacts = append(artifacts, map[string]any{ "relativePath": "artifacts/tmp-openclaw/final.pdf", "label": "final.pdf", "contentType": "application/pdf", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "downloadUrl": downloadURL("artifacts/tmp-openclaw/final.pdf"), }) } if !hallucinatedFiles && (strings.Contains(runMessage, "视频") || strings.Contains(lowerRunMessage, "video")) { artifacts = append(artifacts, map[string]any{ "relativePath": "artifacts/tmp-openclaw/final.mp4", "label": "final.mp4", "contentType": "video/mp4", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "downloadUrl": downloadURL("artifacts/tmp-openclaw/final.mp4"), }) } if strings.Contains(runMessage, "event artifact") { artifacts = append(artifacts, map[string]any{ "relativePath": "events/live.txt", "label": "live.txt", "contentType": "text/plain", "sizeBytes": 12, "sha256": "fake-sha256", "downloadUrl": downloadURL("events/live.txt"), }) } if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" { artifacts = appendArtifactList(artifacts, fake.exportFilesystemArtifacts(artifactScope)) } remoteWorkingDirectory := "/remote/openclaw/workspace" if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" { remoteWorkingDirectory = strings.TrimSpace(fake.artifactWorkspaceRoot) } if strings.Contains(runMessage, "event artifact") { remoteWorkingDirectory = "/remote/openclaw/events" } payload := map[string]any{ "success": success, "status": status, "taskStatus": taskStatus, "mode": "gateway-chat", "resolvedGatewayProviderId": "openclaw", "runId": runID, "taskId": runID, "appThreadKey": appThreadKey, "openclawSessionKey": sessionKey, "message": message, "output": message, "summary": message, "remoteWorkingDirectory": remoteWorkingDirectory, "remoteWorkspaceRefKind": "remotePath", "task": map[string]any{ "taskId": runID, "runId": runID, "status": taskStatus, }, "warnings": []any{}, } if code != "" { payload["code"] = code } if errorMessage != "" { payload["error"] = errorMessage } if len(artifacts) > 0 { payload["artifacts"] = artifacts } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": payload, }) 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, "openclawSessionKey", "")) 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" { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "UNKNOWN_METHOD", "message": "unknown method: xworkmate.artifacts.export", }, }) continue } params := shared.AsMap(frame["params"]) fake.lastArtifactExportParams.Store(params) runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run")) sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")) artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", "")) payload := map[string]any{ "runId": runID, "sessionKey": sessionKey, "remoteWorkingDirectory": "/remote/openclaw/workspace", "remoteWorkspaceRefKind": "remotePath", "scopeKind": "workspace", "artifacts": []any{}, "warnings": []any{}, } if artifactScope != "" { payload["artifactScope"] = artifactScope payload["scopeKind"] = "task" } filesystemArtifacts := []any{} if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" && artifactScope != "" { payload["remoteWorkingDirectory"] = strings.TrimSpace(fake.artifactWorkspaceRoot) if sessionKey != "" { filesystemArtifacts = fake.exportFilesystemArtifacts(artifactScope) } } if strings.Contains(fake.runMessage(runID), "make artifact") { payload["artifacts"] = []any{ map[string]any{ "relativePath": "reports/final.md", "label": "final.md", "contentType": "text/markdown", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", "encoding": "base64", "content": "ZmluYWwgcmVwb3J0", }, } } if strings.Contains(fake.runMessage(runID), "make pdf artifact") { payload["artifacts"] = []any{ map[string]any{ "relativePath": "exports/final.pdf", "label": "final.pdf", "contentType": "application/pdf", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", }, } } if strings.Contains(fake.runMessage(runID), "make partial artifact") { payload["artifacts"] = []any{ map[string]any{ "relativePath": "chapters/intro.md", "label": "intro.md", "contentType": "text/markdown", "sizeBytes": 12, "sha256": "fake-sha256", "artifactScope": artifactScope, "scopeKind": "task", }, } } 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) } _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": payload, }) case "xworkmate.artifacts.read": fake.artifactReadCount.Add(1) params := shared.AsMap(frame["params"]) fake.lastArtifactReadParams.Store(params) relativePath := strings.TrimSpace(shared.StringArg(params, "relativePath", "")) artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", "")) sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")) if sessionKey == "" { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "OPENCLAW_ARTIFACT_READ_FAILED", "message": "openclawSessionKey required", }, }) continue } if fake.consumeArtifactReadFailure() { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "OPENCLAW_ARTIFACT_READ_FAILED", "message": "openclaw artifact read failed", }, }) continue } if relativePath != "reports/final.md" { _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "ARTIFACT_NOT_FOUND", "message": "artifact not found", }, }) continue } content := []byte("final report") sum := sha256.Sum256(content) _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "runId": strings.TrimSpace(shared.StringArg(params, "runId", "")), "sessionKey": sessionKey, "remoteWorkingDirectory": "/remote/openclaw/workspace", "remoteWorkspaceRefKind": "remotePath", "artifactScope": artifactScope, "scopeKind": "task", "artifacts": []any{ map[string]any{ "relativePath": "reports/final.md", "label": "final.md", "contentType": "text/markdown", "sizeBytes": len(content), "sha256": hex.EncodeToString(sum[:]), "artifactScope": artifactScope, "scopeKind": "task", "encoding": "base64", "content": base64.StdEncoding.EncodeToString(content), }, }, }, }) case "skills.status": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{ "workspaceDir": "/remote/openclaw/workspace", "managedSkillsDir": "/remote/openclaw/skills", "skills": []any{ map[string]any{ "name": "it-infra-continuous-png", "description": "Generate infrastructure PNGs.", "source": "openclaw-workspace", "skillKey": "it-infra-continuous-png", "eligible": true, "disabled": false, "missing": map[string]any{ "bins": []any{}, "env": []any{}, "config": []any{}, }, }, map[string]any{ "name": "legacy-disabled", "description": "Disabled test skill.", "source": "agents-skills-personal", "skillKey": "legacy-disabled", "eligible": false, "disabled": true, "blockedByAgentFilter": true, "missing": map[string]any{ "bins": []any{"legacy-cli"}, "env": []any{}, "config": []any{}, }, }, }, }, }) case "chat.run": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "UNKNOWN_METHOD", "message": "unknown method: chat.run", }, }) case "session.start": _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": false, "error": map[string]any{ "code": "UNKNOWN_METHOD", "message": "unknown method: session.start", }, }) default: _ = conn.WriteJSON(map[string]any{ "type": "res", "id": id, "ok": true, "payload": map[string]any{}, }) } } }) fake.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second} go func() { _ = fake.server.Serve(listener) }() return fake } func (f *acpFakeOpenClawGateway) URL() string { return "ws://" + f.listener.Addr().String() + "/" } func (f *acpFakeOpenClawGateway) recordMethod(method string) { f.mu.Lock() defer f.mu.Unlock() f.methods = append(f.methods, method) } func (f *acpFakeOpenClawGateway) Methods() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.methods...) } func (f *acpFakeOpenClawGateway) recordRunMessage(runID, message string) { f.mu.Lock() defer f.mu.Unlock() f.runMessages[runID] = message } func (f *acpFakeOpenClawGateway) runMessage(runID string) string { f.mu.Lock() defer f.mu.Unlock() return f.runMessages[runID] } func (f *acpFakeOpenClawGateway) ConnectCount() int { return int(f.connectCount.Load()) } func (f *acpFakeOpenClawGateway) ChatSendCount() int { return int(f.chatSendCount.Load()) } func (f *acpFakeOpenClawGateway) LastChatSendParams() map[string]any { params, _ := f.lastChatSendParams.Load().(map[string]any) return params } func (f *acpFakeOpenClawGateway) AgentWaitCount() int { return int(f.agentWaitCount.Load()) } func (f *acpFakeOpenClawGateway) LastAgentWaitParams() map[string]any { params, _ := f.lastAgentWaitParams.Load().(map[string]any) return params } func (f *acpFakeOpenClawGateway) ArtifactPrepareCount() int { return int(f.artifactPrepareCount.Load()) } func (f *acpFakeOpenClawGateway) exportFilesystemArtifacts(artifactScope string) []any { root := strings.TrimSpace(f.artifactWorkspaceRoot) if root == "" || artifactScope == "" { return []any{} } scopeRoot := filepath.Join(root, filepath.FromSlash(artifactScope)) entries := make([]any, 0) _ = filepath.WalkDir(scopeRoot, func(path string, entry os.DirEntry, err error) error { if err != nil || entry == nil || entry.IsDir() { return nil } relativePath, relErr := filepath.Rel(scopeRoot, path) if relErr != nil || strings.HasPrefix(relativePath, "..") { return nil } info, statErr := entry.Info() if statErr != nil { return nil } entries = append(entries, map[string]any{ "relativePath": filepath.ToSlash(relativePath), "label": filepath.Base(path), "contentType": artifactContentType(filepath.ToSlash(relativePath)), "sizeBytes": info.Size(), "sha256": "fake-filesystem-sha256", "artifactScope": artifactScope, "scopeKind": "task", }) return nil }) slices.SortFunc(entries, func(left any, right any) int { return strings.Compare( shared.StringArg(shared.AsMap(left), "relativePath", ""), shared.StringArg(shared.AsMap(right), "relativePath", ""), ) }) return entries } func (f *acpFakeOpenClawGateway) LastArtifactPrepareParams() map[string]any { params, _ := f.lastArtifactPrepareParams.Load().(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()) } func (f *acpFakeOpenClawGateway) ArtifactReadCount() int { return int(f.artifactReadCount.Load()) } func (f *acpFakeOpenClawGateway) LastArtifactReadParams() map[string]any { value := f.lastArtifactReadParams.Load() if value == nil { return nil } return shared.AsMap(value) } func (f *acpFakeOpenClawGateway) FailNextArtifactReads(count int) { f.artifactReadFailures.Store(int32(count)) } func (f *acpFakeOpenClawGateway) consumeArtifactReadFailure() bool { for { remaining := f.artifactReadFailures.Load() if remaining <= 0 { return false } if f.artifactReadFailures.CompareAndSwap(remaining, remaining-1) { return true } } } func (f *acpFakeOpenClawGateway) LastArtifactExportParams() map[string]any { value := f.lastArtifactExportParams.Load() if value == nil { return nil } return shared.AsMap(value) } func (f *acpFakeOpenClawGateway) LastConnectClient() map[string]any { value := f.lastConnectClient.Load() if value == nil { return nil } return value.(map[string]any) } func (f *acpFakeOpenClawGateway) Close() { _ = f.server.Close() } func sameMethods(got []string, want []string) bool { if len(got) != len(want) { return false } for index := range got { if got[index] != want[index] { return false } } return true } func sameAnyStringSlice(got []any, want []string) bool { if len(got) != len(want) { return false } for index := range got { if fmt.Sprint(got[index]) != want[index] { return false } } return true } func waitForCondition(t *testing.T, condition func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if condition() { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("timed out waiting for condition") } func TestExecuteSessionTaskAutoRoutingUsesBridgeProductionProviderOrder(t *testing.T) { workspaceDir := filepath.Join(t.TempDir(), "workspace") if err := os.MkdirAll(workspaceDir, 0o755); err != nil { t.Fatalf("create workspace: %v", err) } server := NewServer() geminiProvider := newExternalSingleAgentProvider(t, "gemini", "gemini-output") defer geminiProvider.Close() codexProvider := newExternalSingleAgentProvider(t, "codex", "codex-output") defer codexProvider.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "gemini", Label: "Gemini", Endpoint: geminiProvider.URL, Enabled: true, }) setTestBridgeProvider(server, syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: codexProvider.URL, Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-auto-order", "threadId": "thread-auto-order", "taskPrompt": "create a powerpoint deck for launch", "workingDirectory": workspaceDir, "routing": map[string]any{ "routingMode": "auto", "availableSkills": []any{ map[string]any{ "id": "pptx", "label": "PPTX", "description": "slides", "installed": true, }, }, }, }, }, }) if rpcErr != nil { t.Fatalf("expected success, got rpc error: %v", rpcErr) } if got := response["resolvedProviderId"]; got != "codex" { t.Fatalf("expected resolved provider codex from built-in bridge order, got %#v", response) } } func TestExecuteSessionTaskKeepsRemoteWorkspaceHintOutOfLocalCWD(t *testing.T) { workspaceDir := filepath.Join(t.TempDir(), "workspace") if err := os.MkdirAll(workspaceDir, 0o755); err != nil { t.Fatalf("create workspace: %v", err) } server := NewServer() providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/acp/rpc" { http.NotFound(w, r) return } defer func() { _ = r.Body.Close() }() var request map[string]any if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatalf("decode request: %v", err) } method := strings.TrimSpace(shared.StringArg(request, "method", "")) result := map[string]any{ "success": true, "output": "hello", "summary": "hello", "message": "hello", "remoteWorkingDirectory": "/owners/local/user/demo/threads/main", "remoteWorkspaceRefKind": "remotePath", "artifacts": []map[string]any{ { "relativePath": "notes/hello.txt", "content": "hello artifact", "contentType": "text/plain", }, }, } if method == "thread/start" { result = map[string]any{"id": "codex-thread-1"} } _ = json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": request["id"], "result": result, }) })) defer providerServer.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: providerServer.URL, Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Method: "session.start", Params: map[string]any{ "sessionId": "session-remote-hint", "threadId": "thread-remote-hint", "taskPrompt": "say hello", "workingDirectory": workspaceDir, "remoteWorkingDirectoryHint": "/owners/local/user/demo/threads/main", "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", "explicitProviderId": "codex", }, }, }, }) if rpcErr != nil { t.Fatalf("expected success, got rpc error: %v", rpcErr) } if got := response["remoteWorkingDirectory"]; got != "/owners/local/user/demo/threads/main" { t.Fatalf("expected remote working directory in response, got %#v", response) } if got := response["remoteWorkspaceRefKind"]; got != "remotePath" { t.Fatalf("expected remote workspace kind in response, got %#v", response) } if _, ok := response["artifacts"].([]map[string]any); !ok { if _, ok := response["artifacts"].([]any); !ok { t.Fatalf("expected artifacts payload, got %#v", response["artifacts"]) } } sess := server.sessions["session-remote-hint"] if sess == nil { t.Fatal("expected session state to be retained") } else if sess.control.RequestedWorkingDir != workspaceDir { t.Fatalf("expected local requested cwd %q, got %q", workspaceDir, sess.control.RequestedWorkingDir) } if sess.control.RemoteWorkingDirHint != "/owners/local/user/demo/threads/main" { t.Fatalf("expected remote hint retained, got %#v", sess.control) } if sess.task.Kind != TaskKindSingleAgent || sess.task.State != TaskStateCompleted { t.Fatalf("expected completed single-agent task, got %#v", sess.task) } if sess.artifacts.RemoteWorkingDirectory != "/owners/local/user/demo/threads/main" { t.Fatalf("expected artifact record to keep remote directory, got %#v", sess.artifacts) } } func TestExecuteSessionTaskRequiresRouting(t *testing.T) { server := NewServer() _, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ ID: "request-1", Method: "session.start", Params: map[string]any{ "sessionId": "session-missing-routing", "threadId": "thread-missing-routing", "taskPrompt": "hello", }, }, }) if rpcErr == nil { t.Fatalf("expected routing-required error") } else if rpcErr.Message != "ROUTING_REQUIRED" { t.Fatalf("expected ROUTING_REQUIRED, got %#v", rpcErr) } } func TestExecuteSessionMessageMissingProviderStateReturnsContinuationUnavailable(t *testing.T) { server := NewServer() providerServer := newExternalSingleAgentProvider(t, "codex", "done") defer providerServer.Close() setTestBridgeProvider(server, syncedProvider{ ProviderID: "codex", Label: "Codex", Endpoint: providerServer.URL, Enabled: true, }) response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ ID: "request-continue", Method: "session.message", Params: map[string]any{ "sessionId": "session-without-provider-state", "threadId": "thread-without-provider-state", "taskPrompt": "continue", "workingDirectory": t.TempDir(), "routing": map[string]any{ "routingMode": "explicit", "explicitExecutionTarget": "singleAgent", "explicitProviderId": "codex", }, }, }, }) if rpcErr == nil { t.Fatalf("expected continuation unavailable error, got response %#v", response) } else if rpcErr.Code != -32002 || !strings.Contains(rpcErr.Message, "SESSION_CONTINUATION_UNAVAILABLE") { t.Fatalf("expected structured continuation error, got %#v", rpcErr) } data := shared.AsMap(rpcErr.Data) if got := shared.StringArg(data, "code", ""); got != "SESSION_CONTINUATION_UNAVAILABLE" { t.Fatalf("expected continuation detail code, got %#v", rpcErr.Data) } if got := shared.StringArg(data, "sessionId", ""); got != "session-without-provider-state" { t.Fatalf("expected session id in error data, got %#v", rpcErr.Data) } if got := shared.StringArg(data, "threadId", ""); got != "thread-without-provider-state" { t.Fatalf("expected thread id in error data, got %#v", rpcErr.Data) } if got := shared.StringArg(data, "providerId", ""); got != "codex" { t.Fatalf("expected provider id in error data, got %#v", rpcErr.Data) } } func TestExecuteSessionTaskComplexRequestNoLongerPromotesToMultiAgent(t *testing.T) { workspaceDir := filepath.Join(t.TempDir(), "workspace") if err := os.MkdirAll(workspaceDir, 0o755); err != nil { t.Fatalf("create workspace: %v", err) } server := NewServer() response, rpcErr := server.executeSessionTask(task{ req: shared.RPCRequest{ Params: map[string]any{ "sessionId": "session-complex", "threadId": "thread-complex", "taskPrompt": "collect latest news and summarize it into a report for review", "workingDirectory": workspaceDir, "routing": map[string]any{ "routingMode": "auto", }, }, }, }) if rpcErr == nil { t.Fatalf("expected gateway-not-connected error, got response %#v", response) } else if strings.Contains(rpcErr.Message, "multi-agent") { t.Fatalf("expected no multi-agent path, got rpc error: %v", rpcErr) } } func TestHandleRoutingResolveAllowsSkillInstallRetry(t *testing.T) { tempDir := t.TempDir() finder := filepath.Join(tempDir, "find-skills.sh") installer := filepath.Join(tempDir, "install-skills.sh") if err := os.WriteFile( finder, []byte("#!/bin/sh\nprintf '%s' '{\"candidates\":[{\"id\":\"video-translator\",\"label\":\"video-translator\",\"description\":\"translate video\",\"installed\":false}]}'\n"), 0o755, ); err != nil { t.Fatalf("write finder: %v", err) } if err := os.WriteFile( installer, []byte("#!/bin/sh\nprintf '%s' '{\"candidates\":[{\"id\":\"video-translator\",\"label\":\"video-translator\",\"description\":\"translate video\",\"installed\":true}]}'\n"), 0o755, ); err != nil { t.Fatalf("write installer: %v", err) } t.Setenv("ACP_FIND_SKILLS_BIN", finder) t.Setenv("ACP_INSTALL_SKILL_BIN", installer) result := handleRoutingResolve(map[string]any{ "taskPrompt": "translate and dub this video with subtitles", "workingDirectory": "/tmp/workspace", "routing": map[string]any{ "routingMode": "auto", "allowSkillInstall": true, "availableSkills": []any{ map[string]any{ "id": "docx", "label": "docx", "description": "docs", "installed": true, }, }, }, }) if got := result["skillResolutionSource"]; got != "find_skills" { t.Fatalf("expected find_skills source, got %#v", got) } if got := result["needsSkillInstall"]; got != true { t.Fatalf("expected first pass to request install approval, got %#v", got) } requestID, _ := result["skillInstallRequestId"].(string) if strings.TrimSpace(requestID) == "" { t.Fatalf("expected install request id, got %#v", result) } retried := handleRoutingResolve(map[string]any{ "taskPrompt": "translate and dub this video with subtitles", "workingDirectory": "/tmp/workspace", "routing": map[string]any{ "routingMode": "auto", "allowSkillInstall": true, "installApproval": map[string]any{ "requestId": requestID, "approvedSkillKeys": []any{"video-translator"}, }, "availableSkills": []any{ map[string]any{ "id": "docx", "label": "docx", "description": "docs", "installed": true, }, }, }, }) if got := retried["needsSkillInstall"]; got != false { t.Fatalf("expected install retry to clear needsSkillInstall, got %#v", got) } resolvedSkills, _ := retried["resolvedSkills"].([]string) if len(resolvedSkills) != 1 || resolvedSkills[0] != "video-translator" { t.Fatalf("expected installed skill to resolve, got %#v", retried["resolvedSkills"]) } }