diff --git a/core/src/main/java/com/google/adk/plugins/PendingArtifactDelta.java b/core/src/main/java/com/google/adk/plugins/PendingArtifactDelta.java new file mode 100644 index 000000000..146da4bca --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/PendingArtifactDelta.java @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.plugins; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.common.collect.ImmutableMap; +import java.util.Map; + +/** + * Carries artifact versions from {@code onUserMessageCallback}, which has no {@link + * com.google.adk.events.EventActions} to write to, across to {@code beforeAgentCallback}, which + * does. + * + *

The hand-off rides on the session state under a {@link + * com.google.adk.sessions.State#TEMP_PREFIX} key, so {@code BaseSessionService.appendEvent} skips + * it when applying the state delta and the bookkeeping never reaches persisted session state. The + * key also carries the invocation id, so concurrent invocations sharing one session cannot read + * each other's pending versions. + * + *

Draining overwrites the entry with an empty map rather than removing it. {@code State.remove} + * would write the {@code State.REMOVED} sentinel into the event's state delta, which does not + * survive a JSON round trip. + */ +final class PendingArtifactDelta { + + private static final String KEY = "temp:%s:pending_delta:%s"; + + private PendingArtifactDelta() {} + + /** Stores the versions produced during this invocation. */ + static void stash( + InvocationContext invocationContext, String pluginName, ImmutableMap delta) { + invocationContext + .session() + .state() + .put(key(pluginName, invocationContext.invocationId()), delta); + } + + /** + * Returns the stored versions and clears them, so only the first agent callback reports them. + * + *

The write is guarded on there being something to clear, and the guard is load-bearing: a + * write through {@link CallbackContext#state()} sets the state delta, and {@code BaseAgent} emits + * an event for any before-agent callback that left one. Clearing unconditionally would therefore + * emit an event carrying an empty artifact delta for every agent after the first. + */ + static ImmutableMap drain(CallbackContext callbackContext, String pluginName) { + String key = key(pluginName, callbackContext.invocationId()); + ImmutableMap pending = read(callbackContext.state().get(key)); + if (!pending.isEmpty()) { + callbackContext.state().put(key, ImmutableMap.of()); + } + return pending; + } + + /** + * Discards versions left behind by an invocation that ended before any agent ran, which happens + * when a {@code beforeRunCallback} on another plugin halts the run. There is no {@link + * com.google.adk.events.EventActions} to report them on at that point, so they are dropped rather + * than returned. + * + *

Only writes when something is actually stashed: an unconditional write would add an entry + * for every invocation that uploaded nothing, which is more state noise than the leak it + * prevents. + */ + static void clear(InvocationContext invocationContext, String pluginName) { + Map state = invocationContext.session().state(); + String key = key(pluginName, invocationContext.invocationId()); + if (!read(state.get(key)).isEmpty()) { + state.put(key, ImmutableMap.of()); + } + } + + private static String key(String pluginName, String invocationId) { + return KEY.formatted(pluginName, invocationId); + } + + private static ImmutableMap read(Object stashed) { + if (!(stashed instanceof Map entries)) { + return ImmutableMap.of(); + } + return entries.entrySet().stream() + .filter(PendingArtifactDelta::isVersionEntry) + .collect( + toImmutableMap(entry -> (String) entry.getKey(), entry -> (Integer) entry.getValue())); + } + + /** State values survive a JSON round trip untyped, so each entry is checked before it is kept. */ + private static boolean isVersionEntry(Map.Entry entry) { + return entry.getKey() instanceof String && entry.getValue() instanceof Integer; + } +} diff --git a/core/src/main/java/com/google/adk/plugins/SaveFilesAsArtifactsPlugin.java b/core/src/main/java/com/google/adk/plugins/SaveFilesAsArtifactsPlugin.java new file mode 100644 index 000000000..98e7d70ab --- /dev/null +++ b/core/src/main/java/com/google/adk/plugins/SaveFilesAsArtifactsPlugin.java @@ -0,0 +1,218 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.plugins; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Blob; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Plugin that saves files embedded in user messages as artifacts. + * + *

