diff --git a/CHANGELOG.md b/CHANGELOG.md index 4465fbc..710659e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.2] - 2026-01-07 + +### Fixed + +- **Gateway Mode clientId not sent in request body**: Fixed `getPolicyApprovedContext()` to auto-populate `client_id` in request body from config when not explicitly provided + - Server requires `client_id` in JSON body for `/api/policy/pre-check` endpoint + - Previously only sent as header (X-Client-ID), causing "client_id field is required" errors + - Now matches Go SDK behavior which auto-populates from `config.ClientID` + - Affects all Gateway Mode pre-check calls + +- **executePlan() using non-existent endpoint**: Fixed `executePlan()` to use correct Agent API endpoint + - Changed from `/api/v1/orchestrator/plan/{planId}/execute` (404) to `/api/request` with `request_type: "execute-plan"` + - Now matches Go SDK pattern for plan execution + - Fixes MAP (Multi-Agent Planning) two-step execution flow + ## [2.1.1] - 2026-01-06 ### Fixed diff --git a/pom.xml b/pom.xml index 3bd3976..2cb1ecc 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 2.1.1 + 2.1.2 jar AxonFlow Java SDK diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index f1f6f03..4817bc4 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -261,8 +261,23 @@ public PolicyApprovalResult getPolicyApprovedContext(PolicyApprovalRequest reque // Gateway Mode: Let server decide if credentials are required based on DEPLOYMENT_MODE // Community/self-hosted deployments work without credentials + // Auto-populate clientId from config if not set in request (matches Go SDK behavior) + PolicyApprovalRequest effectiveRequest = request; + if ((request.getClientId() == null || request.getClientId().isEmpty()) + && config.getClientId() != null && !config.getClientId().isEmpty()) { + Map ctx = request.getContext(); + effectiveRequest = PolicyApprovalRequest.builder() + .userToken(request.getUserToken()) + .query(request.getQuery()) + .dataSources(request.getDataSources()) + .context(ctx == null || ctx.isEmpty() ? null : ctx) + .clientId(config.getClientId()) + .build(); + } + + final PolicyApprovalRequest finalRequest = effectiveRequest; return retryExecutor.execute(() -> { - Request httpRequest = buildRequest("POST", "/api/policy/pre-check", request); + Request httpRequest = buildRequest("POST", "/api/policy/pre-check", finalRequest); try (Response response = httpClient.newCall(httpRequest).execute()) { PolicyApprovalResult result = parseResponse(response, PolicyApprovalResult.class); @@ -648,14 +663,55 @@ public PlanResponse executePlan(String planId) { Objects.requireNonNull(planId, "planId cannot be null"); return retryExecutor.execute(() -> { - Request httpRequest = buildRequest("POST", - "/api/v1/orchestrator/plan/" + planId + "/execute", null); + // Build agent request format - like generatePlan but with request_type "execute-plan" + String userToken = config.getClientId() != null ? config.getClientId() : "default"; + String clientId = config.getClientId() != null ? config.getClientId() : "default"; + + Map agentRequest = new java.util.HashMap<>(); + agentRequest.put("query", ""); + agentRequest.put("user_token", userToken); + agentRequest.put("client_id", clientId); + agentRequest.put("request_type", "execute-plan"); + agentRequest.put("context", Map.of("plan_id", planId)); + + Request httpRequest = buildRequest("POST", "/api/request", agentRequest); try (Response response = httpClient.newCall(httpRequest).execute()) { - return parseResponse(response, PlanResponse.class); + return parseExecutePlanResponse(response, planId); } }, "executePlan"); } + /** + * Parses the execute plan response. + */ + @SuppressWarnings("unchecked") + private PlanResponse parseExecutePlanResponse(Response response, String planId) throws IOException { + handleErrorResponse(response); + + ResponseBody body = response.body(); + if (body == null) { + throw new AxonFlowException("Empty response body", response.code(), null); + } + + String json = body.string(); + Map agentResponse = objectMapper.readValue(json, + new TypeReference>() {}); + + // Check for errors + Boolean success = (Boolean) agentResponse.get("success"); + if (success == null || !success) { + String error = (String) agentResponse.get("error"); + throw new PlanExecutionException(error != null ? error : "Plan execution failed"); + } + + // Extract result - this is the completed plan output + String result = (String) agentResponse.get("result"); + + // Build response with execution status + return new PlanResponse(planId, Collections.emptyList(), null, null, null, + null, null, "completed", result); + } + /** * Gets the status of a plan. * diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index 541a074..34e9569 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -158,6 +158,69 @@ void getPolicyApprovedContextAsyncShouldReturnFuture() throws Exception { assertThat(result.isApproved()).isTrue(); } + @Test + @DisplayName("getPolicyApprovedContext should auto-populate clientId from config") + void getPolicyApprovedContextShouldAutoPopulateClientId(WireMockRuntimeInfo wmRuntimeInfo) { + // Create client with clientId configured + AxonFlow client = AxonFlow.create(AxonFlowConfig.builder() + .endpoint(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("my-client-id") + .clientSecret("my-secret") + .build()); + + stubFor(post(urlEqualTo("/api/policy/pre-check")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"context_id\":\"ctx_123\",\"approved\":true}"))); + + // Request WITHOUT explicit clientId - SDK should auto-populate from config + PolicyApprovalRequest request = PolicyApprovalRequest.builder() + .userToken("user-123") + .query("What is the capital of France?") + .build(); + + PolicyApprovalResult result = client.getPolicyApprovedContext(request); + + assertThat(result.isApproved()).isTrue(); + + // Verify clientId was sent in request body (server requires this) + verify(postRequestedFor(urlEqualTo("/api/policy/pre-check")) + .withRequestBody(matchingJsonPath("$.client_id", equalTo("my-client-id")))); + } + + @Test + @DisplayName("getPolicyApprovedContext should use explicit clientId if provided") + void getPolicyApprovedContextShouldUseExplicitClientId(WireMockRuntimeInfo wmRuntimeInfo) { + // Create client with clientId configured + AxonFlow client = AxonFlow.create(AxonFlowConfig.builder() + .endpoint(wmRuntimeInfo.getHttpBaseUrl()) + .clientId("config-client-id") + .clientSecret("my-secret") + .build()); + + stubFor(post(urlEqualTo("/api/policy/pre-check")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"context_id\":\"ctx_123\",\"approved\":true}"))); + + // Request WITH explicit clientId - should use this one, not config + PolicyApprovalRequest request = PolicyApprovalRequest.builder() + .userToken("user-123") + .query("What is the capital of France?") + .clientId("explicit-client-id") + .build(); + + PolicyApprovalResult result = client.getPolicyApprovedContext(request); + + assertThat(result.isApproved()).isTrue(); + + // Verify explicit clientId was sent (not the config one) + verify(postRequestedFor(urlEqualTo("/api/policy/pre-check")) + .withRequestBody(matchingJsonPath("$.client_id", equalTo("explicit-client-id")))); + } + // ======================================================================== // Gateway Mode - Audit // ======================================================================== @@ -257,18 +320,25 @@ void executePlanShouldRequirePlanId() { } @Test - @DisplayName("executePlan should execute plan") + @DisplayName("executePlan should execute plan via Agent API") void executePlanShouldExecutePlan() { - stubFor(post(urlEqualTo("/api/v1/orchestrator/plan/plan_123/execute")) + // executePlan now uses /api/request with request_type: "execute-plan" (matches Go SDK) + stubFor(post(urlEqualTo("/api/request")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") - .withBody("{\"plan_id\":\"plan_123\",\"status\":\"completed\"}"))); + .withBody("{\"success\":true,\"result\":\"Plan executed successfully\"}"))); PlanResponse response = axonflow.executePlan("plan_123"); assertThat(response.getPlanId()).isEqualTo("plan_123"); assertThat(response.getStatus()).isEqualTo("completed"); + assertThat(response.getResult()).isEqualTo("Plan executed successfully"); + + // Verify correct request format + verify(postRequestedFor(urlEqualTo("/api/request")) + .withRequestBody(matchingJsonPath("$.request_type", equalTo("execute-plan"))) + .withRequestBody(matchingJsonPath("$.context.plan_id", equalTo("plan_123")))); } @Test