diff --git a/temporal-spring-ai/README.md b/temporal-spring-ai/README.md index 3fbc587a7..a19e96a4f 100644 --- a/temporal-spring-ai/README.md +++ b/temporal-spring-ai/README.md @@ -51,6 +51,23 @@ public String run(String goal) { } ``` +## Activity options and retry behavior + +`ActivityChatModel.forDefault()` / `forModel(name)` build the chat activity stub with sensible defaults: a 2-minute start-to-close timeout, 3 attempts, and `org.springframework.ai.retry.NonTransientAiException` + `java.lang.IllegalArgumentException` marked non-retryable so a bad API key or invalid prompt fails fast instead of churning through retries. + +When you need finer control — a specific task queue, heartbeats, priority, or a custom `RetryOptions` — pass an `ActivityOptions` directly: + +```java +ActivityChatModel chatModel = ActivityChatModel.forDefault( + ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) + .setTaskQueue("chat-heavy") + .build()); +``` + +`ActivityMcpClient.create()` / `create(ActivityOptions)` work the same way with a 30-second default timeout. + +The Temporal UI labels chat and MCP rows with a short Summary (`chat: `, `mcp: .`). `ActivityChatModel` and `ActivityMcpClient` are constructed only via these factories — there is no public constructor, so users can't accidentally end up in a code path that skips UI labels. Prompt text is deliberately not included in chat summaries to avoid leaking user input (which may contain PII, credentials, or other sensitive data) into workflow history and server logs. + ## Tool Types Tools passed to `defaultTools()` are handled based on their type: diff --git a/temporal-spring-ai/build.gradle b/temporal-spring-ai/build.gradle index c176ed3d8..12b66cc19 100644 --- a/temporal-spring-ai/build.gradle +++ b/temporal-spring-ai/build.gradle @@ -46,6 +46,9 @@ dependencies { testImplementation "org.mockito:mockito-core:${mockitoVersion}" testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.ai:spring-ai-rag' + // Needed only so tests can reference Spring AI's NonTransientAiException to + // verify the plugin's default retry classification. + testImplementation 'org.springframework.ai:spring-ai-retry' testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" testRuntimeOnly "org.junit.platform:junit-platform-launcher" diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java index 6a3051db7..be42468eb 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/chat/TemporalChatClient.java @@ -29,9 +29,7 @@ * @WorkflowInit * public MyWorkflowImpl() { * // Create the activity-backed chat model - * ChatModelActivity chatModelActivity = Workflow.newActivityStub( - * ChatModelActivity.class, activityOptions); - * ActivityChatModel activityChatModel = new ActivityChatModel(chatModelActivity); + * ActivityChatModel activityChatModel = ActivityChatModel.forDefault(); * * // Create tools * WeatherActivity weatherTool = Workflow.newActivityStub(WeatherActivity.class, opts); diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java index 0df9f99b4..d4a6af967 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/mcp/ActivityMcpClient.java @@ -5,6 +5,7 @@ import io.temporal.common.RetryOptions; import io.temporal.workflow.Workflow; import java.time.Duration; +import java.util.List; import java.util.Map; import javax.annotation.Nullable; @@ -48,61 +49,82 @@ public class ActivityMcpClient { /** Default maximum retry attempts for MCP activity calls. */ public static final int DEFAULT_MAX_ATTEMPTS = 3; - private final McpClientActivity activity; - @Nullable private final ActivityOptions baseOptions; - private Map serverCapabilities; - private Map clientInfo; - /** - * Creates a new ActivityMcpClient with the given activity stub. + * Error types that the default retry policy treats as non-retryable. {@link + * IllegalArgumentException} covers unknown-client-name lookups. Client-not-found is already + * thrown as an {@code ApplicationFailure} with {@code nonRetryable=true} and wins on its own. * - * @param activity the activity stub for MCP operations + *

Applied only to the factories that build {@link ActivityOptions} internally. When callers + * pass their own {@link ActivityOptions} via {@link #create(ActivityOptions)}, their {@link + * RetryOptions} are used verbatim. */ - public ActivityMcpClient(McpClientActivity activity) { - this(activity, null); - } + public static final List DEFAULT_NON_RETRYABLE_ERROR_TYPES = + List.of("java.lang.IllegalArgumentException"); - /** - * Creates a new ActivityMcpClient. When {@code baseOptions} is non-null, {@link #callTool(String, - * McpSchema.CallToolRequest, String)} rebuilds the activity stub with a per-call Summary on top - * of those options. When null, the caller supplied a pre-built stub whose options we don't know, - * so we call through it as-is and drop any requested summary. - */ - private ActivityMcpClient(McpClientActivity activity, @Nullable ActivityOptions baseOptions) { + private final McpClientActivity activity; + private final ActivityOptions baseOptions; + private Map serverCapabilities; + private Map clientInfo; + + /** Use one of the {@link #create()} / {@link #create(ActivityOptions)} factories. */ + private ActivityMcpClient(McpClientActivity activity, ActivityOptions baseOptions) { this.activity = activity; this.baseOptions = baseOptions; } /** - * Creates an ActivityMcpClient with default options. + * Creates an ActivityMcpClient with the plugin's default {@link ActivityOptions} (30-second + * start-to-close timeout, 3 attempts, {@link IllegalArgumentException} marked non-retryable). * *

Must be called from workflow code. * * @return a new ActivityMcpClient */ public static ActivityMcpClient create() { - return create(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + return create(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } /** - * Creates an ActivityMcpClient with custom options. + * Creates an ActivityMcpClient using the supplied {@link ActivityOptions}. Pass this when you + * need a specific task queue, heartbeat, priority, or custom {@link RetryOptions}. The provided + * options are used verbatim — the plugin does not augment the caller's {@link RetryOptions}. * *

Must be called from workflow code. * - * @param timeout the activity start-to-close timeout - * @param maxAttempts the maximum number of retry attempts + * @param options the activity options to use for each MCP call * @return a new ActivityMcpClient */ - public static ActivityMcpClient create(Duration timeout, int maxAttempts) { - ActivityOptions options = - ActivityOptions.newBuilder() - .setStartToCloseTimeout(timeout) - .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) - .build(); + public static ActivityMcpClient create(ActivityOptions options) { McpClientActivity activity = Workflow.newActivityStub(McpClientActivity.class, options); return new ActivityMcpClient(activity, options); } + /** + * Returns the plugin's default {@link ActivityOptions} for MCP calls. Useful as a starting point + * when you want to tweak a field without losing the sensible defaults: + * + *

{@code
+   * ActivityMcpClient.create(
+   *     ActivityOptions.newBuilder(ActivityMcpClient.defaultActivityOptions())
+   *         .setTaskQueue("mcp-heavy")
+   *         .build());
+   * }
+ */ + public static ActivityOptions defaultActivityOptions() { + return defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + } + + private static ActivityOptions defaultActivityOptions(Duration timeout, int maxAttempts) { + return ActivityOptions.newBuilder() + .setStartToCloseTimeout(timeout) + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(maxAttempts) + .setDoNotRetry(DEFAULT_NON_RETRYABLE_ERROR_TYPES.toArray(new String[0])) + .build()) + .build(); + } + /** * Gets the server capabilities for all connected MCP clients. * @@ -144,9 +166,7 @@ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRe /** * Calls a tool on a specific MCP client, attaching the given activity Summary to the scheduled - * activity so it renders meaningfully in the Temporal UI. Falls back to the base stub when no - * {@link ActivityOptions} are known (e.g. when this client was constructed from a user-supplied - * stub rather than one of the {@link #create} factories). + * activity so it renders meaningfully in the Temporal UI. * * @param clientName the name of the MCP client * @param request the tool call request @@ -155,17 +175,14 @@ public McpSchema.CallToolResult callTool(String clientName, McpSchema.CallToolRe */ public McpSchema.CallToolResult callTool( String clientName, McpSchema.CallToolRequest request, @Nullable String summary) { - // Overlay the summary onto a fresh stub only when both a summary is requested AND we have - // a recipe to rebuild the stub from (baseOptions). If either is missing, fall through to - // the cached activity — it already has baseOptions baked in if we knew them at construction. - if (summary != null && baseOptions != null) { - McpClientActivity stub = - Workflow.newActivityStub( - McpClientActivity.class, - ActivityOptions.newBuilder(baseOptions).setSummary(summary).build()); - return stub.callTool(clientName, request); + if (summary == null) { + return activity.callTool(clientName, request); } - return activity.callTool(clientName, request); + McpClientActivity stub = + Workflow.newActivityStub( + McpClientActivity.class, + ActivityOptions.newBuilder(baseOptions).setSummary(summary).build()); + return stub.callTool(clientName, request); } /** diff --git a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java index 284d471c1..5a86f03ab 100644 --- a/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java +++ b/temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java @@ -37,25 +37,7 @@ * *

Usage

* - *

For a single chat model, use the constructor directly: - * - *

{@code
- * @WorkflowInit
- * public MyWorkflowImpl() {
- *     ChatModelActivity chatModelActivity = Workflow.newActivityStub(
- *         ChatModelActivity.class,
- *         ActivityOptions.newBuilder()
- *             .setStartToCloseTimeout(Duration.ofMinutes(2))
- *             .build());
- *
- *     ActivityChatModel chatModel = new ActivityChatModel(chatModelActivity);
- *     this.chatClient = ChatClient.builder(chatModel).build();
- * }
- * }
- * - *

Multiple Chat Models

- * - *

For applications with multiple chat models, use the static factory methods: + *

Build instances via the static factory methods: * *

{@code
  * @WorkflowInit
@@ -84,42 +66,31 @@ public class ActivityChatModel implements ChatModel {
   /** Default maximum retry attempts for chat model activity calls. */
   public static final int DEFAULT_MAX_ATTEMPTS = 3;
 
-  private final ChatModelActivity chatModelActivity;
-  @Nullable private final String modelName;
-  @Nullable private final ActivityOptions baseOptions;
-  private final ToolCallingManager toolCallingManager;
-  private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate;
-
   /**
-   * Creates a new ActivityChatModel that uses the default chat model.
+   * Error types that the default retry policy treats as non-retryable. These represent clearly
+   * permanent failures — a bad API key, an invalid prompt, an unknown model name — where retrying
+   * wastes time and money.
    *
-   * @param chatModelActivity the activity stub for calling the chat model
+   * 

Applied only to the factories that build {@link ActivityOptions} internally. When callers + * pass their own {@link ActivityOptions} (via {@link #forDefault(ActivityOptions)} or {@link + * #forModel(String, ActivityOptions)}), their {@link RetryOptions} are used verbatim. */ - public ActivityChatModel(ChatModelActivity chatModelActivity) { - this(chatModelActivity, null, null); - } + public static final List DEFAULT_NON_RETRYABLE_ERROR_TYPES = + List.of( + "org.springframework.ai.retry.NonTransientAiException", + "java.lang.IllegalArgumentException"); - /** - * Creates a new ActivityChatModel that uses a specific chat model. - * - * @param chatModelActivity the activity stub for calling the chat model - * @param modelName the name of the chat model to use, or null for default - */ - public ActivityChatModel(ChatModelActivity chatModelActivity, @Nullable String modelName) { - this(chatModelActivity, modelName, null); - } + private final ChatModelActivity chatModelActivity; + @Nullable private final String modelName; + private final ActivityOptions baseOptions; + private final ToolCallingManager toolCallingManager; + private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; - /** - * Internal constructor used by {@link #forModel(String, Duration, int)} and friends. When {@code - * baseOptions} is non-null, each call rebuilds the activity stub with a per-call Summary on top - * of those options so the Temporal UI can label the chat activity meaningfully. When null, the - * caller supplied a pre-built stub whose options we don't know, so we call through it as-is - * without a summary. - */ + /** Use one of the {@link #forDefault()} / {@link #forModel(String)} factories. */ private ActivityChatModel( ChatModelActivity chatModelActivity, @Nullable String modelName, - @Nullable ActivityOptions baseOptions) { + ActivityOptions baseOptions) { this.chatModelActivity = chatModelActivity; this.modelName = modelName; this.baseOptions = baseOptions; @@ -128,24 +99,36 @@ private ActivityChatModel( } /** - * Creates an ActivityChatModel for the default chat model. - * - *

This factory method creates the activity stub internally with default timeout and retry - * options. + * Creates an ActivityChatModel for the default chat model with the plugin's default {@link + * ActivityOptions} (2-minute start-to-close timeout, 3 attempts, clearly permanent AI errors + * marked non-retryable). * *

Must be called from workflow code. * * @return an ActivityChatModel for the default chat model */ public static ActivityChatModel forDefault() { - return forModel(null, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + return forDefault(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } /** - * Creates an ActivityChatModel for a specific chat model by bean name. + * Creates an ActivityChatModel for the default chat model using the supplied {@link + * ActivityOptions}. Pass this when you need to customize any field on the chat activity stub — + * timeouts, retry policy, task queue, heartbeat, priority, etc. Build on top of {@link + * #defaultActivityOptions()} to inherit the plugin's non-retryable-AI-error classification. + * + *

Must be called from workflow code. * - *

This factory method creates the activity stub internally with default timeout and retry - * options. + * @param options the activity options to use for each chat call + * @return an ActivityChatModel for the default chat model + */ + public static ActivityChatModel forDefault(ActivityOptions options) { + return forModel(null, options); + } + + /** + * Creates an ActivityChatModel for a specific chat model by bean name with the plugin's default + * {@link ActivityOptions}. * *

Must be called from workflow code. * @@ -154,30 +137,53 @@ public static ActivityChatModel forDefault() { * @throws IllegalArgumentException if no model with that name exists (at activity runtime) */ public static ActivityChatModel forModel(String modelName) { - return forModel(modelName, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS)); } /** - * Creates an ActivityChatModel for a specific chat model with custom options. + * Creates an ActivityChatModel for a specific chat model using the supplied {@link + * ActivityOptions}. The provided options are used verbatim — the plugin does not augment the + * caller's {@link RetryOptions} or merge in its defaults. If you want the plugin-default + * non-retryable error classification, copy {@link #DEFAULT_NON_RETRYABLE_ERROR_TYPES} into your + * own {@link RetryOptions}. * *

Must be called from workflow code. * * @param modelName the bean name of the chat model, or null for default - * @param timeout the activity start-to-close timeout - * @param maxAttempts the maximum number of retry attempts + * @param options the activity options to use for each chat call * @return an ActivityChatModel for the specified chat model */ - public static ActivityChatModel forModel( - @Nullable String modelName, Duration timeout, int maxAttempts) { - ActivityOptions options = - ActivityOptions.newBuilder() - .setStartToCloseTimeout(timeout) - .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(maxAttempts).build()) - .build(); + public static ActivityChatModel forModel(@Nullable String modelName, ActivityOptions options) { ChatModelActivity activity = Workflow.newActivityStub(ChatModelActivity.class, options); return new ActivityChatModel(activity, modelName, options); } + /** + * Returns the plugin's default {@link ActivityOptions} for chat model calls. Useful as a starting + * point when you want to customize one or two fields without losing the sensible defaults: + * + *

{@code
+   * ActivityChatModel.forDefault(
+   *     ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
+   *         .setTaskQueue("chat-heavy")
+   *         .build());
+   * }
+ */ + public static ActivityOptions defaultActivityOptions() { + return defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS); + } + + private static ActivityOptions defaultActivityOptions(Duration timeout, int maxAttempts) { + return ActivityOptions.newBuilder() + .setStartToCloseTimeout(timeout) + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(maxAttempts) + .setDoNotRetry(DEFAULT_NON_RETRYABLE_ERROR_TYPES.toArray(new String[0])) + .build()) + .build(); + } + /** * Returns the name of the chat model this instance uses, or null if it uses the plugin default * (the {@code @Primary} {@code ChatModel} bean or the first one registered). @@ -238,9 +244,6 @@ private ChatResponse internalCall(Prompt prompt) { } private ChatModelActivity stubForCall(Prompt prompt) { - if (baseOptions == null) { - return chatModelActivity; - } ActivityOptions withSummary = ActivityOptions.newBuilder(baseOptions).setSummary(buildSummary()).build(); return Workflow.newActivityStub(ChatModelActivity.class, withSummary); diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java new file mode 100644 index 000000000..ce602de16 --- /dev/null +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivityOptionsAndRetryTest.java @@ -0,0 +1,221 @@ +package io.temporal.springai; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.activity.ActivityOptions; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.ChatModelActivityImpl; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.retry.NonTransientAiException; + +/** + * Verifies the retry-classification and custom-{@link ActivityOptions} surface added by the + * retry-and-options branch. + */ +class ActivityOptionsAndRetryTest { + + private static final String TASK_QUEUE = "test-spring-ai-retry"; + private static final String CUSTOM_TASK_QUEUE = "test-spring-ai-custom-queue"; + + private TestWorkflowEnvironment testEnv; + private WorkflowClient client; + + @BeforeEach + void setUp() { + testEnv = TestWorkflowEnvironment.newInstance(); + client = testEnv.getWorkflowClient(); + } + + @AfterEach + void tearDown() { + testEnv.close(); + } + + @Test + void defaultFactory_marksNonTransientAiExceptionNonRetryable() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(ChatWorkflowImpl.class); + CountingChatModel model = new CountingChatModel(new NonTransientAiException("bad key")); + worker.registerActivitiesImplementations(new ChatModelActivityImpl(model)); + testEnv.start(); + + ChatWorkflow workflow = + client.newWorkflowStub( + ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + + try { + workflow.chat("hello"); + } catch (WorkflowException expected) { + // Expected — the workflow propagates the non-retryable failure. + } + + assertEquals( + 1, + model.callCount.get(), + "NonTransientAiException must not be retried by the default policy"); + } + + @Test + void defaultFactory_retriesTransientExceptions() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(ChatWorkflowImpl.class); + // First 2 calls throw a plain RuntimeException (transient to Temporal); 3rd succeeds. + CountingChatModel model = + new CountingChatModel(new RuntimeException("flaky"), new RuntimeException("flaky"), null); + worker.registerActivitiesImplementations(new ChatModelActivityImpl(model)); + testEnv.start(); + + ChatWorkflow workflow = + client.newWorkflowStub( + ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + + String result = workflow.chat("hello"); + assertEquals("ok", result); + assertEquals( + 3, model.callCount.get(), "Transient RuntimeException should be retried up to 3 attempts"); + } + + @Test + void customOptions_landOnScheduledActivity() { + // Worker on the default task queue runs the workflow. A second worker on the *custom* task + // queue runs the chat activity — the chat stub's task queue override is what routes there. + Worker workflowWorker = testEnv.newWorker(TASK_QUEUE); + workflowWorker.registerWorkflowImplementationTypes(CustomQueueChatWorkflowImpl.class); + + Worker activityWorker = testEnv.newWorker(CUSTOM_TASK_QUEUE); + activityWorker.registerActivitiesImplementations( + new ChatModelActivityImpl(new FixedChatModel("routed"))); + testEnv.start(); + + ChatWorkflow workflow = + client.newWorkflowStub( + ChatWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + assertEquals("routed", workflow.chat("hi")); + + String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); + List events = client.fetchHistory(workflowId).getHistory().getEventsList(); + String scheduledOn = findScheduledActivityTaskQueue(events); + assertTrue( + CUSTOM_TASK_QUEUE.equals(scheduledOn), + "callChatModel activity should have been scheduled on the custom task queue, but was: " + + scheduledOn); + } + + private static String findScheduledActivityTaskQueue(List events) { + for (HistoryEvent e : events) { + if (!e.hasActivityTaskScheduledEventAttributes()) continue; + var attrs = e.getActivityTaskScheduledEventAttributes(); + if ("CallChatModel".equals(attrs.getActivityType().getName())) { + return attrs.getTaskQueue().getName(); + } + } + return null; + } + + @WorkflowInterface + public interface ChatWorkflow { + @WorkflowMethod + String chat(String message); + } + + public static class ChatWorkflowImpl implements ChatWorkflow { + @Override + public String chat(String message) { + ActivityChatModel model = ActivityChatModel.forDefault(); + ChatClient chat = TemporalChatClient.builder(model).build(); + return chat.prompt().user(message).call().content(); + } + } + + public static class CustomQueueChatWorkflowImpl implements ChatWorkflow { + @Override + public String chat(String message) { + ActivityOptions opts = + ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions()) + .setTaskQueue(CUSTOM_TASK_QUEUE) + .setRetryOptions( + RetryOptions.newBuilder() + .setMaximumAttempts(1) + .build()) // keep this test fast — don't retry on surprise failures + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build(); + ActivityChatModel model = ActivityChatModel.forDefault(opts); + ChatClient chat = TemporalChatClient.builder(model).build(); + return chat.prompt().user(message).call().content(); + } + } + + /** + * ChatModel whose behavior is scripted by a queue of outcomes. Each outcome is either a Throwable + * (thrown on that call) or null (return "ok"). Extra calls after the queue empties replay the + * last outcome. + */ + private static class CountingChatModel implements ChatModel { + final AtomicInteger callCount = new AtomicInteger(0); + private final Object[] outcomes; + + CountingChatModel(Object... outcomes) { + this.outcomes = outcomes; + } + + @Override + public ChatResponse call(Prompt prompt) { + int i = callCount.getAndIncrement(); + Object outcome = outcomes[Math.min(i, outcomes.length - 1)]; + if (outcome instanceof RuntimeException re) { + throw re; + } + return ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage("ok")))) + .build(); + } + + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + throw new UnsupportedOperationException(); + } + } + + private static class FixedChatModel implements ChatModel { + private final String content; + + FixedChatModel(String content) { + this.content = content; + } + + @Override + public ChatResponse call(Prompt prompt) { + return ChatResponse.builder() + .generations(List.of(new Generation(new AssistantMessage(content)))) + .build(); + } + + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java index bf0efebba..a7d11c070 100644 --- a/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java +++ b/temporal-spring-ai/src/test/java/io/temporal/springai/ActivitySummaryTest.java @@ -80,6 +80,35 @@ void chatActivity_carriesModelOnlySummary_neverLeaksUserPrompt() { "Summary must not include user prompt content, got: " + summary); } + /** + * Regression guard: `forDefault(ActivityOptions)` must thread its options through the + * summary-bearing constructor so custom-options users still get UI labels. + */ + @Test + void chatActivity_customOptions_stillCarriesSummary() { + Worker worker = testEnv.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(CustomOptionsChatWorkflowImpl.class); + worker.registerActivitiesImplementations( + new ChatModelActivityImpl(new StubChatModel("Bonjour!"))); + testEnv.start(); + + SummaryTestWorkflow workflow = + client.newWorkflowStub( + SummaryTestWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + workflow.chat("Parlez-vous francais?"); + + String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); + List events = client.fetchHistory(workflowId).getHistory().getEventsList(); + + String summary = findChatActivitySummary(events); + assertNotNull( + summary, "forDefault(ActivityOptions) should still attach a Summary to the chat activity"); + assertTrue( + summary.startsWith("chat: default"), + "Summary should start with 'chat: default' but was: " + summary); + } + private static String findChatActivitySummary(List events) { for (HistoryEvent event : events) { if (!event.hasActivityTaskScheduledEventAttributes()) { @@ -114,6 +143,20 @@ public String chat(String message) { } } + public static class CustomOptionsChatWorkflowImpl implements SummaryTestWorkflow { + @Override + public String chat(String message) { + io.temporal.activity.ActivityOptions options = + io.temporal.activity.ActivityOptions.newBuilder( + ActivityChatModel.defaultActivityOptions()) + .setStartToCloseTimeout(java.time.Duration.ofMinutes(5)) + .build(); + ActivityChatModel chatModel = ActivityChatModel.forDefault(options); + ChatClient chatClient = TemporalChatClient.builder(chatModel).build(); + return chatClient.prompt().user(message).call().content(); + } + } + private static class StubChatModel implements ChatModel { private final String response;