This allows users to upload files in the chat experience and have those files available to the + * agent within the current session. Each {@code inlineData} part of the incoming user message is + * written to the configured {@link com.google.adk.artifacts.BaseArtifactService} and replaced, in + * the message that reaches the model, by a short text placeholder naming the artifact. The bytes + * themselves are therefore stored once and not resent on every turn. + * + *

The artifact name is taken from {@link Blob#displayName()} when present, so an uploaded {@code + * report.pdf} is stored under that name. When the blob carries no display name, a name is generated + * from the invocation id and the part index. Artifacts with the same name overwrite each other, and + * a name prefixed with {@code user:} is scoped to the user rather than the session. + * + *

Add the {@code load_artifacts} tool to the agent, or load the artifacts from your own tool, to + * let the model read the stored bytes back. + * + *

Register it on the runner: + * + *

{@code
+ * Runner runner =
+ *     Runner.builder()
+ *         .agent(agent)
+ *         .appName("my-app")
+ *         .artifactService(new InMemoryArtifactService())
+ *         .sessionService(new InMemorySessionService())
+ *         .plugins(new SaveFilesAsArtifactsPlugin())
+ *         .build();
+ * }
+ * + *

The plugin is a no-op when no artifact service is configured on the runner. + */ +public class SaveFilesAsArtifactsPlugin extends BasePlugin { + + /** Name used when the plugin is constructed without an explicit one. Matches adk-python's. */ + public static final String DEFAULT_NAME = "save_files_as_artifacts_plugin"; + + private static final Logger logger = LoggerFactory.getLogger(SaveFilesAsArtifactsPlugin.class); + + private static final String GENERATED_FILE_NAME = "artifact_%s_%d"; + private static final String PLACEHOLDER_TEXT = "[Uploaded Artifact: \"%s\"]"; + + public SaveFilesAsArtifactsPlugin() { + this(DEFAULT_NAME); + } + + public SaveFilesAsArtifactsPlugin(String name) { + super(name); + } + + @Override + public Maybe onUserMessageCallback( + InvocationContext invocationContext, Content userMessage) { + if (invocationContext.artifactService() == null) { + logger.warn("No artifact service is configured; plugin '{}' is disabled.", getName()); + return Maybe.empty(); + } + ImmutableList parts = + ImmutableList.copyOf(userMessage.parts().orElse(ImmutableList.of())); + if (parts.stream().noneMatch(SaveFilesAsArtifactsPlugin::hasInlineData)) { + return Maybe.empty(); + } + return Flowable.range(0, parts.size()) + .concatMapSingle(index -> savePart(invocationContext, parts.get(index), index)) + .collect(toImmutableList()) + .map(results -> rebuildMessage(invocationContext, userMessage, results)) + .filter(Optional::isPresent) + .map(Optional::get); + } + + /** + * Records the artifact versions stashed by {@link #onUserMessageCallback} on the first event + * actions of the invocation. {@code onUserMessageCallback} runs before any {@link + * com.google.adk.events.EventActions} exists, so the versions cannot be reported from there. + * + *

The reporting is a side effect and the return is always empty, deliberately: {@code + * PluginManager} stops at the first plugin that returns a value, so returning content here would + * both skip every later plugin's callback and halt the agent. + */ + @Override + public Maybe beforeAgentCallback(BaseAgent agent, CallbackContext callbackContext) { + PendingArtifactDelta.drain(callbackContext, getName()) + .forEach(callbackContext.eventActions().artifactDelta()::put); + return Maybe.empty(); + } + + /** + * Discards a stash that {@link #beforeAgentCallback} never got to report, which happens when a + * {@code beforeRunCallback} on another plugin halts the invocation before any agent runs. + * + *

This does not cover an invocation that fails in that same window: {@code + * afterRunCallback} only runs after successful completion, and adk-java has no agent/run error + * callback to clean up from — see #1316. A failed upload therefore + * leaves one inert entry in the in-memory session state; it is {@code temp:}-prefixed so it never + * reaches persisted state, and invocation-scoped so no later invocation can read it. + */ + @Override + public Completable afterRunCallback(InvocationContext invocationContext) { + PendingArtifactDelta.clear(invocationContext, getName()); + return Completable.complete(); + } + + /** Saves one part if it carries inline data, leaving every other part untouched. */ + private Single savePart(InvocationContext invocationContext, Part part, int index) { + if (!hasInlineData(part)) { + return Single.just(SavedPart.unchanged(part)); + } + String fileName = resolveFileName(invocationContext, part, index); + return invocationContext + .artifactService() + .saveArtifact( + invocationContext.appName(), + invocationContext.userId(), + invocationContext.session().id(), + fileName, + part) + .map(version -> SavedPart.saved(placeholderFor(fileName), fileName, version)) + .onErrorReturn(error -> keepOriginal(part, fileName, error)); + } + + /** A failed save must not fail the invocation: the original part is passed through unchanged. */ + private SavedPart keepOriginal(Part part, String fileName, Throwable error) { + logger.error("Failed to save artifact '{}'; keeping the original part.", fileName, error); + return SavedPart.unchanged(part); + } + + /** Returns the rewritten message, or empty when no part was actually offloaded. */ + private Optional rebuildMessage( + InvocationContext invocationContext, Content userMessage, List results) { + ImmutableMap delta = toArtifactDelta(results); + if (delta.isEmpty()) { + return Optional.empty(); + } + PendingArtifactDelta.stash(invocationContext, getName(), delta); + ImmutableList parts = results.stream().map(SavedPart::part).collect(toImmutableList()); + return Optional.of(userMessage.toBuilder().parts(parts).build()); + } + + private static ImmutableMap toArtifactDelta(List results) { + return results.stream() + .filter(SavedPart::isSaved) + .collect( + toImmutableMap(SavedPart::savedFileName, SavedPart::version, (older, newer) -> newer)); + } + + private static String resolveFileName(InvocationContext invocationContext, Part part, int index) { + return part.inlineData() + .flatMap(Blob::displayName) + .filter(displayName -> !displayName.isEmpty()) + .orElseGet(() -> GENERATED_FILE_NAME.formatted(invocationContext.invocationId(), index)); + } + + private static Part placeholderFor(String fileName) { + return Part.fromText(PLACEHOLDER_TEXT.formatted(fileName)); + } + + private static boolean hasInlineData(Part part) { + return part.inlineData().isPresent(); + } + + /** One input part after the offload attempt: either untouched, or replaced by a placeholder. */ + private record SavedPart(Part part, Optional fileName, int version) { + + static SavedPart unchanged(Part part) { + return new SavedPart(part, Optional.empty(), 0); + } + + static SavedPart saved(Part placeholder, String fileName, int version) { + return new SavedPart(placeholder, Optional.of(fileName), version); + } + + boolean isSaved() { + return fileName.isPresent(); + } + + String savedFileName() { + return fileName.orElseThrow(); + } + } +} diff --git a/core/src/test/java/com/google/adk/plugins/PendingArtifactDeltaTest.java b/core/src/test/java/com/google/adk/plugins/PendingArtifactDeltaTest.java new file mode 100644 index 000000000..42bf8b1b1 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/PendingArtifactDeltaTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.plugins; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; + +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.adk.sessions.Session; +import com.google.adk.sessions.State; +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** + * Tests for {@link PendingArtifactDelta}, the {@code onUserMessageCallback} to {@code + * beforeAgentCallback} hand-off. + * + *

Exercised directly rather than only through {@link SaveFilesAsArtifactsPlugin}, because the + * rules it enforces are its own: the {@code temp:} key convention, invocation scoping, clearing by + * overwrite, and reading back state that carries no type information. + */ +@RunWith(JUnit4.class) +public class PendingArtifactDeltaTest { + + private static final String PLUGIN_NAME = "test_plugin"; + private static final String INVOCATION_ID = "e-1234"; + private static final String OTHER_INVOCATION_ID = "e-5678"; + private static final String KEY_FORMAT = "temp:%s:pending_delta:%s"; + private static final String KEY = KEY_FORMAT.formatted(PLUGIN_NAME, INVOCATION_ID); + private static final String FILE_NAME = "report.pdf"; + private static final ImmutableMap DELTA = ImmutableMap.of(FILE_NAME, 3); + + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock private InvocationContext mockInvocationContext; + @Mock private CallbackContext mockCallbackContext; + + private final State state = new State(new ConcurrentHashMap<>()); + private final Session session = Session.builder("test_session").state(state).build(); + + @Before + public void setUp() { + state.clear(); + when(mockInvocationContext.invocationId()).thenReturn(INVOCATION_ID); + when(mockInvocationContext.session()).thenReturn(session); + when(mockCallbackContext.invocationId()).thenReturn(INVOCATION_ID); + when(mockCallbackContext.state()).thenReturn(state); + } + + @Test + public void stash_writesUnderATempPrefixedInvocationScopedKey() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + + assertThat(state).containsKey(KEY); + assertThat(KEY).startsWith(State.TEMP_PREFIX); + } + + @Test + public void drain_returnsWhatWasStashed() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)) + .containsExactly(FILE_NAME, 3); + } + + @Test + public void drainTwice_returnsEmptyTheSecondTime() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)).isEmpty(); + } + + @Test + public void drain_clearsByOverwritingRatherThanRemoving() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + + PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME); + + assertThat(state).containsEntry(KEY, ImmutableMap.of()); + } + + /** + * Each agent's callback gets a {@link State} with its own fresh delta, and {@code BaseAgent} + * emits an event for any before-agent callback that leaves one. So draining an already-drained + * stash must not write at all — otherwise every agent after the first emits an event carrying an + * empty artifact delta. This is what the {@code isEmpty} guard in {@code drain} buys. + */ + @Test + public void secondDrain_leavesNoStateDelta() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME); + + State laterCallbackState = new State(state, new ConcurrentHashMap<>()); + when(mockCallbackContext.state()).thenReturn(laterCallbackState); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)).isEmpty(); + assertThat(laterCallbackState.hasDelta()).isFalse(); + } + + @Test + public void drain_withNothingStashed_addsNoStateEntry() { + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)).isEmpty(); + + assertThat(state).doesNotContainKey(KEY); + } + + @Test + public void clear_emptiesAStashThatWasNeverDrained() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + + PendingArtifactDelta.clear(mockInvocationContext, PLUGIN_NAME); + + assertThat(state).containsEntry(KEY, ImmutableMap.of()); + } + + @Test + public void clear_withNothingStashed_addsNoStateEntry() { + PendingArtifactDelta.clear(mockInvocationContext, PLUGIN_NAME); + + assertThat(state).doesNotContainKey(KEY); + } + + @Test + public void anotherInvocationsStash_isNotVisible() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + when(mockCallbackContext.invocationId()).thenReturn(OTHER_INVOCATION_ID); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)).isEmpty(); + assertThat(state).containsEntry(KEY, DELTA); + } + + @Test + public void anotherPluginsStash_isNotVisible() { + PendingArtifactDelta.stash(mockInvocationContext, PLUGIN_NAME, DELTA); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, "other_plugin")).isEmpty(); + } + + // --- reading back untyped state --------------------------------------------------------------- + + @Test + public void valueThatIsNotAMap_isIgnored() { + state.put(KEY, "not a map"); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)).isEmpty(); + } + + @Test + public void entriesWithANonIntegerVersion_areDropped() { + state.put(KEY, Map.of(FILE_NAME, "3", "chart.png", 7)); + + assertThat(PendingArtifactDelta.drain(mockCallbackContext, PLUGIN_NAME)) + .containsExactly("chart.png", 7); + } +} diff --git a/core/src/test/java/com/google/adk/plugins/SaveFilesAsArtifactsPluginTest.java b/core/src/test/java/com/google/adk/plugins/SaveFilesAsArtifactsPluginTest.java new file mode 100644 index 000000000..5bc50b439 --- /dev/null +++ b/core/src/test/java/com/google/adk/plugins/SaveFilesAsArtifactsPluginTest.java @@ -0,0 +1,493 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.plugins; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.adk.artifacts.BaseArtifactService; +import com.google.adk.artifacts.InMemoryArtifactService; +import com.google.adk.events.EventActions; +import com.google.adk.sessions.Session; +import com.google.adk.sessions.State; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Blob; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.Single; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** + * Tests for {@link SaveFilesAsArtifactsPlugin}. + * + *

