xworkmate-bridge/internal/acp/routing_test.go

3340 lines
112 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
}
return s.handleRequest(t.req, t.notify)
}
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)
}
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(),
"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())
}
chatParams := gateway.LastChatSendParams()
for _, key := range []string{
"artifactDirectory",
"artifactScope",
"artifactScopeKind",
"relativeArtifactDirectory",
"remoteWorkingDirectory",
"remoteWorkspaceRefKind",
"xworkmateArtifacts",
} {
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", ""))
for _, expected := range []string{
"artifactDirectory: /remote/openclaw/workspace/tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", ""),
"artifactScope: tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", ""),
"export XWORKMATE_TASK_ARTIFACT_DIR='/remote/openclaw/workspace/tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
"cd '/remote/openclaw/workspace/tasks/thread-openclaw/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
} {
if !strings.Contains(receipt, expected) {
t.Fatalf("expected chat.send systemProvenanceReceipt to include %q, got %q", expected, receipt)
}
}
if gateway.AgentWaitCount() != 1 {
t.Fatalf("expected one OpenClaw agent.wait request, got %d", gateway.AgentWaitCount())
}
waitParams := gateway.LastAgentWaitParams()
timeoutMs, ok := waitParams["timeoutMs"].(float64)
if !ok {
t.Fatalf("expected numeric OpenClaw agent.wait timeoutMs, got %#v", waitParams)
}
if got := int64(timeoutMs); got != openClawAgentWaitDefaultTimeout.Milliseconds() {
t.Fatalf("expected default OpenClaw agent.wait timeoutMs %d, got %#v", openClawAgentWaitDefaultTimeout.Milliseconds(), waitParams)
}
if got := int64(timeoutMs); got <= 120000 {
t.Fatalf("expected OpenClaw agent.wait timeout to exceed the previous 120s cap, got %#v", waitParams)
}
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one OpenClaw artifact export sync after run, got %d", gateway.ArtifactExportCount())
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, 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 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 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": "silent-turn",
"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() != 1 {
t.Fatalf("expected one artifact export sync even when no displayable text is returned, got %d", gateway.ArtifactExportCount())
}
}
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() != 1 {
t.Fatalf("expected one OpenClaw agent.wait request, got %d", gateway.AgentWaitCount())
}
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one OpenClaw artifact export sync after message run, got %d", gateway.ArtifactExportCount())
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
func TestExecuteSessionTaskMultiAgentModes(t *testing.T) {
cases := []struct {
name string
mode string
maxTurns int
wantSteps int
}{
{name: "sequence", mode: "sequence", wantSteps: 2},
{name: "parallel", mode: "parallel", wantSteps: 2},
{name: "race", mode: "race", wantSteps: 1},
{name: "conversation", mode: "conversation", maxTurns: 2, wantSteps: 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := NewServer()
opencodeProvider := newExternalSingleAgentProvider(t, "opencode", "opencode-output")
defer opencodeProvider.Close()
geminiProvider := newExternalSingleAgentProvider(t, "gemini", "gemini-output")
defer geminiProvider.Close()
setTestBridgeProvider(server, syncedProvider{
ProviderID: "opencode",
Label: "OpenCode",
Endpoint: opencodeProvider.URL,
Enabled: true,
})
setTestBridgeProvider(server, syncedProvider{
ProviderID: "gemini",
Label: "Gemini",
Endpoint: geminiProvider.URL,
Enabled: true,
})
routing := map[string]any{
"orchestrationMode": tc.mode,
"steps": []any{
map[string]any{"providerId": "opencode", "prompt": "first"},
map[string]any{"providerId": "gemini", "prompt": "second sees {{previousOutput}}"},
},
}
if tc.mode == "conversation" {
routing["maxTurns"] = tc.maxTurns
routing["participants"] = []any{"opencode", "gemini"}
}
response, rpcErr := server.executeSessionTask(task{
req: shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "session-multi-" + tc.name,
"threadId": "thread-multi-" + tc.name,
"taskPrompt": "coordinate two agents",
"workingDirectory": t.TempDir(),
"multiAgent": true,
"routing": routing,
},
},
})
if rpcErr != nil {
t.Fatalf("expected multi-agent response, got rpc error: %#v", rpcErr)
}
if !parseBool(response["success"]) {
t.Fatalf("expected successful multi-agent response, got %#v", response)
}
if got := response["resolvedExecutionTarget"]; got != "multi-agent" {
t.Fatalf("expected multi-agent execution target, got %#v", response)
}
if got := response["orchestrationMode"]; got != tc.mode {
t.Fatalf("expected orchestration mode %q, got %#v", tc.mode, response)
}
steps := mustStepMaps(t, response["steps"])
if len(steps) != tc.wantSteps {
t.Fatalf("expected %d step results, got %#v", tc.wantSteps, steps)
}
if output := strings.TrimSpace(shared.StringArg(response, "output", "")); output == "" {
t.Fatalf("expected displayable multi-agent output, got %#v", response)
}
})
}
}
func TestExecuteSessionTaskMultiAgentProviderUnavailableIsResultFailure(t *testing.T) {
server := NewServer()
providerServer := newExternalSingleAgentProvider(t, "opencode", "opencode-output")
defer providerServer.Close()
setTestBridgeProvider(server, syncedProvider{
ProviderID: "opencode",
Label: "OpenCode",
Endpoint: providerServer.URL,
Enabled: true,
})
response, rpcErr := server.executeSessionTask(task{
req: shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "session-multi-missing",
"threadId": "thread-multi-missing",
"taskPrompt": "coordinate agents",
"workingDirectory": t.TempDir(),
"multiAgent": true,
"routing": map[string]any{
"orchestrationMode": "parallel",
"steps": []any{
map[string]any{"providerId": "opencode", "prompt": "first"},
map[string]any{"providerId": "missing", "prompt": "second"},
},
},
},
},
})
if rpcErr != nil {
t.Fatalf("expected normalized failure result, got rpc error: %#v", rpcErr)
}
if parseBool(response["success"]) || response["status"] != "failed" {
t.Fatalf("expected failed multi-agent result, got %#v", response)
}
if !strings.Contains(strings.TrimSpace(shared.StringArg(response, "error", "")), "missing: provider unavailable") {
t.Fatalf("expected provider unavailable error, got %#v", response)
}
}
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)
}
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.artifacts.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)
}
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, got response: %#v", response)
}
if rpcErr.Code != -32002 || !strings.Contains(rpcErr.Message, "openclaw wait failed") {
t.Fatalf("expected surfaced agent.wait failure, got %#v", rpcErr)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, then agent.wait, got %#v", got)
}
snapshot := server.handleSessionGet(map[string]any{
"sessionId": "session-openclaw-wait-fail",
"threadId": "thread-openclaw-wait-fail",
})
if got := snapshot["status"]; got != string(TaskStateFailed) {
t.Fatalf("expected failed session snapshot, got %#v from %#v", got, snapshot)
}
result := shared.AsMap(snapshot["result"])
if got := result["success"]; got != false {
t.Fatalf("expected failed result snapshot, got %#v", result)
}
if got := shared.StringArg(result, "code", ""); got != "OPENCLAW_WAIT_FAILED" {
t.Fatalf("expected OpenClaw wait code in snapshot, got %#v", result)
}
if got := shared.StringArg(result, "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() != 1 {
t.Fatalf("expected one OpenClaw artifact export request, 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 != "thread-openclaw-artifact" {
t.Fatalf("expected thread sessionKey in downloadUrl, got %q", got)
}
if got := parsedDownloadURL.Query().Get("relativePath"); got != "reports/final.md" {
t.Fatalf("expected artifact relativePath in downloadUrl, got %q", got)
}
if artifactScope := parsedDownloadURL.Query().Get("artifactScope"); artifactScope != "tasks/thread-openclaw-artifact/"+response["runId"].(string) {
t.Fatalf("expected prepared artifact scope in downloadUrl, got %q", artifactScope)
}
if parsedDownloadURL.Query().Get("sig") == "" {
t.Fatalf("expected signed downloadUrl, got %q", downloadURL)
}
exportParams := gateway.LastArtifactExportParams()
if got := strings.TrimSpace(shared.StringArg(exportParams, "maxInlineBytes", "")); got != "0" {
t.Fatalf("expected OpenClaw artifact export to disable inline content, got %#v", exportParams)
}
if got := shared.BoolArg(shared.StringArg(exportParams, "includeContent", ""), true); got {
t.Fatalf("expected OpenClaw artifact export to omit content, got %#v", exportParams)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
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"])
}
exportParams := gateway.LastArtifactExportParams()
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got == "" {
t.Fatalf("expected bridge to export the prepared task artifact scope, got %#v", exportParams)
}
if _, ok := exportParams["latestIfEmpty"]; ok {
t.Fatalf("expected no latestIfEmpty fallback export param, got %#v", exportParams)
}
if got := strings.TrimSpace(shared.StringArg(exportParams, "maxInlineBytes", "")); got != "0" {
t.Fatalf("expected latest workspace export to disable inline content, got %#v", exportParams)
}
if got := shared.BoolArg(shared.StringArg(exportParams, "includeContent", ""), true); got {
t.Fatalf("expected latest workspace export to omit content, got %#v", exportParams)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
}
}
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())
}
exportParams := gateway.LastArtifactExportParams()
if got := strings.TrimSpace(shared.StringArg(exportParams, "runId", "")); got != "openclaw-run-actual" {
t.Fatalf("expected artifact export to use actual OpenClaw runId, got %#v", exportParams)
}
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got != "tasks/thread-openclaw-actual-run/openclaw-run-actual" {
t.Fatalf("expected artifact export to use actual OpenClaw run scope, got %#v", exportParams)
}
artifacts, ok := response["artifacts"].([]map[string]any)
if !ok || 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/thread-openclaw-actual-run/openclaw-run-actual" {
t.Fatalf("expected download URL to use actual OpenClaw artifact scope, got %q", got)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "xworkmate.artifacts.prepare", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected bridge to reprepare actual OpenClaw run before wait/export, got %#v", got)
}
}
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() != 1 {
t.Fatalf("expected one post-run artifact export sync, got %d", gateway.ArtifactExportCount())
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, 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)
}
exportParams := gateway.LastArtifactExportParams()
if got := strings.TrimSpace(shared.StringArg(exportParams, "artifactScope", "")); got == "" {
t.Fatalf("expected bridge to export the prepared task artifact scope, got %#v", exportParams)
}
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export"}) {
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, 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")
}
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())
}
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)
}
attachments := shared.ListArg(chatParams, "attachments")
if len(attachments) != 1 {
t.Fatalf("expected one materialized attachment, got %#v", attachments)
}
attachment := shared.AsMap(attachments[0])
if got := shared.StringArg(attachment, "name", ""); got != "prompt.png" {
t.Fatalf("expected materialized attachment name, got %#v", attachment)
}
path := shared.StringArg(attachment, "path", "")
if path == "" {
t.Fatalf("expected materialized attachment path, got %#v", attachment)
}
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 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)
}
attachments := shared.ListArg(chatParams, "attachments")
if len(attachments) != 1 {
t.Fatalf("expected one materialized attachment, got %#v", attachments)
}
path := shared.StringArg(shared.AsMap(attachments[0]), "path", "")
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)
}
path := shared.StringArg(shared.AsMap(shared.ListArg(chatParams, "attachments")[0]), "path", "")
if !strings.HasPrefix(path, writableWorkspace) {
t.Fatalf("expected materialized attachment under writable workspace %q, got %q", writableWorkspace, path)
}
}
func TestExecuteSessionTaskGatewayRejectsOversizedInlineAttachmentBeforeChatSend(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
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)
}
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 TestExecuteSessionTaskGatewayAlwaysSyncsGatewayArtifactsAfterRun(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(),
"routing": map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "gateway",
"preferredGatewayProviderId": "openclaw",
},
},
},
})
if rpcErr != nil {
t.Fatalf("expected gateway text response despite artifact export failure, got rpc error: %#v", rpcErr)
}
if got := response["output"]; got != "gateway pong" {
t.Fatalf("expected gateway pong output, got %#v", response)
}
if gateway.ArtifactExportCount() != 1 {
t.Fatalf("expected one OpenClaw artifact export sync, got %d", gateway.ArtifactExportCount())
}
if warnings := shared.ListArg(response, "artifactWarnings"); len(warnings) != 0 {
t.Fatalf("expected no artifact warnings when gateway export succeeds empty, got %#v", warnings)
}
}
func TestExecuteSessionTaskGatewayDoesNotFailMissingFilesFromPromptHeuristic(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-missing-files",
"threadId": "thread-openclaw-missing-files",
"taskPrompt": "输出 PPT PDF docx 文件 hallucinate-files",
"workingDirectory": t.TempDir(),
"routing": map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "gateway",
"preferredGatewayProviderId": "openclaw",
},
},
},
})
if rpcErr != nil {
t.Fatalf("expected bridge response, got rpc error: %#v", rpcErr)
}
if success, _ := response["success"].(bool); !success {
t.Fatalf("expected bridge to preserve gateway success without prompt heuristic failure, got %#v", response)
}
output := strings.TrimSpace(shared.StringArg(response, "output", ""))
if !strings.Contains(output, "点击直接下载") || !strings.Contains(output, "文件已就绪") {
t.Fatalf("expected bridge to preserve gateway output, got %q", output)
}
if _, ok := response["artifacts"]; ok {
t.Fatalf("expected no artifacts when export returned none, got %#v", response["artifacts"])
}
if warnings := shared.ListArg(response, "artifactWarnings"); len(warnings) != 0 {
t.Fatalf("expected no bridge artifact-missing warning, got %#v", warnings)
}
}
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")
}
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
artifactCount atomic.Int32
artifactReadCount atomic.Int32
artifactReadFailures atomic.Int32
closeNextChatSend atomic.Bool
alwaysCloseChatSend atomic.Bool
agentWaitDelayMs atomic.Int64
largeGatewayPayloadBytes atomic.Int64
emitAgentDelta atomic.Bool
lastConnectClient atomic.Value
lastChatSendParams atomic.Value
lastArtifactPrepareParams atomic.Value
lastArtifactExportParams atomic.Value
lastAgentWaitParams atomic.Value
mu sync.Mutex
methods []string
runMessages map[string]string
artifactMode string
alternateRunID string
}
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
}
if got, want := shared.StringArg(shared.AsMap(params["auth"]), "token", ""), os.Getenv("BRIDGE_AUTH_TOKEN"); 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)
}
fake.recordRunMessage(runID, strings.TrimSpace(shared.StringArg(params, "message", "")))
_ = conn.WriteJSON(map[string]any{
"type": "res",
"id": id,
"ok": true,
"payload": map[string]any{
"runId": runID,
"status": "started",
},
})
case "xworkmate.artifacts.prepare":
fake.artifactPrepareCount.Add(1)
params := shared.AsMap(frame["params"])
fake.lastArtifactPrepareParams.Store(params)
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", "main"))
artifactScope := "tasks/" + sessionKey + "/" + runID
_ = 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",
"artifactDirectory": "/remote/openclaw/workspace/" + 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
}
message := "gateway pong"
if strings.Contains(fake.runMessage(runID), "hallucinate-files") {
message = "文件已就绪,点击直接下载👇 三个格式一键收取:"
}
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.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"))
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
payload := map[string]any{
"runId": runID,
"sessionKey": strings.TrimSpace(shared.StringArg(params, "sessionKey", "")),
"remoteWorkingDirectory": "/remote/openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"scopeKind": "workspace",
"artifacts": []any{},
"warnings": []any{},
}
if artifactScope != "" {
payload["artifactScope"] = artifactScope
payload["scopeKind"] = "task"
}
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",
},
}
}
_ = 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"])
relativePath := strings.TrimSpace(shared.StringArg(params, "relativePath", ""))
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
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": strings.TrimSpace(shared.StringArg(params, "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) LastArtifactPrepareParams() map[string]any {
params, _ := f.lastArtifactPrepareParams.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) 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 mustStepMaps(t *testing.T, value any) []map[string]any {
t.Helper()
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
steps := make([]map[string]any, 0, len(typed))
for _, item := range typed {
steps = append(steps, shared.AsMap(item))
}
return steps
default:
t.Fatalf("expected step map list, got %#v", value)
return nil
}
}
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")
}
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")
}
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)
}
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)
}
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"])
}
}