diff --git a/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java b/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java
index 89032082c..6654fa58f 100644
--- a/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java
+++ b/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java
@@ -32,7 +32,11 @@
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Maybe;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
@@ -42,23 +46,86 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/** Plugin for replaying ADK agent interactions from recordings. */
+/**
+ * Plugin for replaying ADK agent interactions from recordings.
+ *
+ *
The replay case directory comes from the session state, which any caller of the dev server can
+ * set. Recordings are therefore only loaded from inside a replay root directory that the server
+ * operator configures: the {@code adk.replay.root} system property, the {@code ADK_REPLAY_ROOT}
+ * environment variable, or the process working directory when neither is set. Case directories that
+ * resolve outside the root, through {@code ..} segments, an absolute path or a symlink, are
+ * rejected. A relative case directory resolves against the root, not against the working directory.
+ *
+ *
The working-directory fallback rarely matches where the recordings live, so configure the root
+ * explicitly; the plugin logs the root it ended up with when it is constructed.
+ */
public class ReplayPlugin extends BasePlugin {
private static final Logger logger = LoggerFactory.getLogger(ReplayPlugin.class);
private static final String REPLAY_CONFIG_KEY = "_adk_replay_config";
private static final String RECORDINGS_FILENAME = "generated-recordings.yaml";
+ private static final String REPLAY_ROOT_PROPERTY = "adk.replay.root";
+ private static final String REPLAY_ROOT_ENV = "ADK_REPLAY_ROOT";
// Track replay state per invocation to support concurrent runs
// key: invocation_id -> InvocationReplayState
private final Map invocationStates;
+ // Recordings are only read from inside this directory, never from arbitrary session state paths.
+ private final Path replayRoot;
+
public ReplayPlugin() {
this("adk_replay");
}
public ReplayPlugin(String name) {
+ this(name, defaultReplayRoot(), configuredReplayRoot() == null);
+ }
+
+ /**
+ * Creates a plugin that only loads recordings from inside {@code replayRoot}.
+ *
+ * @param name the plugin name
+ * @param replayRoot the directory that every replay case directory must resolve inside
+ */
+ public ReplayPlugin(String name, Path replayRoot) {
+ this(name, replayRoot, /* usingWorkingDirectory= */ false);
+ }
+
+ private ReplayPlugin(String name, Path replayRoot, boolean usingWorkingDirectory) {
super(name);
+ this.replayRoot = replayRoot.toAbsolutePath().normalize();
this.invocationStates = new ConcurrentHashMap<>();
+ logReplayRoot(usingWorkingDirectory);
+ }
+
+ private static Path defaultReplayRoot() {
+ String configured = configuredReplayRoot();
+ return Paths.get(configured != null ? configured : System.getProperty("user.dir", ""));
+ }
+
+ /** Returns the operator-configured replay root, or null when neither knob is set. */
+ private static String configuredReplayRoot() {
+ String configured = System.getProperty(REPLAY_ROOT_PROPERTY);
+ if (configured == null || configured.isEmpty()) {
+ configured = System.getenv(REPLAY_ROOT_ENV);
+ }
+ return configured == null || configured.isEmpty() ? null : configured;
+ }
+
+ /** Reports the effective replay root once, so a misconfigured root shows up at startup. */
+ private void logReplayRoot(boolean usingWorkingDirectory) {
+ String readable = Files.isDirectory(replayRoot) ? "" : " (not a readable directory)";
+ if (usingWorkingDirectory) {
+ logger.warn(
+ "Replay recordings are confined to the working directory {}{}, which is rarely where"
+ + " recordings live. Set -D{} or {} to the directory holding them.",
+ replayRoot,
+ readable,
+ REPLAY_ROOT_PROPERTY,
+ REPLAY_ROOT_ENV);
+ } else {
+ logger.info("Replay recordings are confined to {}{}", replayRoot, readable);
+ }
}
@Override
@@ -169,17 +236,19 @@ private boolean isReplayModeOn(ToolContext toolContext) {
}
private boolean isReplayModeOnFromState(Map sessionState) {
- if (!sessionState.containsKey(REPLAY_CONFIG_KEY)) {
- return false;
- }
+ Map config = replayConfig(sessionState);
+ return config != null && config.get("dir") != null && config.get("user_message_index") != null;
+ }
+ /** Returns the replay config from session state, or null when replay is not configured. */
+ private static Map replayConfig(Map sessionState) {
+ Object config = sessionState.get(REPLAY_CONFIG_KEY);
+ if (!(config instanceof Map)) {
+ return null;
+ }
@SuppressWarnings("unchecked")
- Map config = (Map) sessionState.get(REPLAY_CONFIG_KEY);
-
- String caseDir = (String) config.get("dir");
- Integer msgIndex = (Integer) config.get("user_message_index");
-
- return caseDir != null && msgIndex != null;
+ Map typedConfig = (Map) config;
+ return typedConfig;
}
private InvocationReplayState getInvocationState(CallbackContext callbackContext) {
@@ -194,28 +263,32 @@ private void loadInvocationState(InvocationContext invocationContext) {
String invocationId = invocationContext.invocationId();
Map sessionState = invocationContext.session().state();
- @SuppressWarnings("unchecked")
- Map config = (Map) sessionState.get(REPLAY_CONFIG_KEY);
+ Map config = replayConfig(sessionState);
if (config == null) {
throw new ReplayConfigError("Replay parameters are missing from session state");
}
- String caseDir = (String) config.get("dir");
- Integer msgIndex = (Integer) config.get("user_message_index");
-
- if (caseDir == null || msgIndex == null) {
+ Object caseDirValue = config.get("dir");
+ Object msgIndexValue = config.get("user_message_index");
+ if (caseDirValue == null || msgIndexValue == null) {
throw new ReplayConfigError("Replay parameters are missing from session state");
}
+ if (!(caseDirValue instanceof String)) {
+ throw new ReplayConfigError(
+ "Replay parameter 'dir' must be a string, got "
+ + caseDirValue.getClass().getSimpleName());
+ }
+ String caseDir = (String) caseDirValue;
+ int msgIndex = userMessageIndex(msgIndexValue);
// Load recordings
- Path recordingsFile = Paths.get(caseDir, RECORDINGS_FILENAME);
+ Path recordingsFile = resolveRecordingsFile(caseDir);
- if (!Files.exists(recordingsFile)) {
- throw new ReplayConfigError("Recordings file not found: " + recordingsFile);
- }
-
- try {
- Recordings recordings = RecordingsLoader.load(recordingsFile);
+ // NOFOLLOW_LINKS: the path was canonical when it was checked, so refuse it if it became a
+ // symlink in between.
+ try (InputStream recordingsStream =
+ Files.newInputStream(recordingsFile, LinkOption.NOFOLLOW_LINKS)) {
+ Recordings recordings = RecordingsLoader.load(recordingsStream);
// Create and store invocation state
InvocationReplayState state = new InvocationReplayState(caseDir, msgIndex, recordings);
@@ -234,6 +307,86 @@ private void loadInvocationState(InvocationContext invocationContext) {
}
}
+ /**
+ * Returns the user message index, which YAML and JSON decoders hand over as any numeric type.
+ *
+ * Anything that is not a whole number in range is rejected rather than narrowed, so a bad
+ * index cannot quietly select the wrong recording.
+ */
+ private static int userMessageIndex(Object value) {
+ if (!(value instanceof Number)) {
+ throw new ReplayConfigError(
+ "Replay parameter 'user_message_index' must be a number, got "
+ + value.getClass().getSimpleName());
+ }
+ double asDouble = ((Number) value).doubleValue();
+ if (asDouble != Math.floor(asDouble) || asDouble < 0 || asDouble > Integer.MAX_VALUE) {
+ throw new ReplayConfigError(
+ "Replay parameter 'user_message_index' must be a whole number in [0, "
+ + Integer.MAX_VALUE
+ + "], got "
+ + value);
+ }
+ return ((Number) value).intValue();
+ }
+
+ /**
+ * Resolves the recordings file for a session-supplied case directory, keeping it inside the
+ * replay root.
+ *
+ *
Containment is checked twice: once lexically, so an obvious escape never touches the file
+ * system, and once on the canonical path, so a symlink cannot point out of the root. The
+ * canonical path is returned, so the caller opens exactly what was checked.
+ */
+ private Path resolveRecordingsFile(String caseDir) {
+ Path canonicalRoot;
+ try {
+ canonicalRoot = replayRoot.toRealPath();
+ } catch (IOException e) {
+ throw new ReplayConfigError(
+ "Replay root directory is not readable: "
+ + replayRoot
+ + ". Set -D"
+ + REPLAY_ROOT_PROPERTY
+ + " to the directory holding the recordings.",
+ e);
+ }
+
+ Path recordingsFile;
+ try {
+ recordingsFile = replayRoot.resolve(caseDir).normalize().resolve(RECORDINGS_FILENAME);
+ } catch (InvalidPathException e) {
+ throw new ReplayConfigError("Invalid replay directory: " + caseDir, e);
+ }
+ // When the root is itself a symlink the caller may spell it either way, so a lexical miss is
+ // conclusive only outside both spellings. The canonical check below is the one that decides.
+ if (!recordingsFile.startsWith(replayRoot) && !recordingsFile.startsWith(canonicalRoot)) {
+ throw new ReplayConfigError(
+ "Replay directory '"
+ + caseDir
+ + "' resolves outside the replay root "
+ + replayRoot
+ + " (-D"
+ + REPLAY_ROOT_PROPERTY
+ + ")");
+ }
+
+ Path canonicalFile;
+ try {
+ canonicalFile = recordingsFile.toRealPath();
+ } catch (NoSuchFileException e) {
+ throw new ReplayConfigError("Recordings file not found: " + recordingsFile, e);
+ } catch (IOException e) {
+ throw new ReplayConfigError("Failed to read recordings file: " + recordingsFile, e);
+ }
+ if (!canonicalFile.startsWith(canonicalRoot)) {
+ throw new ReplayConfigError(
+ "Replay directory '" + caseDir + "' links outside the replay root " + canonicalRoot);
+ }
+
+ return canonicalFile;
+ }
+
private Recording getNextRecordingForAgent(InvocationReplayState state, String agentName) {
int currentAgentIndex = state.getAgentReplayIndex(agentName);
diff --git a/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java b/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java
index 8e89c2567..7112f8a19 100644
--- a/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java
+++ b/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java
@@ -16,6 +16,7 @@
package com.google.adk.plugins;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -23,6 +24,7 @@
import com.google.adk.agents.CallbackContext;
import com.google.adk.agents.InvocationContext;
import com.google.adk.models.LlmRequest;
+import com.google.adk.models.LlmResponse;
import com.google.adk.sessions.Session;
import com.google.adk.sessions.State;
import com.google.adk.tools.BaseTool;
@@ -32,10 +34,13 @@
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Single;
+import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -50,14 +55,17 @@ class ReplayPluginTest {
@TempDir Path tempDir;
+ private Path replayRoot;
private ReplayPlugin plugin;
private Session mockSession;
private ConcurrentHashMap sessionState;
private State state;
@BeforeEach
- void setUp() {
- plugin = new ReplayPlugin();
+ void setUp() throws Exception {
+ // toRealPath: @TempDir can sit behind a symlink, e.g. /var -> /private/var on macOS.
+ replayRoot = Files.createDirectory(tempDir.resolve("root")).toRealPath();
+ plugin = new ReplayPlugin("adk_replay", replayRoot);
mockSession = mock(Session.class);
sessionState = new ConcurrentHashMap<>();
state = new State(sessionState);
@@ -68,7 +76,7 @@ void setUp() {
@Test
void beforeModelCallback_withMatchingRecording_returnsRecordedResponse() throws Exception {
// Setup: Create a minimal recording file
- Path recordingsFile = tempDir.resolve("generated-recordings.yaml");
+ Path recordingsFile = replayRoot.resolve("generated-recordings.yaml");
Files.writeString(
recordingsFile,
"""
@@ -92,7 +100,8 @@ void beforeModelCallback_withMatchingRecording_returnsRecordedResponse() throws
// Step 1: Setup replay config
sessionState.put(
- "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0));
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
// Step 2: Call beforeRunCallback to load recordings
InvocationContext invocationContext = mock(InvocationContext.class);
@@ -128,7 +137,7 @@ void beforeModelCallback_withMatchingRecording_returnsRecordedResponse() throws
@Test
void beforeModelCallback_requestMismatch_returnsEmpty() throws Exception {
// Setup: Create recording with different model
- Path recordingsFile = tempDir.resolve("generated-recordings.yaml");
+ Path recordingsFile = replayRoot.resolve("generated-recordings.yaml");
Files.writeString(
recordingsFile,
"""
@@ -147,7 +156,8 @@ void beforeModelCallback_requestMismatch_returnsEmpty() throws Exception {
// Step 1: Setup replay config
sessionState.put(
- "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0));
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
// Step 2: Load recordings
InvocationContext invocationContext = mock(InvocationContext.class);
@@ -179,7 +189,7 @@ void beforeModelCallback_requestMismatch_returnsEmpty() throws Exception {
@Test
void beforeToolCallback_withMatchingRecording_returnsRecordedResponse() throws Exception {
// Setup: Create recording with tool call
- Path recordingsFile = tempDir.resolve("generated-recordings.yaml");
+ Path recordingsFile = replayRoot.resolve("generated-recordings.yaml");
Files.writeString(
recordingsFile,
"""
@@ -202,7 +212,8 @@ void beforeToolCallback_withMatchingRecording_returnsRecordedResponse() throws E
// Step 1: Setup replay config
sessionState.put(
- "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0));
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
// Step 2: Load recordings
InvocationContext invocationContext = mock(InvocationContext.class);
@@ -234,7 +245,7 @@ void beforeToolCallback_withMatchingRecording_returnsRecordedResponse() throws E
@Test
void beforeToolCallback_toolNameMismatch_returnsEmpty() throws Exception {
// Setup: Create recording
- Path recordingsFile = tempDir.resolve("generated-recordings.yaml");
+ Path recordingsFile = replayRoot.resolve("generated-recordings.yaml");
Files.writeString(
recordingsFile,
"""
@@ -251,7 +262,8 @@ void beforeToolCallback_toolNameMismatch_returnsEmpty() throws Exception {
// Step 1: Setup replay config
sessionState.put(
- "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0));
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
// Step 2: Load recordings
InvocationContext invocationContext = mock(InvocationContext.class);
@@ -279,7 +291,7 @@ void beforeToolCallback_toolNameMismatch_returnsEmpty() throws Exception {
@Test
void beforeToolCallback_toolArgsMismatch_returnsEmpty() throws Exception {
// Setup: Create recording
- Path recordingsFile = tempDir.resolve("generated-recordings.yaml");
+ Path recordingsFile = replayRoot.resolve("generated-recordings.yaml");
Files.writeString(
recordingsFile,
"""
@@ -296,7 +308,8 @@ void beforeToolCallback_toolArgsMismatch_returnsEmpty() throws Exception {
// Step 1: Setup replay config
sessionState.put(
- "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0));
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
// Step 2: Load recordings
InvocationContext invocationContext = mock(InvocationContext.class);
@@ -321,4 +334,291 @@ void beforeToolCallback_toolArgsMismatch_returnsEmpty() throws Exception {
.blockingGet();
assertThat(result).isNull();
}
+
+ @Test
+ void beforeRunCallback_relativeCaseDirInsideRoot_loadsRecordings() throws Exception {
+ Path caseDir = Files.createDirectory(replayRoot.resolve("case"));
+ Files.writeString(caseDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0));
+
+ plugin.beforeRunCallback(newInvocationContext()).blockingGet();
+
+ assertThat(replayedResponseText(plugin)).isEqualTo("Recorded response");
+ }
+
+ @Test
+ void beforeRunCallback_symlinkedCaseDirInsideRoot_loadsRecordings() throws Exception {
+ Path caseDir = Files.createDirectory(replayRoot.resolve("actual_case"));
+ Files.writeString(caseDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ createSymbolicLinkOrSkip(replayRoot.resolve("case"), caseDir);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0));
+
+ plugin.beforeRunCallback(newInvocationContext()).blockingGet();
+
+ assertThat(replayedResponseText(plugin)).isEqualTo("Recorded response");
+ }
+
+ @Test
+ void beforeRunCallback_caseDirTraversesOutsideRoot_throws() throws Exception {
+ Path outsideDir = Files.createDirectory(tempDir.resolve("outside"));
+ Files.writeString(outsideDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put(
+ "_adk_replay_config", ImmutableMap.of("dir", "../outside", "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("resolves outside the replay root");
+ }
+
+ @Test
+ void beforeRunCallback_absoluteCaseDirOutsideRoot_throws() throws Exception {
+ Path outsideDir = Files.createDirectory(tempDir.resolve("outside"));
+ Files.writeString(outsideDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put(
+ "_adk_replay_config",
+ ImmutableMap.of("dir", outsideDir.toString(), "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("resolves outside the replay root");
+ }
+
+ @Test
+ void beforeRunCallback_symlinkedCaseDirOutsideRoot_throws() throws Exception {
+ Path outsideDir = Files.createDirectory(tempDir.resolve("outside"));
+ Files.writeString(outsideDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ createSymbolicLinkOrSkip(replayRoot.resolve("case"), outsideDir);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("links outside the replay root");
+ }
+
+ @Test
+ void beforeRunCallback_symlinkedRecordingsFileOutsideRoot_throws() throws Exception {
+ Path outsideDir = Files.createDirectory(tempDir.resolve("outside"));
+ Path outsideFile = outsideDir.resolve("generated-recordings.yaml");
+ Files.writeString(outsideFile, MINIMAL_RECORDINGS);
+ Path caseDir = Files.createDirectory(replayRoot.resolve("case"));
+ createSymbolicLinkOrSkip(caseDir.resolve("generated-recordings.yaml"), outsideFile);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("links outside the replay root");
+ }
+
+ @Test
+ void beforeRunCallback_caseDirIsSiblingSharingRootPrefix_throws() throws Exception {
+ // "root" is a lexical prefix of "rootsibling", so a string comparison would let this through.
+ Path siblingDir = Files.createDirectory(tempDir.resolve("rootsibling"));
+ Files.writeString(siblingDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put(
+ "_adk_replay_config",
+ ImmutableMap.of("dir", siblingDir.toString(), "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("resolves outside the replay root");
+ }
+
+ @Test
+ void beforeRunCallback_missingRecordingsFileInsideRoot_throws() {
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("Recordings file not found");
+ }
+
+ @Test
+ void beforeRunCallback_replayRootFromSystemProperty_loadsRecordings() throws Exception {
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ String previous = System.getProperty("adk.replay.root");
+ System.setProperty("adk.replay.root", replayRoot.toString());
+ try {
+ ReplayPlugin pluginFromProperty = new ReplayPlugin();
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", 0));
+
+ pluginFromProperty.beforeRunCallback(newInvocationContext()).blockingGet();
+
+ assertThat(replayedResponseText(pluginFromProperty)).isEqualTo("Recorded response");
+ } finally {
+ if (previous == null) {
+ System.clearProperty("adk.replay.root");
+ } else {
+ System.setProperty("adk.replay.root", previous);
+ }
+ }
+ }
+
+ @Test
+ void beforeRunCallback_nonMapReplayConfig_leavesReplayOff() {
+ sessionState.put("_adk_replay_config", "/etc/passwd");
+
+ plugin.beforeRunCallback(newInvocationContext()).blockingGet();
+
+ // Replay stays off, so the model call falls through instead of hitting missing replay state.
+ assertThat(replayedResponse(plugin)).isNull();
+ }
+
+ @Test
+ void beforeRunCallback_userMessageIndexNotANumber_throws() throws Exception {
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put(
+ "_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", "zero"));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("'user_message_index' must be a number");
+ }
+
+ @Test
+ void beforeRunCallback_userMessageIndexNotAWholeNumber_throws() throws Exception {
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", 1.5));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("must be a whole number");
+ }
+
+ @Test
+ void beforeRunCallback_userMessageIndexAsLong_loadsRecordings() throws Exception {
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", 0L));
+
+ plugin.beforeRunCallback(newInvocationContext()).blockingGet();
+
+ assertThat(replayedResponseText(plugin)).isEqualTo("Recorded response");
+ }
+
+ @Test
+ void beforeRunCallback_caseDirNotAString_throws() throws Exception {
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put("_adk_replay_config", ImmutableMap.of("dir", 1, "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains("'dir' must be a string");
+ }
+
+ @Test
+ void beforeRunCallback_rootReachedThroughSymlink_loadsRecordings() throws Exception {
+ // The conformance dev server configures its root through a symlink, so both spellings have to
+ // work: the symlinked root, and the canonical one the caller may send back.
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ Path linkedRoot = tempDir.resolve("linked_root");
+ createSymbolicLinkOrSkip(linkedRoot, replayRoot);
+ ReplayPlugin linkedPlugin = new ReplayPlugin("adk_replay", linkedRoot);
+
+ sessionState.put(
+ "_adk_replay_config",
+ ImmutableMap.of("dir", linkedRoot.toString(), "user_message_index", 0));
+ linkedPlugin.beforeRunCallback(newInvocationContext()).blockingGet();
+ assertThat(replayedResponseText(linkedPlugin)).isEqualTo("Recorded response");
+
+ sessionState.put(
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
+ linkedPlugin.beforeRunCallback(newInvocationContext()).blockingGet();
+ assertThat(replayedResponseText(linkedPlugin)).isEqualTo("Recorded response");
+ }
+
+ @Test
+ void beforeRunCallback_noConfiguredRoot_confinesToWorkingDirectory() throws Exception {
+ String previous = System.getProperty("adk.replay.root");
+ System.clearProperty("adk.replay.root");
+ try {
+ // Neither knob set, so the root is the working directory. The temp directory is outside it,
+ // and the rejection names the property the operator has to set.
+ ReplayPlugin defaultPlugin = new ReplayPlugin();
+ Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS);
+ sessionState.put(
+ "_adk_replay_config",
+ ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0));
+
+ ReplayConfigError error =
+ assertThrows(
+ ReplayConfigError.class,
+ () -> defaultPlugin.beforeRunCallback(newInvocationContext()).blockingGet());
+ assertThat(error).hasMessageThat().contains(Paths.get("").toAbsolutePath().toString());
+ assertThat(error).hasMessageThat().contains("adk.replay.root");
+ } finally {
+ if (previous != null) {
+ System.setProperty("adk.replay.root", previous);
+ }
+ }
+ }
+
+ private InvocationContext newInvocationContext() {
+ InvocationContext invocationContext = mock(InvocationContext.class);
+ when(invocationContext.session()).thenReturn(mockSession);
+ when(invocationContext.invocationId()).thenReturn("test-invocation");
+ return invocationContext;
+ }
+
+ /**
+ * Windows without developer mode refuses symlinks with an IOException, not the documented one.
+ */
+ private static void createSymbolicLinkOrSkip(Path link, Path target) {
+ try {
+ Files.createSymbolicLink(link, target);
+ } catch (UnsupportedOperationException | IOException e) {
+ Assumptions.abort("File system does not support symlinks: " + e.getMessage());
+ }
+ }
+
+ /** Runs the model callback against the loaded recordings, or null when replay is off. */
+ private LlmResponse replayedResponse(ReplayPlugin plugin) {
+ CallbackContext callbackContext = mock(CallbackContext.class);
+ when(callbackContext.state()).thenReturn(state);
+ when(callbackContext.invocationId()).thenReturn("test-invocation");
+ when(callbackContext.agentName()).thenReturn("test_agent");
+ return plugin
+ .beforeModelCallback(callbackContext, LlmRequest.builder().model("gemini-2.0-flash"))
+ .blockingGet();
+ }
+
+ private String replayedResponseText(ReplayPlugin plugin) {
+ LlmResponse response = replayedResponse(plugin);
+ assertThat(response).isNotNull();
+ assertThat(response.content()).isPresent();
+ return response.content().get().text();
+ }
+
+ private static final String MINIMAL_RECORDINGS =
+ """
+ recordings:
+ - user_message_index: 0
+ agent_index: 0
+ agent_name: "test_agent"
+ llm_recording:
+ llm_request:
+ model: "gemini-2.0-flash"
+ llm_responses:
+ - content:
+ role: "model"
+ parts:
+ - text: "Recorded response"
+ """;
}
diff --git a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java
index 78935155a..6065de0f2 100644
--- a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java
+++ b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java
@@ -201,9 +201,13 @@ public class WebMojo extends AbstractMojo {
* Example:
*
*
{@code
- * mvn google-adk:web -Dagents=... -DextraPlugins=com.google.adk.plugins.ReplayPlugin
+ * mvn google-adk:web -Dagents=... -DextraPlugins=com.google.adk.plugins.ReplayPlugin -Dadk.replay.root=/path/to/conformance
* mvn google-adk:web -Dagents=... -DextraPlugins=com.google.adk.plugins.ReplayPlugin,com.example.CustomPlugin
* }
+ *
+ * {@code ReplayPlugin} takes its case directory from the request, so it only loads recordings
+ * from inside {@code -Dadk.replay.root} (the working directory when that is unset). Point it at
+ * the directory holding the conformance cases, otherwise every replay request is rejected.
*/
@Parameter(property = "extraPlugins")
private String extraPlugins;