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;
+ }
+}