Mirrors {@code GlobalInstructionPluginTest}: the two contexts are mocked, while {@link State}, + * {@link Session} and the artifact service are real, so the {@code temp:} hand-off between {@code + * onUserMessageCallback} and {@code beforeAgentCallback} is exercised rather than stubbed. + */ +@RunWith(JUnit4.class) +public class SaveFilesAsArtifactsPluginTest { + + private static final String APP_NAME = "test_app"; + private static final String USER_ID = "test_user"; + private static final String SESSION_ID = "test_session"; + private static final String INVOCATION_ID = "e-1234"; + private static final String ROLE = "user"; + private static final String FILE_NAME = "report.pdf"; + private static final String OTHER_FILE_NAME = "chart.png"; + private static final String PLACEHOLDER = "[Uploaded Artifact: \"%s\"]"; + private static final String SAVE_FAILED = "artifact store unavailable"; + private static final String GENERATED_NAME = "artifact_%s_0".formatted(INVOCATION_ID); + private static final String CUSTOM_NAME = "uploads"; + private static final String KEY_FORMAT = "temp:%s:pending_delta:%s"; + private static final String STASH_KEY = + KEY_FORMAT.formatted(SaveFilesAsArtifactsPlugin.DEFAULT_NAME, INVOCATION_ID); + private static final byte[] PAYLOAD = "hello artifact".getBytes(UTF_8); + + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock private InvocationContext mockInvocationContext; + @Mock private CallbackContext mockCallbackContext; + @Mock private BaseAgent mockAgent; + + private final State state = new State(new ConcurrentHashMap<>()); + private final Session session = Session.builder(SESSION_ID).state(state).build(); + private final BaseArtifactService artifactService = new InMemoryArtifactService(); + private final EventActions eventActions = EventActions.builder().build(); + private final SaveFilesAsArtifactsPlugin plugin = new SaveFilesAsArtifactsPlugin(); + + @Before + public void setUp() { + state.clear(); + when(mockInvocationContext.invocationId()).thenReturn(INVOCATION_ID); + when(mockInvocationContext.appName()).thenReturn(APP_NAME); + when(mockInvocationContext.userId()).thenReturn(USER_ID); + when(mockInvocationContext.session()).thenReturn(session); + when(mockInvocationContext.artifactService()).thenReturn(artifactService); + + when(mockCallbackContext.invocationId()).thenReturn(INVOCATION_ID); + when(mockCallbackContext.state()).thenReturn(state); + when(mockCallbackContext.eventActions()).thenReturn(eventActions); + } + + // --- onUserMessageCallback ------------------------------------------------------------------- + + @Test + public void inlineDataPart_savedAndReplacedWithPlaceholder() { + Content rewritten = runUserMessage(messageWith(blob(FILE_NAME))); + + assertThat(partTexts(rewritten)).containsExactly(placeholder(FILE_NAME)); + assertThat(storedFileNames()).containsExactly(FILE_NAME); + } + + @Test + public void blobWithDisplayName_usesDisplayNameAsArtifactName() { + runUserMessage(messageWith(blob(FILE_NAME))); + + assertThat(storedFileNames()).containsExactly(FILE_NAME); + assertThat(storedFileNames()).doesNotContain(GENERATED_NAME); + } + + @Test + public void blobWithoutDisplayName_usesGeneratedName() { + runUserMessage(messageWith(blob(null))); + + assertThat(storedFileNames()).containsExactly(GENERATED_NAME); + } + + @Test + public void textOnlyMessage_returnsEmpty() { + Content message = Content.fromParts(Part.fromText("no files here")); + + plugin + .onUserMessageCallback(mockInvocationContext, message) + .test() + .assertNoValues() + .assertComplete(); + + assertThat(storedFileNames()).isEmpty(); + assertThat(state).doesNotContainKey(STASH_KEY); + } + + /** A message with no parts at all, which {@code parts().orElse(...)} has to absorb. */ + @Test + public void messageWithNoParts_returnsEmpty() { + Content message = Content.builder().role("user").build(); + + plugin + .onUserMessageCallback(mockInvocationContext, message) + .test() + .assertNoValues() + .assertComplete(); + + assertThat(storedFileNames()).isEmpty(); + assertThat(state).doesNotContainKey(STASH_KEY); + } + + /** A blank display name is not a name — the {@code isEmpty} filter must fall back. */ + @Test + public void blobWithBlankDisplayName_usesGeneratedName() { + runUserMessage(messageWith(blob(""))); + + assertThat(storedFileNames()).containsExactly(GENERATED_NAME); + } + + /** The rewrite goes through {@code toBuilder()}, so everything but the parts must survive it. */ + @Test + public void rewrittenMessage_keepsTheRole() { + Content message = + Content.builder().role(ROLE).parts(ImmutableList.of(blobPart(FILE_NAME))).build(); + + Content rewritten = runUserMessage(message); + + assertThat(rewritten.role()).hasValue(ROLE); + } + + @Test + public void noArtifactService_returnsEmptyAndDoesNotThrow() { + when(mockInvocationContext.artifactService()).thenReturn(null); + + plugin + .onUserMessageCallback(mockInvocationContext, messageWith(blob(FILE_NAME))) + .test() + .assertNoValues() + .assertComplete(); + + assertThat(state).doesNotContainKey(STASH_KEY); + } + + @Test + public void mixedParts_preservesOrderAndNonBlobParts() { + Content message = + Content.fromParts(Part.fromText("before"), blobPart(FILE_NAME), Part.fromText("after")); + + Content rewritten = runUserMessage(message); + + assertThat(partTexts(rewritten)) + .containsExactly("before", placeholder(FILE_NAME), "after") + .inOrder(); + } + + // --- the plugin's name ------------------------------------------------------------------------- + + @Test + public void defaultName_matchesUpstream() { + assertThat(new SaveFilesAsArtifactsPlugin().getName()) + .isEqualTo("save_files_as_artifacts_plugin"); + } + + /** A custom name is not cosmetic: it scopes the state key the two hooks hand off through. */ + @Test + public void customName_scopesTheStash() { + SaveFilesAsArtifactsPlugin named = new SaveFilesAsArtifactsPlugin(CUSTOM_NAME); + + named + .onUserMessageCallback(mockInvocationContext, messageWith(blob(FILE_NAME))) + .test() + .assertComplete(); + + assertThat(named.getName()).isEqualTo(CUSTOM_NAME); + assertThat(state).containsKey(customStashKey()); + assertThat(state).doesNotContainKey(STASH_KEY); + } + + // --- the caller's message is never touched --------------------------------------------------- + + /** + * The defect this plugin's predecessor fixed ({@code Runner} rewriting the caller's parts list in + * place, #1377) must not reappear + * here. The hook returns a new {@link Content}; the one it was handed keeps its blob. + */ + @Test + public void callerMessage_isNotMutated() { + Content message = Content.fromParts(Part.fromText("before"), blobPart(FILE_NAME)); + + Content rewritten = runUserMessage(message); + + assertThat(partTexts(rewritten)).containsExactly("before", placeholder(FILE_NAME)).inOrder(); + assertThat(partTexts(message)).containsExactly("before", "").inOrder(); + assertThat(message.parts().orElseThrow().get(1).inlineData()).isPresent(); + } + + /** + * {@code Content.Builder.parts(List)} stores the caller's list without copying, so an + * immutable list travels straight into the hook. This is the construction that threw in #1377; + * the plugin copies before rewriting, so it must not care. + */ + @Test + public void messageBuiltFromImmutableList_isSavedAndReplaced() { + Content message = + Content.builder() + .role("user") + .parts(ImmutableList.of(Part.fromText("before"), blobPart(FILE_NAME))) + .build(); + + Content rewritten = runUserMessage(message); + + assertThat(partTexts(rewritten)).containsExactly("before", placeholder(FILE_NAME)).inOrder(); + assertThat(partTexts(message)).containsExactly("before", "").inOrder(); + assertThat(storedFileNames()).containsExactly(FILE_NAME); + } + + /** + * {@code Content.Builder.parts(Part.Builder...)} collects to a Guava {@code ImmutableList} inside + * genai — a second immutable backing the caller cannot influence. + */ + @Test + public void messageBuiltFromPartBuilders_isSavedAndReplaced() { + Content message = + Content.builder() + .role("user") + .parts(Part.fromText("before").toBuilder(), blobPart(FILE_NAME).toBuilder()) + .build(); + + Content rewritten = runUserMessage(message); + + assertThat(partTexts(rewritten)).containsExactly("before", placeholder(FILE_NAME)).inOrder(); + assertThat(storedFileNames()).containsExactly(FILE_NAME); + } + + @Test + public void multipleBlobs_allSavedAndOrderPreserved() { + Content message = + Content.fromParts(blobPart(FILE_NAME), Part.fromText("between"), blobPart(OTHER_FILE_NAME)); + + Content rewritten = runUserMessage(message); + + assertThat(partTexts(rewritten)) + .containsExactly(placeholder(FILE_NAME), "between", placeholder(OTHER_FILE_NAME)) + .inOrder(); + assertThat(storedFileNames()).containsExactly(FILE_NAME, OTHER_FILE_NAME); + } + + @Test + public void multipleBlobs_allReportedInArtifactDelta() { + runUserMessage(Content.fromParts(blobPart(FILE_NAME), blobPart(OTHER_FILE_NAME))); + + plugin.beforeAgentCallback(mockAgent, mockCallbackContext).test().assertComplete(); + + assertThat(eventActions.artifactDelta()).containsExactly(FILE_NAME, 0, OTHER_FILE_NAME, 0); + } + + @Test + public void partialFailure_placeholdersTheSavedPartAndKeepsTheFailedOne() { + BaseArtifactService failing = failingOnSecondSaveArtifactService(); + when(mockInvocationContext.artifactService()).thenReturn(failing); + + Content rewritten = + runUserMessage(Content.fromParts(blobPart(FILE_NAME), blobPart(OTHER_FILE_NAME))); + + assertThat(partTexts(rewritten)).containsExactly(placeholder(FILE_NAME), "").inOrder(); + assertThat(pendingStash()).containsExactly(FILE_NAME, 0); + } + + @Test + public void duplicateDisplayNames_reportTheLatestVersion() { + Content rewritten = runUserMessage(Content.fromParts(blobPart(FILE_NAME), blobPart(FILE_NAME))); + + assertThat(partTexts(rewritten)) + .containsExactly(placeholder(FILE_NAME), placeholder(FILE_NAME)); + assertThat(pendingStash()).containsExactly(FILE_NAME, 1); + } + + @Test + public void saveFailure_keepsOriginalPartAndDoesNotFailInvocation() { + BaseArtifactService failing = failingArtifactService(); + when(mockInvocationContext.artifactService()).thenReturn(failing); + + plugin + .onUserMessageCallback(mockInvocationContext, messageWith(blob(FILE_NAME))) + .test() + .assertNoErrors() + .assertNoValues() + .assertComplete(); + + assertThat(state).doesNotContainKey(STASH_KEY); + } + + @Test + public void payloadRoundTrips() { + runUserMessage(messageWith(blob(FILE_NAME))); + + Part loaded = + artifactService.loadArtifact(APP_NAME, USER_ID, SESSION_ID, FILE_NAME, 0).blockingGet(); + + assertThat(loaded.inlineData().get().data().get()).isEqualTo(PAYLOAD); + } + + // --- beforeAgentCallback --------------------------------------------------------------------- + + @Test + public void savedVersions_appearInArtifactDelta() { + runUserMessage(messageWith(blob(FILE_NAME))); + + plugin + .beforeAgentCallback(mockAgent, mockCallbackContext) + .test() + .assertNoValues() + .assertComplete(); + + assertThat(eventActions.artifactDelta()).containsExactly(FILE_NAME, 0); + } + + /** + * The drain writes entry by entry into the live map, so a delta another plugin already reported + * on the same event survives. Clearing or replacing it would silently drop their artifact. + */ + @Test + public void existingArtifactDelta_isPreserved() { + eventActions.artifactDelta().put(OTHER_FILE_NAME, 7); + runUserMessage(messageWith(blob(FILE_NAME))); + + plugin.beforeAgentCallback(mockAgent, mockCallbackContext).test().assertComplete(); + + assertThat(eventActions.artifactDelta()).containsExactly(OTHER_FILE_NAME, 7, FILE_NAME, 0); + } + + @Test + public void secondAgentCallback_seesEmptyDelta() { + runUserMessage(messageWith(blob(FILE_NAME))); + plugin.beforeAgentCallback(mockAgent, mockCallbackContext).test().assertComplete(); + eventActions.artifactDelta().clear(); + + plugin.beforeAgentCallback(mockAgent, mockCallbackContext).test().assertComplete(); + + assertThat(eventActions.artifactDelta()).isEmpty(); + } + + // --- afterRunCallback: the halt-before-agent cleanup ------------------------------------------ + + @Test + public void afterRunCallback_clearsStashWhenAgentNeverRan() { + runUserMessage(messageWith(blob(FILE_NAME))); + assertThat(pendingStash()).isNotEmpty(); + + plugin.afterRunCallback(mockInvocationContext).test().assertComplete(); + + assertThat(pendingStash()).isEmpty(); + } + + @Test + public void afterRunCallback_withNothingStashed_addsNoStateEntry() { + plugin.afterRunCallback(mockInvocationContext).test().assertComplete(); + + assertThat(state).doesNotContainKey(STASH_KEY); + } + + @Test + public void afterRunCallback_afterDrain_isANoOp() { + runUserMessage(messageWith(blob(FILE_NAME))); + plugin.beforeAgentCallback(mockAgent, mockCallbackContext).test().assertComplete(); + + plugin.afterRunCallback(mockInvocationContext).test().assertComplete(); + + assertThat(eventActions.artifactDelta()).containsExactly(FILE_NAME, 0); + assertThat(pendingStash()).isEmpty(); + } + + // --- helpers --------------------------------------------------------------------------------- + + /** Runs the hook and returns the rewritten message, failing the test if none was produced. */ + private Content runUserMessage(Content message) { + return plugin + .onUserMessageCallback(mockInvocationContext, message) + .test() + .assertNoErrors() + .assertComplete() + .values() + .get(0); + } + + private static String placeholder(String fileName) { + return PLACEHOLDER.formatted(fileName); + } + + private static String customStashKey() { + return KEY_FORMAT.formatted(CUSTOM_NAME, INVOCATION_ID); + } + + private static Content messageWith(Blob inlineData) { + return Content.fromParts(Part.builder().inlineData(inlineData).build()); + } + + private static Part blobPart(String displayName) { + return Part.builder().inlineData(blob(displayName)).build(); + } + + private static Blob blob(String displayName) { + Blob.Builder builder = Blob.builder().mimeType("application/pdf").data(PAYLOAD); + return displayName == null ? builder.build() : builder.displayName(displayName).build(); + } + + private static ImmutableList partTexts(Content content) { + return content.parts().orElseThrow().stream() + .map(part -> part.text().orElse("")) + .collect(toImmutableList()); + } + + private ImmutableList storedFileNames() { + return artifactService + .listArtifactKeys(APP_NAME, USER_ID, SESSION_ID) + .blockingGet() + .filenames(); + } + + @SuppressWarnings("unchecked") + private Map pendingStash() { + Object stashed = state.get(STASH_KEY); + return stashed instanceof Map ? (Map) stashed : Map.of(); + } + + /** + * Fails every save. The error is carried by the returned {@link Single} rather than thrown from + * the method body: a synchronous throw escapes the Rx chain and never reaches the plugin's {@code + * onErrorReturn}, which would test nothing. + */ + private static BaseArtifactService failingArtifactService() { + BaseArtifactService failing = mock(BaseArtifactService.class); + when(failing.saveArtifact(anyString(), anyString(), anyString(), anyString(), any(Part.class))) + .thenReturn(Single.error(() -> new IllegalStateException(SAVE_FAILED))); + return failing; + } + + /** + * Saves the first artifact and fails every save after it, via consecutive stubbing rather than an + * answer with a counter, so the test carries no mutable state of its own. + */ + private static BaseArtifactService failingOnSecondSaveArtifactService() { + BaseArtifactService failing = mock(BaseArtifactService.class); + when(failing.saveArtifact(anyString(), anyString(), anyString(), anyString(), any(Part.class))) + .thenReturn(Single.just(0)) + .thenReturn(Single.error(() -> new IllegalStateException(SAVE_FAILED))); + return failing; + } +}