- Introduce ThreadSessionMapper to derive stable OpenClaw session keys from threadId/sessionId, avoiding leaked draft session identifiers - Replace the artifact scope cascading fallback (output-token heuristics, draft variant retries) with a single collect-and-snapshot call followed by export, per anti-fallback rules - Enforce artifact contract by failing runs that report success but miss required final artifact extensions - Update orchestrator and tests to the new methods sequence (collect-and-snapshot before export) - Relax AGENTS.md rule to allow updating tests when the protocol contract itself changes
3933 lines
133 KiB
Go
3933 lines
133 KiB
Go
package acp
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"xworkmate-bridge/internal/shared"
|
||
)
|
||
|
||
func handleRoutingResolve(params map[string]any) map[string]any {
|
||
server := NewServer()
|
||
res, _ := server.routingEngine.Resolve(context.Background(), params)
|
||
|
||
// Convert to map for tests
|
||
m := map[string]any{
|
||
"resolvedExecutionTarget": res.TargetID,
|
||
"resolvedProviderId": res.ProviderID,
|
||
"resolvedGatewayProviderId": res.GatewayProviderID,
|
||
"resolvedModel": res.Model,
|
||
"resolvedSkills": res.Skills,
|
||
"status": res.Status,
|
||
"unavailable": res.Status == "unavailable",
|
||
"unavailableCode": res.UnavailableCode,
|
||
"unavailableMessage": res.UnavailableMsg,
|
||
"skillResolutionSource": res.SkillResolutionSource,
|
||
"needsSkillInstall": res.NeedsSkillInstall,
|
||
"skillInstallRequestId": res.SkillInstallRequestID,
|
||
}
|
||
return m
|
||
}
|
||
|
||
type task struct {
|
||
req shared.RPCRequest
|
||
notify func(map[string]any)
|
||
}
|
||
|
||
func (s *Server) executeSessionTask(t task) (map[string]any, *shared.RPCError) {
|
||
if t.req.Method == "" {
|
||
t.req.Method = "session.start"
|
||
}
|
||
response, rpcErr := s.handleRequest(t.req, t.notify)
|
||
if rpcErr != nil || strings.TrimSpace(shared.StringArg(response, "status", "")) != string(TaskStateRunning) {
|
||
return response, rpcErr
|
||
}
|
||
return s.handleRequest(shared.RPCRequest{
|
||
Method: "xworkmate.tasks.get",
|
||
Params: map[string]any{
|
||
"sessionId": shared.StringArg(response, "sessionId", ""),
|
||
"threadId": shared.StringArg(response, "threadId", ""),
|
||
"turnId": shared.StringArg(response, "turnId", ""),
|
||
"runId": shared.StringArg(response, "runId", ""),
|
||
"sessionKey": shared.StringArg(response, "sessionKey", ""),
|
||
"artifactScope": shared.StringArg(response, "artifactScope", ""),
|
||
"artifactDirectory": shared.StringArg(response, "artifactDirectory", ""),
|
||
"gatewayProviderId": shared.StringArg(response, "resolvedGatewayProviderId", ""),
|
||
"runtimeBudgetMinutes": shared.StringArg(response, "runtimeBudgetMinutes", ""),
|
||
"taskLoadClass": shared.StringArg(response, "taskLoadClass", ""),
|
||
"expectedArtifactExtensions": shared.ListArg(response, "expectedArtifactExtensions"),
|
||
"requiredArtifactExtensions": shared.ListArg(response, "requiredArtifactExtensions"),
|
||
},
|
||
}, t.notify)
|
||
}
|
||
|
||
func 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", ""))
|
||
openClawSessionKey := shared.StringArg(chatParams, "sessionKey", "")
|
||
if openClawSessionKey == "" || openClawSessionKey == "thread-openclaw" {
|
||
t.Fatalf("expected mapped OpenClaw sessionKey, got %#v", chatParams)
|
||
}
|
||
for _, expected := range []string{
|
||
"artifactDirectory: /remote/openclaw/workspace/tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", ""),
|
||
"artifactScope: tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", ""),
|
||
"export XWORKMATE_TASK_ARTIFACT_DIR='/remote/openclaw/workspace/tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
|
||
"cd '/remote/openclaw/workspace/tasks/" + openClawSessionKey + "/" + shared.StringArg(chatParams, "idempotencyKey", "") + "'",
|
||
} {
|
||
if !strings.Contains(receipt, expected) {
|
||
t.Fatalf("expected chat.send systemProvenanceReceipt to include %q, got %q", expected, receipt)
|
||
}
|
||
}
|
||
if gateway.AgentWaitCount() != 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 != openClawTaskProbeTimeoutMs {
|
||
t.Fatalf("expected OpenClaw probe timeoutMs %d, got %#v", openClawTaskProbeTimeoutMs, 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.collect-and-snapshot", "xworkmate.artifacts.export"}) {
|
||
t.Fatalf("expected connect, artifact prepare, chat.send, agent.wait, then artifact export, got %#v", got)
|
||
}
|
||
client := gateway.LastConnectClient()
|
||
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 TestOpenClawArtifactContractInfersRemoteScenarioDeliverables(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
text string
|
||
want []string
|
||
}{
|
||
{
|
||
name: "video",
|
||
text: "围绕 AI Agent 身份演进 测试制作视频",
|
||
want: []string{"mp4"},
|
||
},
|
||
{
|
||
name: "image",
|
||
text: "Preferred skills:\n- it-infra-continuous-png\n\n连续制作 7 张图片",
|
||
want: []string{"png"},
|
||
},
|
||
{
|
||
name: "copywriting",
|
||
text: "输出小红书风格、微信文章风格、头条号风格文案",
|
||
want: []string{"md"},
|
||
},
|
||
{
|
||
name: "news",
|
||
text: "采集今天最新 AI Agent 资讯并输出报告",
|
||
want: []string{"md"},
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
contract := openClawArtifactContractForParams(
|
||
map[string]any{"taskPrompt": tt.text},
|
||
map[string]any{"message": tt.text},
|
||
)
|
||
if !slices.Equal(contract.ExpectedArtifactExtensions, tt.want) {
|
||
t.Fatalf("expected expected extensions %#v, got %#v", tt.want, contract.ExpectedArtifactExtensions)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestGatewayRequestForwardsOpenClawSkillsStatus(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
connected, rpcErr := server.handleRequest(shared.RPCRequest{
|
||
Method: "xworkmate.gateway.connect",
|
||
Params: map[string]any{
|
||
"runtimeId": "app-runtime",
|
||
"gatewayProviderId": "openclaw",
|
||
},
|
||
}, nil)
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected gateway connect, got rpc error: %#v", rpcErr)
|
||
}
|
||
if connected["ok"] != true {
|
||
t.Fatalf("expected gateway connect ok, got %#v", connected)
|
||
}
|
||
|
||
response, rpcErr := server.handleRequest(shared.RPCRequest{
|
||
Method: "xworkmate.gateway.request",
|
||
Params: map[string]any{
|
||
"runtimeId": "app-runtime",
|
||
"method": "skills.status",
|
||
"params": map[string]any{
|
||
"agentId": "main",
|
||
},
|
||
"timeoutMs": float64(15000),
|
||
},
|
||
}, nil)
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected skills.status gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if response["ok"] != true {
|
||
t.Fatalf("expected skills.status ok, got %#v", response)
|
||
}
|
||
payload := shared.AsMap(response["payload"])
|
||
if got := shared.StringArg(payload, "workspaceDir", ""); got != "/remote/openclaw/workspace" {
|
||
t.Fatalf("expected workspaceDir from OpenClaw payload, got %#v", payload)
|
||
}
|
||
if got := shared.StringArg(payload, "managedSkillsDir", ""); got != "/remote/openclaw/skills" {
|
||
t.Fatalf("expected managedSkillsDir from OpenClaw payload, got %#v", payload)
|
||
}
|
||
skills := shared.ListArg(payload, "skills")
|
||
if len(skills) != 2 {
|
||
t.Fatalf("expected two OpenClaw skill records, got %#v", payload["skills"])
|
||
}
|
||
first := shared.AsMap(skills[0])
|
||
if got := shared.StringArg(first, "skillKey", ""); got != "it-infra-continuous-png" {
|
||
t.Fatalf("expected bridge to preserve skillKey, got %#v", first)
|
||
}
|
||
if first["eligible"] != true {
|
||
t.Fatalf("expected eligible OpenClaw skill, got %#v", first)
|
||
}
|
||
second := shared.AsMap(skills[1])
|
||
if second["blockedByAgentFilter"] != true || second["disabled"] != true {
|
||
t.Fatalf("expected disabled/blocked skill metadata to be preserved, got %#v", second)
|
||
}
|
||
if !slices.Contains(gateway.Methods(), "skills.status") {
|
||
t.Fatalf("expected fake OpenClaw gateway to receive skills.status, got %#v", gateway.Methods())
|
||
}
|
||
}
|
||
|
||
func TestGatewayRequestSkillsStatusAutoConnectsOpenClaw(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json"))
|
||
resetBridgeGatewayIdentityForTest()
|
||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.handleRequest(shared.RPCRequest{
|
||
Method: "xworkmate.gateway.request",
|
||
Params: map[string]any{
|
||
"runtimeId": "app-runtime",
|
||
"method": "skills.status",
|
||
"params": map[string]any{
|
||
"agentId": "main",
|
||
},
|
||
"timeoutMs": float64(15000),
|
||
},
|
||
}, nil)
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected skills.status gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if response["ok"] != true {
|
||
t.Fatalf("expected skills.status ok, got %#v", response)
|
||
}
|
||
payload := shared.AsMap(response["payload"])
|
||
skills := shared.ListArg(payload, "skills")
|
||
if len(skills) != 2 {
|
||
t.Fatalf("expected two OpenClaw skill records, got %#v", payload["skills"])
|
||
}
|
||
if got := gateway.ConnectCount(); got != 1 {
|
||
t.Fatalf("expected skills.status to auto-connect OpenClaw once, got %d", got)
|
||
}
|
||
if !slices.Contains(gateway.Methods(), "skills.status") {
|
||
t.Fatalf("expected fake OpenClaw gateway to receive skills.status, got %#v", gateway.Methods())
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayNoDisplayableOutputFails(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.start",
|
||
Params: map[string]any{
|
||
"sessionId": "session-openclaw-no-output",
|
||
"threadId": "thread-openclaw-no-output",
|
||
"taskPrompt": "completed-empty",
|
||
"workingDirectory": t.TempDir(),
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected structured no-output response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if success, _ := response["success"].(bool); success {
|
||
t.Fatalf("expected no-output gateway response to fail, got %#v", response)
|
||
}
|
||
if got := response["status"]; got != "failed" {
|
||
t.Fatalf("expected failed status for no-output gateway response, got %#v", response)
|
||
}
|
||
if got := response["code"]; got != "OPENCLAW_NO_DISPLAYABLE_OUTPUT" {
|
||
t.Fatalf("expected structured no-displayable code, got %#v", response)
|
||
}
|
||
if got := response["output"]; got != openClawNoDisplayableText {
|
||
t.Fatalf("expected no-displayable output message, got %#v", response)
|
||
}
|
||
if gateway.ArtifactExportCount() != 1 {
|
||
t.Fatalf("expected one artifact export sync even when no displayable text is returned, got %d", gateway.ArtifactExportCount())
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayComplexArtifactContractAcceptsRequiredFinalArtifact(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-complex-pdf",
|
||
"threadId": "thread-openclaw-complex-pdf",
|
||
"taskPrompt": "make pdf artifact",
|
||
"workingDirectory": t.TempDir(),
|
||
"metadata": map[string]any{
|
||
"taskLoadClass": "complex_long_chain_task",
|
||
"expectedArtifactExtensions": []any{"pdf"},
|
||
},
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["success"]; got != true {
|
||
t.Fatalf("expected required PDF artifact to satisfy contract, got %#v", response)
|
||
}
|
||
artifacts := responseArtifactMaps(t, response)
|
||
if len(artifacts) != 1 || artifacts[0]["relativePath"] != "exports/final.pdf" {
|
||
t.Fatalf("expected final PDF artifact, got %#v", artifacts)
|
||
}
|
||
if got := response["expectedArtifactExtensions"]; fmt.Sprint(got) != "[pdf]" {
|
||
t.Fatalf("expected artifact extension diagnostics, got %#v", response)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayFailsArtifactContractAfterWaitFailure(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.start",
|
||
Params: map[string]any{
|
||
"sessionId": "session-openclaw-wait-recover",
|
||
"threadId": "thread-openclaw-wait-recover",
|
||
"taskPrompt": "wait-timeout",
|
||
"workingDirectory": t.TempDir(),
|
||
"metadata": map[string]any{
|
||
"taskLoadClass": "complex_long_chain_task",
|
||
"expectedArtifactExtensions": []any{"pdf"},
|
||
},
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected wait-timeout probe to keep task running, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["status"]; got != string(TaskStateRunning) {
|
||
t.Fatalf("expected wait-timeout probe to keep running status, got %#v", response)
|
||
}
|
||
if got := gateway.ChatSendCount(); got != 1 {
|
||
t.Fatalf("expected no automatic repair model turn, got %d", got)
|
||
}
|
||
if got := gateway.AgentWaitCount(); got != 1 {
|
||
t.Fatalf("expected one status probe, got %d", got)
|
||
}
|
||
if got := gateway.ArtifactExportCount(); got != 0 {
|
||
t.Fatalf("expected no artifact export before terminal state, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayKeepsRunningOnNonTerminalWaitPayload(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.start",
|
||
Params: map[string]any{
|
||
"sessionId": "session-openclaw-running-wait",
|
||
"threadId": "thread-openclaw-running-wait",
|
||
"taskPrompt": "wait-running",
|
||
"workingDirectory": t.TempDir(),
|
||
"metadata": map[string]any{
|
||
"taskLoadClass": "complex_long_chain_task",
|
||
"expectedArtifactExtensions": []any{"pdf"},
|
||
},
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected running wait payload to keep task running, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["status"]; got != string(TaskStateRunning) {
|
||
t.Fatalf("expected running status from non-terminal wait payload, got %#v", response)
|
||
}
|
||
if got := gateway.ChatSendCount(); got != 1 {
|
||
t.Fatalf("expected no repair turn, got %d", got)
|
||
}
|
||
if got := gateway.AgentWaitCount(); got != 1 {
|
||
t.Fatalf("expected one status probe, got %d", got)
|
||
}
|
||
if got := gateway.ArtifactExportCount(); got != 0 {
|
||
t.Fatalf("expected no artifact export before terminal wait payload, got %d", got)
|
||
}
|
||
if _, ok := response["code"]; ok {
|
||
t.Fatalf("expected no terminal failure code, got %#v", response)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayAgentFailedBeforeReplyReturnsFailureCode(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.start",
|
||
Params: map[string]any{
|
||
"sessionId": "session-openclaw-agent-failed",
|
||
"threadId": "thread-openclaw-agent-failed",
|
||
"taskPrompt": "agent failed before reply",
|
||
"workingDirectory": t.TempDir(),
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected structured agent failure response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["success"]; got != false {
|
||
t.Fatalf("expected agent failure response to fail, got %#v", response)
|
||
}
|
||
if got := response["status"]; got != "failed" {
|
||
t.Fatalf("expected failed status for agent failure, got %#v", response)
|
||
}
|
||
if got := response["code"]; got != "OPENCLAW_AGENT_FAILED_BEFORE_REPLY" {
|
||
t.Fatalf("expected agent failure code, got %#v", response)
|
||
}
|
||
if got := shared.StringArg(response, "error", ""); !strings.Contains(got, "No available auth profile") {
|
||
t.Fatalf("expected agent failure details, got %#v", response)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionMessageGatewayUsesOpenClawChatSend(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.message",
|
||
Params: map[string]any{
|
||
"sessionId": "session-openclaw",
|
||
"threadId": "thread-openclaw",
|
||
"taskPrompt": "continue",
|
||
"workingDirectory": t.TempDir(),
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["output"]; got != "gateway pong" {
|
||
t.Fatalf("expected gateway pong output, got %#v", response)
|
||
}
|
||
if gateway.ChatSendCount() != 1 {
|
||
t.Fatalf("expected one OpenClaw chat.send request, got %d", gateway.ChatSendCount())
|
||
}
|
||
if gateway.AgentWaitCount() != 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.collect-and-snapshot", "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 as task result, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["status"]; got != string(TaskStateFailed) {
|
||
t.Fatalf("expected failed task result, got %#v", response)
|
||
}
|
||
if got := response["code"]; got != "OPENCLAW_WAIT_FAILED" {
|
||
t.Fatalf("expected OpenClaw wait failure code, got %#v", response)
|
||
}
|
||
if got := shared.StringArg(response, "message", ""); !strings.Contains(got, "openclaw wait failed") {
|
||
t.Fatalf("expected surfaced agent.wait failure, got %#v", response)
|
||
}
|
||
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "agent.wait"}) {
|
||
t.Fatalf("expected connect, artifact prepare, chat.send, then agent.wait, got %#v", got)
|
||
}
|
||
snapshot := server.handleTaskGet(context.Background(), map[string]any{
|
||
"sessionId": "session-openclaw-wait-fail",
|
||
"threadId": "thread-openclaw-wait-fail",
|
||
}, nil)
|
||
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 != shared.StringArg(response, "sessionKey", "") {
|
||
t.Fatalf("expected mapped sessionKey in downloadUrl, got %q", got)
|
||
}
|
||
if got := parsedDownloadURL.Query().Get("relativePath"); got != "reports/final.md" {
|
||
t.Fatalf("expected artifact relativePath in downloadUrl, got %q", got)
|
||
}
|
||
if artifactScope := parsedDownloadURL.Query().Get("artifactScope"); artifactScope != "tasks/"+shared.StringArg(response, "sessionKey", "")+"/"+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.collect-and-snapshot", "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.collect-and-snapshot", "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/"+shared.StringArg(response, "sessionKey", "")+"/openclaw-run-actual" {
|
||
t.Fatalf("expected artifact export to use actual OpenClaw run scope, got %#v", exportParams)
|
||
}
|
||
artifacts, ok := response["artifacts"].([]map[string]any)
|
||
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/"+shared.StringArg(response, "sessionKey", "")+"/openclaw-run-actual" {
|
||
t.Fatalf("expected download URL to use actual OpenClaw artifact scope, got %q", got)
|
||
}
|
||
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.artifacts.prepare", "chat.send", "xworkmate.artifacts.prepare", "agent.wait", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.export"}) {
|
||
t.Fatalf("expected bridge to reprepare actual OpenClaw run before wait/export, got %#v", got)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayDoesNotExportArtifactScopeDeclaredInOutput(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
const sessionKey = "draft-1780110089528457-2"
|
||
const actualRunID = "turn-1780353431540965793"
|
||
actualScope := "tasks/draft_1780110089528457-2/" + actualRunID
|
||
actualFile := filepath.Join(
|
||
gateway.artifactWorkspaceRoot,
|
||
filepath.FromSlash(actualScope),
|
||
"AI_Agent_News_June_2_2026.md",
|
||
)
|
||
if err := os.MkdirAll(filepath.Dir(actualFile), 0o755); err != nil {
|
||
t.Fatalf("create actual artifact dir: %v", err)
|
||
}
|
||
if err := os.WriteFile(actualFile, []byte("news artifact"), 0o644); err != nil {
|
||
t.Fatalf("write actual artifact: %v", err)
|
||
}
|
||
gateway.alternateRunID = actualRunID
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.start",
|
||
Params: map[string]any{
|
||
"sessionId": sessionKey,
|
||
"threadId": sessionKey,
|
||
"taskPrompt": "declare output artifact path",
|
||
"workingDirectory": t.TempDir(),
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["success"]; got != true {
|
||
t.Fatalf("expected text-only response to complete without adopting output-declared artifact, got %#v", response)
|
||
}
|
||
if got := gateway.ArtifactExportCount(); got != 1 {
|
||
t.Fatalf("expected only current prepared scope export, got %d", got)
|
||
}
|
||
if artifacts, ok := response["artifacts"]; ok {
|
||
t.Fatalf("expected no artifact from output-declared path, got %#v", artifacts)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionTaskGatewayDoesNotExportDraftScopeVariant(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
const sessionKey = "draft-1780110089528457-2"
|
||
const actualRunID = "turn-1780353431540965793"
|
||
actualScope := "tasks/draft_1780110089528457-2/" + actualRunID
|
||
actualFile := filepath.Join(
|
||
gateway.artifactWorkspaceRoot,
|
||
filepath.FromSlash(actualScope),
|
||
"AI_Agent_News_June_2_2026.md",
|
||
)
|
||
if err := os.MkdirAll(filepath.Dir(actualFile), 0o755); err != nil {
|
||
t.Fatalf("create actual artifact dir: %v", err)
|
||
}
|
||
if err := os.WriteFile(actualFile, []byte("news artifact"), 0o644); err != nil {
|
||
t.Fatalf("write actual artifact: %v", err)
|
||
}
|
||
gateway.alternateRunID = actualRunID
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.start",
|
||
Params: map[string]any{
|
||
"sessionId": sessionKey,
|
||
"threadId": sessionKey,
|
||
"taskPrompt": "plain done",
|
||
"workingDirectory": t.TempDir(),
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["success"]; got != true {
|
||
t.Fatalf("expected text-only task to complete without adopting draft variant artifact, got %#v", response)
|
||
}
|
||
if got := gateway.ArtifactExportCount(); got != 1 {
|
||
t.Fatalf("expected only current prepared scope export, got %d", got)
|
||
}
|
||
if artifacts, ok := response["artifacts"]; ok {
|
||
t.Fatalf("expected no artifact from draft scope variant, got %#v", artifacts)
|
||
}
|
||
}
|
||
|
||
func TestExecuteSessionMessageGatewayDoesNotRewriteClaimedArtifactsWithoutGatewayFiles(t *testing.T) {
|
||
gateway := newAcpFakeOpenClawGateway(t)
|
||
defer gateway.Close()
|
||
|
||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||
|
||
server := NewServer()
|
||
response, rpcErr := server.executeSessionTask(task{
|
||
req: shared.RPCRequest{
|
||
Method: "session.message",
|
||
Params: map[string]any{
|
||
"sessionId": "session-openclaw-claimed-artifact",
|
||
"threadId": "thread-openclaw-claimed-artifact",
|
||
"taskPrompt": "hi hallucinate-files",
|
||
"workingDirectory": t.TempDir(),
|
||
"routing": map[string]any{
|
||
"routingMode": "explicit",
|
||
"explicitExecutionTarget": "gateway",
|
||
"preferredGatewayProviderId": "openclaw",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if rpcErr != nil {
|
||
t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr)
|
||
}
|
||
if got := response["success"]; got != true {
|
||
t.Fatalf("expected bridge to preserve gateway terminal state, got %#v", response)
|
||
}
|
||
if output := strings.TrimSpace(shared.StringArg(response, "output", "")); !strings.Contains(output, "文件已就绪") {
|
||
t.Fatalf("expected bridge not to rewrite gateway text output, got %q", output)
|
||
}
|
||
if gateway.ArtifactExportCount() != 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.collect-and-snapshot", "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.collect-and-snapshot", "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 TestExecuteSessionTaskGatewayFailsHallucinatedFileClaimsWithoutArtifacts(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 reject hallucinated file success without artifacts, got %#v", response)
|
||
}
|
||
if got := response["code"]; got != "OPENCLAW_REQUIRED_ARTIFACT_MISSING" {
|
||
t.Fatalf("expected required artifact failure code, got %#v", response)
|
||
}
|
||
if _, ok := response["artifacts"]; ok {
|
||
t.Fatalf("expected no artifacts when export returned none, got %#v", response["artifacts"])
|
||
}
|
||
if missing := fmt.Sprint(response["missingArtifactExtensions"]); missing == "[]" || strings.TrimSpace(missing) == "" {
|
||
t.Fatalf("expected missing extension diagnostics, got %#v", response)
|
||
}
|
||
}
|
||
|
||
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
|
||
artifactSnapshotCount 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
|
||
lastArtifactSnapshotParams atomic.Value
|
||
lastArtifactExportParams atomic.Value
|
||
lastAgentWaitParams atomic.Value
|
||
mu sync.Mutex
|
||
methods []string
|
||
runMessages map[string]string
|
||
artifactMode string
|
||
artifactWorkspaceRoot 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)
|
||
}
|
||
message := strings.TrimSpace(shared.StringArg(params, "message", ""))
|
||
fake.recordRunMessage(runID, message)
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"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
|
||
workspaceRoot := "/remote/openclaw/workspace"
|
||
if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" {
|
||
workspaceRoot = strings.TrimSpace(fake.artifactWorkspaceRoot)
|
||
}
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"sessionKey": sessionKey,
|
||
"remoteWorkingDirectory": workspaceRoot,
|
||
"remoteWorkspaceRefKind": "remotePath",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
"artifactDirectory": filepath.Join(workspaceRoot, filepath.FromSlash(artifactScope)),
|
||
"relativeArtifactDirectory": artifactScope,
|
||
"warnings": []any{},
|
||
},
|
||
})
|
||
case "agent.wait":
|
||
fake.agentWaitCount.Add(1)
|
||
if delayMs := fake.agentWaitDelayMs.Load(); delayMs > 0 {
|
||
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
||
}
|
||
params := shared.AsMap(frame["params"])
|
||
fake.lastAgentWaitParams.Store(params)
|
||
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
|
||
switch fake.runMessage(runID) {
|
||
case "wait-error":
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": false,
|
||
"error": map[string]any{
|
||
"code": "OPENCLAW_WAIT_FAILED",
|
||
"message": "openclaw wait failed",
|
||
},
|
||
})
|
||
continue
|
||
case "wait-timeout":
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": false,
|
||
"error": map[string]any{
|
||
"code": "TIMEOUT",
|
||
"message": "openclaw wait timeout",
|
||
},
|
||
})
|
||
continue
|
||
case "wait-running":
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"status": "running",
|
||
"terminal": false,
|
||
},
|
||
})
|
||
continue
|
||
case "completed-empty":
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"status": "completed",
|
||
},
|
||
})
|
||
continue
|
||
}
|
||
message := "gateway pong"
|
||
if strings.Contains(fake.runMessage(runID), "hallucinate-files") {
|
||
message = "文件已就绪,点击直接下载👇 三个格式一键收取:"
|
||
}
|
||
if strings.Contains(fake.runMessage(runID), "declare output artifact path") {
|
||
message = "I've saved the requested file at `/remote/openclaw/workspace/tasks/draft_1780110089528457-2/" + runID + "/AI_Agent_News_June_2_2026.md`"
|
||
}
|
||
if strings.Contains(fake.runMessage(runID), "agent failed before reply") {
|
||
message = "Agent failed before reply: No available auth profile for nvidia"
|
||
}
|
||
emitChatEvent := !strings.Contains(fake.runMessage(runID), "silent-turn")
|
||
if payloadBytes := fake.largeGatewayPayloadBytes.Load(); payloadBytes > 0 {
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "event",
|
||
"event": "health",
|
||
"seq": 1,
|
||
"payload": map[string]any{
|
||
"status": "ok",
|
||
"blob": strings.Repeat("x", int(payloadBytes)),
|
||
},
|
||
})
|
||
}
|
||
if fake.emitAgentDelta.Load() {
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "event",
|
||
"event": "agent",
|
||
"seq": 2,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"sessionKey": "main",
|
||
"stream": "assistant",
|
||
"data": map[string]any{"text": "streamed delta"},
|
||
"largeIgnored": strings.Repeat("y", 1024),
|
||
},
|
||
})
|
||
}
|
||
if emitChatEvent {
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "event",
|
||
"event": "chat",
|
||
"seq": 3,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"state": "final",
|
||
"message": map[string]any{
|
||
"role": "assistant",
|
||
"content": message,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
if strings.Contains(fake.runMessage(runID), "event artifact") {
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "event",
|
||
"event": "chat",
|
||
"seq": 2,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"state": "final",
|
||
"remoteWorkingDirectory": "/remote/openclaw/events",
|
||
"remoteWorkspaceRefKind": "remotePath",
|
||
"artifacts": []any{
|
||
map[string]any{
|
||
"relativePath": "events/live.txt",
|
||
"contentType": "text/plain",
|
||
"encoding": "base64",
|
||
"content": "bGl2ZSBldmVudA==",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"status": "ok",
|
||
},
|
||
})
|
||
case "xworkmate.artifacts.collect-and-snapshot":
|
||
fake.artifactSnapshotCount.Add(1)
|
||
params := shared.AsMap(frame["params"])
|
||
fake.lastArtifactSnapshotParams.Store(params)
|
||
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
|
||
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
|
||
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": map[string]any{
|
||
"runId": runID,
|
||
"sessionKey": sessionKey,
|
||
"remoteWorkingDirectory": "/remote/openclaw/workspace",
|
||
"remoteWorkspaceRefKind": "remotePath",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
"copiedFiles": []any{},
|
||
"warnings": []any{},
|
||
},
|
||
})
|
||
case "xworkmate.artifacts.export":
|
||
fake.artifactCount.Add(1)
|
||
if fake.artifactMode == "unknown" {
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": false,
|
||
"error": map[string]any{
|
||
"code": "UNKNOWN_METHOD",
|
||
"message": "unknown method: xworkmate.artifacts.export",
|
||
},
|
||
})
|
||
continue
|
||
}
|
||
params := shared.AsMap(frame["params"])
|
||
fake.lastArtifactExportParams.Store(params)
|
||
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
|
||
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
|
||
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||
payload := map[string]any{
|
||
"runId": runID,
|
||
"sessionKey": sessionKey,
|
||
"remoteWorkingDirectory": "/remote/openclaw/workspace",
|
||
"remoteWorkspaceRefKind": "remotePath",
|
||
"scopeKind": "workspace",
|
||
"artifacts": []any{},
|
||
"warnings": []any{},
|
||
}
|
||
if artifactScope != "" {
|
||
payload["artifactScope"] = artifactScope
|
||
payload["scopeKind"] = "task"
|
||
}
|
||
filesystemArtifacts := []any{}
|
||
if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" && artifactScope != "" {
|
||
payload["remoteWorkingDirectory"] = strings.TrimSpace(fake.artifactWorkspaceRoot)
|
||
if sessionKey == "" || openClawSessionKeyFromArtifactScope(artifactScope) == sessionKey {
|
||
filesystemArtifacts = fake.exportFilesystemArtifacts(artifactScope)
|
||
}
|
||
}
|
||
if strings.Contains(fake.runMessage(runID), "make artifact") {
|
||
payload["artifacts"] = []any{
|
||
map[string]any{
|
||
"relativePath": "reports/final.md",
|
||
"label": "final.md",
|
||
"contentType": "text/markdown",
|
||
"sizeBytes": 12,
|
||
"sha256": "fake-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
"encoding": "base64",
|
||
"content": "ZmluYWwgcmVwb3J0",
|
||
},
|
||
}
|
||
}
|
||
if strings.Contains(fake.runMessage(runID), "make pdf artifact") {
|
||
payload["artifacts"] = []any{
|
||
map[string]any{
|
||
"relativePath": "exports/final.pdf",
|
||
"label": "final.pdf",
|
||
"contentType": "application/pdf",
|
||
"sizeBytes": 12,
|
||
"sha256": "fake-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
},
|
||
}
|
||
}
|
||
if strings.Contains(fake.runMessage(runID), "make partial artifact") {
|
||
payload["artifacts"] = []any{
|
||
map[string]any{
|
||
"relativePath": "chapters/intro.md",
|
||
"label": "intro.md",
|
||
"contentType": "text/markdown",
|
||
"sizeBytes": 12,
|
||
"sha256": "fake-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
},
|
||
}
|
||
}
|
||
runMessage := fake.runMessage(runID)
|
||
lowerRunMessage := strings.ToLower(runMessage)
|
||
hallucinatedFiles := strings.Contains(runMessage, "hallucinate-files")
|
||
if !hallucinatedFiles && (strings.Contains(runMessage, "7张") || strings.Contains(runMessage, "图片") || strings.Contains(lowerRunMessage, "image")) {
|
||
payload["artifacts"] = appendArtifactList(payload["artifacts"], []any{map[string]any{
|
||
"relativePath": "artifacts/media/browser/series-01.png",
|
||
"label": "series-01.png",
|
||
"contentType": "image/png",
|
||
"sizeBytes": 12,
|
||
"sha256": "fake-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
}})
|
||
}
|
||
if !hallucinatedFiles && !strings.Contains(runMessage, "make pdf artifact") && strings.Contains(lowerRunMessage, "pdf") {
|
||
payload["artifacts"] = appendArtifactList(payload["artifacts"], []any{map[string]any{
|
||
"relativePath": "artifacts/tmp-openclaw/final.pdf",
|
||
"label": "final.pdf",
|
||
"contentType": "application/pdf",
|
||
"sizeBytes": 12,
|
||
"sha256": "fake-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
}})
|
||
}
|
||
if !hallucinatedFiles && (strings.Contains(runMessage, "视频") || strings.Contains(lowerRunMessage, "video")) {
|
||
payload["artifacts"] = appendArtifactList(payload["artifacts"], []any{map[string]any{
|
||
"relativePath": "artifacts/tmp-openclaw/final.mp4",
|
||
"label": "final.mp4",
|
||
"contentType": "video/mp4",
|
||
"sizeBytes": 12,
|
||
"sha256": "fake-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
}})
|
||
}
|
||
if len(filesystemArtifacts) > 0 {
|
||
payload["artifacts"] = appendArtifactList(payload["artifacts"], filesystemArtifacts)
|
||
}
|
||
_ = conn.WriteJSON(map[string]any{
|
||
"type": "res",
|
||
"id": id,
|
||
"ok": true,
|
||
"payload": payload,
|
||
})
|
||
case "xworkmate.artifacts.read":
|
||
fake.artifactReadCount.Add(1)
|
||
params := shared.AsMap(frame["params"])
|
||
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) exportFilesystemArtifacts(artifactScope string) []any {
|
||
root := strings.TrimSpace(f.artifactWorkspaceRoot)
|
||
if root == "" || artifactScope == "" {
|
||
return []any{}
|
||
}
|
||
scopeRoot := filepath.Join(root, filepath.FromSlash(artifactScope))
|
||
entries := make([]any, 0)
|
||
_ = filepath.WalkDir(scopeRoot, func(path string, entry os.DirEntry, err error) error {
|
||
if err != nil || entry == nil || entry.IsDir() {
|
||
return nil
|
||
}
|
||
relativePath, relErr := filepath.Rel(scopeRoot, path)
|
||
if relErr != nil || strings.HasPrefix(relativePath, "..") {
|
||
return nil
|
||
}
|
||
info, statErr := entry.Info()
|
||
if statErr != nil {
|
||
return nil
|
||
}
|
||
entries = append(entries, map[string]any{
|
||
"relativePath": filepath.ToSlash(relativePath),
|
||
"label": filepath.Base(path),
|
||
"contentType": artifactContentType(filepath.ToSlash(relativePath)),
|
||
"sizeBytes": info.Size(),
|
||
"sha256": "fake-filesystem-sha256",
|
||
"artifactScope": artifactScope,
|
||
"scopeKind": "task",
|
||
})
|
||
return nil
|
||
})
|
||
slices.SortFunc(entries, func(left any, right any) int {
|
||
return strings.Compare(
|
||
shared.StringArg(shared.AsMap(left), "relativePath", ""),
|
||
shared.StringArg(shared.AsMap(right), "relativePath", ""),
|
||
)
|
||
})
|
||
return entries
|
||
}
|
||
|
||
func (f *acpFakeOpenClawGateway) LastArtifactPrepareParams() map[string]any {
|
||
params, _ := f.lastArtifactPrepareParams.Load().(map[string]any)
|
||
return params
|
||
}
|
||
|
||
func (f *acpFakeOpenClawGateway) ArtifactSnapshotCount() int {
|
||
return int(f.artifactSnapshotCount.Load())
|
||
}
|
||
|
||
func (f *acpFakeOpenClawGateway) LastArtifactSnapshotParams() map[string]any {
|
||
params, _ := f.lastArtifactSnapshotParams.Load().(map[string]any)
|
||
return params
|
||
}
|
||
|
||
func (f *acpFakeOpenClawGateway) ArtifactExportCount() int {
|
||
return int(f.artifactCount.Load())
|
||
}
|
||
|
||
func (f *acpFakeOpenClawGateway) ArtifactReadCount() int {
|
||
return int(f.artifactReadCount.Load())
|
||
}
|
||
|
||
func (f *acpFakeOpenClawGateway) 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 responseArtifactMaps(t *testing.T, response map[string]any) []map[string]any {
|
||
t.Helper()
|
||
if artifacts, ok := response["artifacts"].([]map[string]any); ok {
|
||
return artifacts
|
||
}
|
||
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))
|
||
}
|
||
return artifacts
|
||
}
|
||
|
||
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"])
|
||
}
|
||
}
|