Token budget must stay below {@code max_tokens} (8192 for {@code claude-sonnet-4-5}).
+ * Reasoning arrives as multiple {@link com.embabel.common.core.streaming.StreamingEvent.Thinking}
+ * events — one per line, not a single block.
+ */
+ @Nested
+ class StreamingWithThinkingNoTools {
+
+ @Test
+ void whenStreamingWithThinking_thenReceivesReasoningAndRecommendation() {
+ // budget_tokens must be < max_tokens (8192). This also enables Reasoning events are emitted only from the final LLM iteration — after all tool calls
+ * complete. Intermediate reasoning (the model's thinking while deciding which tools to call)
+ * is not surfaced to the subscriber, as each Spring AI-managed tool-loop iteration starts a new stream.
+ */
+ @Test
+ void whenStreamingWithThinkingAndTooling_thenReceivesRecommendationAndReasoning() {
+ PromptRunner runner = ai.withDefaultLlm()
+ .withToolObject(new ParkingTooling())
+ .withToolCallInspectors(new ToolCallLoggingInspector(LogLevel.INFO, logger));
+ assertTrue(runner.supportsStreaming(), "Default LLM must support streaming");
+
+ new StreamingPromptRunnerBuilder(runner)
+ .streaming()
+ .withPrompt(TOOLING_PARKING_PROMPT)
+ .createObjectStreamWithThinking(ParkingRecommendation.class)
+ .timeout(Duration.ofSeconds(120))
+ .doOnNext(event -> {
+ if (event.isObject()) {
+ ParkingRecommendation rec = event.getObject();
+ if (rec != null) {
+ logger.info("Received recommendation: option={}, cost={}, summary={}",
+ rec.chosenOption(), rec.estimatedTotalCost(), rec.summary());
+ }
+ } else if (event.isThinking()) {
+ logger.info("Received reasoning: {}", event.getThinking());
+ }
+ })
+ .blockLast(Duration.ofSeconds(240));
+ }
+ }
+}
diff --git a/embabel-modules/pom.xml b/embabel-modules/pom.xml
index 258f429366b5..241720457454 100644
--- a/embabel-modules/pom.xml
+++ b/embabel-modules/pom.xml
@@ -18,6 +18,7 @@