diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ChatResponse.java b/agentscope-core/src/main/java/io/agentscope/core/model/ChatResponse.java index 2e0165b61..256141c5b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ChatResponse.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ChatResponse.java @@ -101,6 +101,17 @@ public String getFinishReason() { return finishReason; } + /** + * Creates a new instance of ChatResponse with the specified ID, + * copying all other fields from this instance. + * + * @param newId the new identifier + * @return a new ChatResponse instance + */ + public ChatResponse withId(String newId) { + return new ChatResponse(newId, this.content, this.usage, this.metadata, this.finishReason); + } + /** * Creates a new builder for ChatResponse. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/OllamaChatModel.java b/agentscope-core/src/main/java/io/agentscope/core/model/OllamaChatModel.java index 73d3d27ce..c9ff2874d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/OllamaChatModel.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/OllamaChatModel.java @@ -187,8 +187,25 @@ private Flux streamWithHttpClient( Flux responseFlux; if (stream) { responseFlux = - httpClient.stream(request) - .map(response -> formatter.parseResponse(response, Instant.now())); + Flux.defer( + () -> { + final String[] finalStableId = new String[1]; + + return httpClient.stream(request) + .map( + response -> { + ChatResponse parsedResponse = + formatter.parseResponse( + response, Instant.now()); + + if (finalStableId[0] == null) { + finalStableId[0] = parsedResponse.getId(); + return parsedResponse; + } + + return parsedResponse.withId(finalStableId[0]); + }); + }); } else { responseFlux = Flux.defer( diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/OllamaChatModelTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/OllamaChatModelTest.java index 20a839305..f8e22cbac 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/model/OllamaChatModelTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/model/OllamaChatModelTest.java @@ -969,4 +969,59 @@ void testRealQwen3Thinking() { assertEquals("POST", request.getMethod()); assertTrue(request.getUrl().endsWith("/api/chat")); } + + @Test + @DisplayName("Should populate and maintain stable ID for streaming responses") + void testStreamStableMessageId() { + // no ID field is included, simulating real Ollama behavior + String part1 = + "{\"model\":\"" + + TEST_MODEL_NAME + + "\",\"message\":{\"role\":\"assistant\",\"content\":\"Thinking...\"},\"done\":false}"; + String part2 = + "{\"model\":\"" + + TEST_MODEL_NAME + + "\",\"message\":{\"role\":\"assistant\",\"content\":\"" + + " Hello\"},\"done\":false}"; + String part3 = + "{\"model\":\"" + + TEST_MODEL_NAME + + "\",\"message\":{\"role\":\"assistant\",\"content\":\"" + + " World\"},\"done\":false}"; + String part4 = + "{\"model\":\"" + TEST_MODEL_NAME + "\",\"done\":true,\"total_duration\":100}"; + + when(httpTransport.stream(any(HttpRequest.class))) + .thenReturn(Flux.just(part1, part2, part3, part4)); + + GenerateOptions genOptions = OllamaOptions.builder().build().toGenerateOptions(); + + List responses = + model.stream( + List.of(Msg.builder().role(MsgRole.USER).textContent("Hi").build()), + null, + genOptions) + .collectList() + .block(); + + assertNotNull(responses); + assertFalse(responses.isEmpty(), "Stream responses should not be empty"); + + // collect all the IDs for assertion + List collectedIds = new ArrayList<>(); + for (int i = 0; i < responses.size(); i++) { + String id = responses.get(i).getId(); + + assertNotNull(id); + collectedIds.add(id); + } + + // verify the stability of the ID (Stable ID) + long uniqueIdCount = collectedIds.stream().distinct().count(); + assertEquals( + 1, + uniqueIdCount, + "All stream chunks for a single response must share the same stable ID! Found IDs: " + + collectedIds); + } }