diff --git a/internal/acp/execution.go b/internal/acp/execution.go index a80e096..a250d6b 100644 --- a/internal/acp/execution.go +++ b/internal/acp/execution.go @@ -168,6 +168,37 @@ func (s *Server) runSingleAgentViaExternalProvider( return enrichSingleAgentResultArtifacts(collector.apply(result), forwardParams), nil } +func (s *Server) probeExternalProvider( + ctx context.Context, + provider syncedProvider, + params map[string]any, +) (map[string]any, error) { + endpoint := resolveSingleAgentForwardEndpoint(provider) + if endpoint == "" { + return nil, fmt.Errorf("external provider endpoint is missing") + } + authorization := firstNonEmptyString( + strings.TrimSpace(provider.AuthorizationHeader), + strings.TrimSpace(shared.StringArg(params, inboundAuthorizationHeaderKey, "")), + ) + response, err := requestExternalACP( + ctx, + endpoint, + authorization, + "acp.capabilities", + map[string]any{}, + nil, + ) + if err != nil { + return nil, err + } + result := asMap(response["result"]) + if len(result) == 0 { + return nil, fmt.Errorf("external provider probe missing result payload") + } + return result, nil +} + func resolveSingleAgentForwardEndpoint(provider syncedProvider) string { return strings.TrimSpace(provider.Endpoint) } diff --git a/internal/acp/providers_sync_test.go b/internal/acp/providers_sync_test.go index 8ef83f6..240f115 100644 --- a/internal/acp/providers_sync_test.go +++ b/internal/acp/providers_sync_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "testing" @@ -312,6 +313,64 @@ func TestExecuteSessionTaskUsesBridgeAuthTokenFallbackForBuiltInProvider(t *test } } +func TestHandleRequestProviderProbeUsesBridgeForwardingPath(t *testing.T) { + externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer probe-token" { + t.Fatalf("expected probe bearer auth header, got %q", got) + } + 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) + } + if got := request["method"]; got != "acp.capabilities" { + t.Fatalf("expected bridge probe to forward acp.capabilities, got %#v", request) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request["id"], + "result": map[string]any{ + "providers": []string{"codex"}, + }, + }) + })) + defer externalServer.Close() + + server := NewServer() + setTestBridgeProvider(server, syncedProvider{ + ProviderID: "codex", + Label: "Codex", + Endpoint: externalServer.URL, + AuthorizationHeader: "Bearer probe-token", + Enabled: true, + }) + + response, rpcErr := server.handleRequest(shared.RPCRequest{ + Method: "xworkmate.provider.probe", + Params: map[string]any{ + "providerId": "codex", + }, + }, func(map[string]any) {}) + if rpcErr != nil { + t.Fatalf("expected success, got rpc error: %v", rpcErr) + } + if got := response["success"]; got != true { + t.Fatalf("expected provider probe success, got %#v", response) + } + if got := response["providerId"]; got != "codex" { + t.Fatalf("expected providerId codex, got %#v", response) + } + capabilities, ok := response["capabilities"].(map[string]any) + if !ok { + t.Fatalf("expected capabilities payload, got %#v", response) + } + if got := capabilities["providers"]; !reflect.DeepEqual(got, []any{"codex"}) { + t.Fatalf("expected provider list in capabilities, got %#v", capabilities) + } +} + func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteMetadata(t *testing.T) { workingDir := t.TempDir() if err := os.MkdirAll(filepath.Join(workingDir, "outputs"), 0o755); err != nil { diff --git a/internal/acp/server.go b/internal/acp/server.go index 06e6b55..1c50a7c 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -396,6 +396,36 @@ func (s *Server) handleRequest( s.availableProviders(), ) return mergeRoutingResponse(map[string]any{"ok": true}, result), nil + case "xworkmate.provider.probe": + providerID := strings.TrimSpace(shared.StringArg(request.Params, "providerId", "")) + if providerID == "" { + return nil, &shared.RPCError{ + Code: -32602, + Message: "providerId is required", + } + } + provider, ok := s.syncedProviderByID(providerID) + if !ok { + return map[string]any{ + "success": false, + "providerId": providerID, + "error": "provider is not advertised by the bridge", + }, nil + } + result, err := s.probeExternalProvider(context.Background(), provider, request.Params) + if err != nil { + return map[string]any{ + "success": false, + "providerId": providerID, + "error": err.Error(), + }, nil + } + return map[string]any{ + "success": true, + "providerId": providerID, + "probeMethod": "acp.capabilities", + "capabilities": result, + }, nil case "xworkmate.mounts.reconcile": return handleMountReconcile(request.Params), nil case "xworkmate.gateway.connect": diff --git a/scripts/github-actions/test-validate-deploy.sh b/scripts/github-actions/test-validate-deploy.sh index 0c6e2cf..4121580 100644 --- a/scripts/github-actions/test-validate-deploy.sh +++ b/scripts/github-actions/test-validate-deploy.sh @@ -139,7 +139,20 @@ case "${scenario}" in printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n' ;; https://xworkmate-bridge.svc.plus/acp/rpc) - printf '{"jsonrpc":"2.0","result":{"success":true,"output":"pong"}}\n' + if [[ "${data}" == *'"providerId":"codex"'* ]]; then + printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"codex","capabilities":{"providers":["codex"]}}}\n' + exit 0 + fi + if [[ "${data}" == *'"providerId":"opencode"'* ]]; then + printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"opencode","capabilities":{"providers":["opencode"]}}}\n' + exit 0 + fi + if [[ "${data}" == *'"providerId":"gemini"'* ]]; then + printf '{"jsonrpc":"2.0","result":{"success":true,"providerId":"gemini","capabilities":{"providers":["gemini"]}}}\n' + exit 0 + fi + printf 'unexpected bridge probe payload in retry-success scenario: %s\n' "${data}" >&2 + exit 1 ;; *) printf 'unexpected url in retry-success scenario: %s\n' "${url}" >&2 diff --git a/scripts/github-actions/validate-deploy.sh b/scripts/github-actions/validate-deploy.sh index 8325fdd..cd8c034 100644 --- a/scripts/github-actions/validate-deploy.sh +++ b/scripts/github-actions/validate-deploy.sh @@ -5,7 +5,7 @@ IMAGE_REF="${1:?image_ref is required}" RETRYABLE_TRANSPORT=10 RETRYABLE_NOT_READY=11 FAST_HTTP_TIMEOUT_SECONDS=20 -BRIDGE_RPC_TIMEOUT_SECONDS=330 +BRIDGE_RPC_TIMEOUT_SECONDS=60 normalize_url() { local value="$1" @@ -215,15 +215,13 @@ jsonrpc_bridge_call() { printf '%s\n' "${response}" } -probe_bridge_single_agent_smoke_once() { +probe_bridge_provider_probe_once() { local provider_id="$1" - local request_id="smoke-${provider_id}-$(date +%s)" - local session_id="validate-${provider_id}-$(date +%s)" local payload local response payload="$(cat <= 2 and normalized[0] == normalized[-1] and normalized[0] in {'"', "'"}: - normalized = normalized[1:-1].strip() - return normalized.lower() - -text = first_text_candidate(result) -if normalize_text(text) != "pong": - raise SystemExit(f"{provider}: expected normalized pong output, got {text!r} from {result!r}") +capabilities = result.get("capabilities") +if not isinstance(capabilities, dict): + raise SystemExit(f"{provider}: probe did not return capabilities payload: {result!r}") PY } @@ -376,6 +361,6 @@ probe_safe_http_endpoint "${OPENCLAW_HTTP_PROBE_URL}" run_with_retry "capabilities ${CODEX_RPC_URL}" 3 5 "${RETRYABLE_TRANSPORT}" probe_jsonrpc_capabilities_once "${CODEX_RPC_URL}" run_with_retry "capabilities ${OPENCODE_RPC_URL}" 3 5 "${RETRYABLE_TRANSPORT}" probe_jsonrpc_capabilities_once "${OPENCODE_RPC_URL}" run_with_retry "capabilities ${GEMINI_RPC_URL}" 3 5 "${RETRYABLE_TRANSPORT}" probe_jsonrpc_capabilities_once "${GEMINI_RPC_URL}" -run_with_retry "bridge single-agent smoke codex" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_single_agent_smoke_once "codex" -run_with_retry "bridge single-agent smoke opencode" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_single_agent_smoke_once "opencode" -run_with_retry "bridge single-agent smoke gemini" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_single_agent_smoke_once "gemini" +run_with_retry "bridge provider probe codex" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "codex" +run_with_retry "bridge provider probe opencode" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "opencode" +run_with_retry "bridge provider probe gemini" 3 10 "${RETRYABLE_TRANSPORT}" probe_bridge_provider_probe_once "gemini"