|
23 | 23 | import static com.google.adk.testing.TestUtils.createTestLlm; |
24 | 24 | import static com.google.adk.testing.TestUtils.createTextLlmResponse; |
25 | 25 | import static com.google.adk.testing.TestUtils.simplifyEvents; |
| 26 | +import static com.google.common.collect.ImmutableList.toImmutableList; |
26 | 27 | import static com.google.common.truth.Truth.assertThat; |
27 | 28 | import static java.nio.charset.StandardCharsets.UTF_8; |
28 | 29 | import static java.util.Arrays.stream; |
|
50 | 51 | import com.google.adk.apps.App; |
51 | 52 | import com.google.adk.apps.ResumabilityConfig; |
52 | 53 | import com.google.adk.artifacts.BaseArtifactService; |
| 54 | +import com.google.adk.artifacts.InMemoryArtifactService; |
53 | 55 | import com.google.adk.events.Event; |
54 | 56 | import com.google.adk.flows.llmflows.Functions; |
55 | 57 | import com.google.adk.models.LlmRequest; |
@@ -3042,6 +3044,275 @@ public void runner_executesSaveArtifactFlow() { |
3042 | 3044 | assertThat(simplifyEvents(events.values())).containsExactly("test agent: from llm"); |
3043 | 3045 | } |
3044 | 3046 |
|
| 3047 | + private static final String BLOB_MIME_TYPE = "example/octet-stream"; |
| 3048 | + private static final String BLOB_PAYLOAD = "blob payload"; |
| 3049 | + private static final String PLACEHOLDER_FORMAT = |
| 3050 | + "Uploaded file: %s. It has been saved to the artifacts"; |
| 3051 | + |
| 3052 | + private static Part blobPart() { |
| 3053 | + return Part.fromBytes(BLOB_PAYLOAD.getBytes(UTF_8), BLOB_MIME_TYPE); |
| 3054 | + } |
| 3055 | + |
| 3056 | + /** The text the runner substitutes for the blob it offloaded to {@code fileName}. */ |
| 3057 | + private static String placeholderFor(String fileName) { |
| 3058 | + return PLACEHOLDER_FORMAT.formatted(fileName); |
| 3059 | + } |
| 3060 | + |
| 3061 | + /** |
| 3062 | + * A message whose parts list is immutable: {@code Content.Builder.parts(List)} stores the |
| 3063 | + * caller's list without copying it. |
| 3064 | + */ |
| 3065 | + private static Content immutablePartsMessage() { |
| 3066 | + return Content.builder() |
| 3067 | + .role("user") |
| 3068 | + .parts(ImmutableList.of(Part.fromText("hello"), blobPart())) |
| 3069 | + .build(); |
| 3070 | + } |
| 3071 | + |
| 3072 | + /** A message whose parts list genai itself collected into an {@code ImmutableList}. */ |
| 3073 | + private static Content partBuilderPartsMessage() { |
| 3074 | + return Content.builder() |
| 3075 | + .role("user") |
| 3076 | + .parts(Part.fromText("hello").toBuilder(), blobPart().toBuilder()) |
| 3077 | + .build(); |
| 3078 | + } |
| 3079 | + |
| 3080 | + /** |
| 3081 | + * A message whose parts list accepts {@code set}. Used where the assertion is that the runner |
| 3082 | + * leaves the caller's message alone: with an immutable list the runner could not have modified it |
| 3083 | + * either way, so only a mutable one distinguishes copying from rewriting in place. |
| 3084 | + */ |
| 3085 | + private static Content mutablePartsMessage() { |
| 3086 | + return Content.builder() |
| 3087 | + .role("user") |
| 3088 | + .parts(new ArrayList<>(ImmutableList.of(Part.fromText("hello"), blobPart()))) |
| 3089 | + .build(); |
| 3090 | + } |
| 3091 | + |
| 3092 | + /** |
| 3093 | + * A message carrying two blobs, at part indices 1 and 2. The runner names each artifact after the |
| 3094 | + * index of the part it came from, so only a message with more than one blob distinguishes that |
| 3095 | + * from a running counter. |
| 3096 | + */ |
| 3097 | + private static Content twoBlobsMessage() { |
| 3098 | + return Content.builder() |
| 3099 | + .role("user") |
| 3100 | + .parts(ImmutableList.of(Part.fromText("hello"), blobPart(), blobPart())) |
| 3101 | + .build(); |
| 3102 | + } |
| 3103 | + |
| 3104 | + private static RunConfig saveInputBlobs(boolean enabled) { |
| 3105 | + return RunConfig.builder().saveInputBlobsAsArtifacts(enabled).build(); |
| 3106 | + } |
| 3107 | + |
| 3108 | + /** |
| 3109 | + * Points {@link #runner} at a runner backed by a fresh {@link InMemoryArtifactService}, with a |
| 3110 | + * fresh {@link #session} on it. What the service stored is read back with {@link #artifactNames} |
| 3111 | + * and {@link Runner#artifactService()}. |
| 3112 | + */ |
| 3113 | + private void useRunnerWithArtifactService() { |
| 3114 | + this.runner = |
| 3115 | + Runner.builder() |
| 3116 | + .app(App.builder().name("test").rootAgent(agent).build()) |
| 3117 | + .artifactService(new InMemoryArtifactService()) |
| 3118 | + .build(); |
| 3119 | + this.session = this.runner.sessionService().createSession("test", "user").blockingGet(); |
| 3120 | + } |
| 3121 | + |
| 3122 | + /** The names of the artifacts saved for {@link #session}. */ |
| 3123 | + private ImmutableList<String> artifactNames() { |
| 3124 | + return ImmutableList.copyOf( |
| 3125 | + runner |
| 3126 | + .artifactService() |
| 3127 | + .listArtifactKeys("test", "user", session.id()) |
| 3128 | + .blockingGet() |
| 3129 | + .filenames()); |
| 3130 | + } |
| 3131 | + |
| 3132 | + /** The name of the single saved artifact whose file name ends in {@code suffix}. */ |
| 3133 | + private String artifactNameEndingIn(String suffix) { |
| 3134 | + ImmutableList<String> matches = |
| 3135 | + artifactNames().stream().filter(name -> name.endsWith(suffix)).collect(toImmutableList()); |
| 3136 | + assertThat(matches).hasSize(1); |
| 3137 | + return matches.get(0); |
| 3138 | + } |
| 3139 | + |
| 3140 | + /** The user message that was actually appended to the session. */ |
| 3141 | + private Content appendedUserMessage() { |
| 3142 | + Session stored = |
| 3143 | + runner |
| 3144 | + .sessionService() |
| 3145 | + .getSession("test", "user", session.id(), Optional.empty()) |
| 3146 | + .blockingGet(); |
| 3147 | + return stored.events().stream() |
| 3148 | + .filter(event -> event.author().equals("user")) |
| 3149 | + .findFirst() |
| 3150 | + .flatMap(Event::content) |
| 3151 | + .orElseThrow(() -> new AssertionError("No user message was appended to the session.")); |
| 3152 | + } |
| 3153 | + |
| 3154 | + /** The parts of the user message that was actually appended to the session. */ |
| 3155 | + private List<Part> appendedUserParts() { |
| 3156 | + return appendedUserMessage() |
| 3157 | + .parts() |
| 3158 | + .orElseThrow(() -> new AssertionError("The appended user message has no parts.")); |
| 3159 | + } |
| 3160 | + |
| 3161 | + /** Asserts the run reached the model and emitted the agent's reply. */ |
| 3162 | + private static void assertAgentReplied(TestSubscriber<Event> events) { |
| 3163 | + events.assertComplete(); |
| 3164 | + assertThat(simplifyEvents(events.values())).containsExactly("test agent: from llm"); |
| 3165 | + } |
| 3166 | + |
| 3167 | + @Test |
| 3168 | + public void saveInputBlobsAsArtifacts_immutablePartsList_savesArtifactAndCompletes() { |
| 3169 | + useRunnerWithArtifactService(); |
| 3170 | + |
| 3171 | + var events = |
| 3172 | + runner.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(true)).test(); |
| 3173 | + |
| 3174 | + assertAgentReplied(events); |
| 3175 | + assertThat(artifactNames()).hasSize(1); |
| 3176 | + } |
| 3177 | + |
| 3178 | + @Test |
| 3179 | + public void saveInputBlobsAsArtifacts_partBuilderPartsList_savesArtifactAndCompletes() { |
| 3180 | + useRunnerWithArtifactService(); |
| 3181 | + |
| 3182 | + var events = |
| 3183 | + runner |
| 3184 | + .runAsync("user", session.id(), partBuilderPartsMessage(), saveInputBlobs(true)) |
| 3185 | + .test(); |
| 3186 | + |
| 3187 | + assertAgentReplied(events); |
| 3188 | + assertThat(artifactNames()).hasSize(1); |
| 3189 | + } |
| 3190 | + |
| 3191 | + @Test |
| 3192 | + public void saveInputBlobsAsArtifacts_doesNotModifyCallerMessage() { |
| 3193 | + useRunnerWithArtifactService(); |
| 3194 | + Content callerMessage = mutablePartsMessage(); |
| 3195 | + |
| 3196 | + var events = runner.runAsync("user", session.id(), callerMessage, saveInputBlobs(true)).test(); |
| 3197 | + |
| 3198 | + assertAgentReplied(events); |
| 3199 | + assertThat(artifactNames()).hasSize(1); |
| 3200 | + assertThat(callerMessage.parts().get().get(1).inlineData()).isPresent(); |
| 3201 | + assertThat(callerMessage.parts().get().get(1).text()).isEmpty(); |
| 3202 | + } |
| 3203 | + |
| 3204 | + @Test |
| 3205 | + public void saveInputBlobsAsArtifacts_appendedEventReplacesBlobWithPlaceholder() { |
| 3206 | + useRunnerWithArtifactService(); |
| 3207 | + |
| 3208 | + var events = |
| 3209 | + runner.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(true)).test(); |
| 3210 | + |
| 3211 | + assertAgentReplied(events); |
| 3212 | + // The appended message is a copy of the caller's, so the role has to survive the copy. |
| 3213 | + assertThat(appendedUserMessage().role()).hasValue("user"); |
| 3214 | + List<Part> appended = appendedUserParts(); |
| 3215 | + assertThat(appended).hasSize(2); |
| 3216 | + assertThat(appended.get(0).text()).hasValue("hello"); |
| 3217 | + assertThat(appended.get(1).inlineData()).isEmpty(); |
| 3218 | + assertThat(appended.get(1).text()).hasValue(placeholderFor(artifactNames().get(0))); |
| 3219 | + } |
| 3220 | + |
| 3221 | + @Test |
| 3222 | + public void saveInputBlobsAsArtifacts_twoBlobs_namesEachArtifactAfterItsPartIndex() { |
| 3223 | + useRunnerWithArtifactService(); |
| 3224 | + |
| 3225 | + var events = |
| 3226 | + runner.runAsync("user", session.id(), twoBlobsMessage(), saveInputBlobs(true)).test(); |
| 3227 | + |
| 3228 | + assertAgentReplied(events); |
| 3229 | + assertThat(artifactNames()).hasSize(2); |
| 3230 | + List<Part> appended = appendedUserParts(); |
| 3231 | + assertThat(appended).hasSize(3); |
| 3232 | + assertThat(appended.get(1).text()).hasValue(placeholderFor(artifactNameEndingIn("_1"))); |
| 3233 | + assertThat(appended.get(2).text()).hasValue(placeholderFor(artifactNameEndingIn("_2"))); |
| 3234 | + } |
| 3235 | + |
| 3236 | + @Test |
| 3237 | + public void saveInputBlobsAsArtifacts_storesBlobVerbatim() { |
| 3238 | + useRunnerWithArtifactService(); |
| 3239 | + |
| 3240 | + var events = |
| 3241 | + runner.runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(true)).test(); |
| 3242 | + |
| 3243 | + assertAgentReplied(events); |
| 3244 | + assertThat(artifactNames()).hasSize(1); |
| 3245 | + Part stored = |
| 3246 | + runner |
| 3247 | + .artifactService() |
| 3248 | + .loadArtifact("test", "user", session.id(), artifactNames().get(0)) |
| 3249 | + .blockingGet(); |
| 3250 | + assertThat(new String(stored.inlineData().get().data().get(), UTF_8)).isEqualTo(BLOB_PAYLOAD); |
| 3251 | + assertThat(stored.inlineData().get().mimeType()).hasValue(BLOB_MIME_TYPE); |
| 3252 | + } |
| 3253 | + |
| 3254 | + @Test |
| 3255 | + public void saveInputBlobsAsArtifacts_textOnlyMessage_passesThroughUnchanged() { |
| 3256 | + useRunnerWithArtifactService(); |
| 3257 | + Content callerMessage = Content.fromParts(Part.fromText("hello")); |
| 3258 | + |
| 3259 | + var events = runner.runAsync("user", session.id(), callerMessage, saveInputBlobs(true)).test(); |
| 3260 | + |
| 3261 | + assertAgentReplied(events); |
| 3262 | + assertThat(artifactNames()).isEmpty(); |
| 3263 | + List<Part> appended = appendedUserParts(); |
| 3264 | + assertThat(appended).hasSize(1); |
| 3265 | + assertThat(appended.get(0).text()).hasValue("hello"); |
| 3266 | + assertThat(callerMessage.parts().get().get(0).text()).hasValue("hello"); |
| 3267 | + } |
| 3268 | + |
| 3269 | + @Test |
| 3270 | + public void saveInputBlobsAsArtifacts_disabledWithTextOnlyMessage_passesThroughUnchanged() { |
| 3271 | + // The default path for every ordinary agent call: no blob, and the option at its default false. |
| 3272 | + // The runner must not touch the message at all. |
| 3273 | + useRunnerWithArtifactService(); |
| 3274 | + Content callerMessage = Content.fromParts(Part.fromText("hello")); |
| 3275 | + |
| 3276 | + var events = runner.runAsync("user", session.id(), callerMessage, saveInputBlobs(false)).test(); |
| 3277 | + |
| 3278 | + assertAgentReplied(events); |
| 3279 | + assertThat(artifactNames()).isEmpty(); |
| 3280 | + List<Part> appended = appendedUserParts(); |
| 3281 | + assertThat(appended).hasSize(1); |
| 3282 | + assertThat(appended.get(0).text()).hasValue("hello"); |
| 3283 | + assertThat(callerMessage.parts().get().get(0).text()).hasValue("hello"); |
| 3284 | + } |
| 3285 | + |
| 3286 | + @Test |
| 3287 | + public void saveInputBlobsAsArtifacts_disabled_keepsBlobAndSavesNothing() { |
| 3288 | + useRunnerWithArtifactService(); |
| 3289 | + |
| 3290 | + var events = |
| 3291 | + runner |
| 3292 | + .runAsync("user", session.id(), immutablePartsMessage(), saveInputBlobs(false)) |
| 3293 | + .test(); |
| 3294 | + |
| 3295 | + assertAgentReplied(events); |
| 3296 | + assertThat(artifactNames()).isEmpty(); |
| 3297 | + assertThat(appendedUserParts().get(1).inlineData()).isPresent(); |
| 3298 | + } |
| 3299 | + |
| 3300 | + @Test |
| 3301 | + public void saveInputBlobsAsArtifacts_fromPartsConstruction_savesArtifactAndCompletes() { |
| 3302 | + useRunnerWithArtifactService(); |
| 3303 | + Content fromPartsMessage = Content.fromParts(Part.fromText("hello"), blobPart()); |
| 3304 | + |
| 3305 | + var events = |
| 3306 | + runner.runAsync("user", session.id(), fromPartsMessage, saveInputBlobs(true)).test(); |
| 3307 | + |
| 3308 | + assertAgentReplied(events); |
| 3309 | + assertThat(artifactNames()).hasSize(1); |
| 3310 | + List<Part> appended = appendedUserParts(); |
| 3311 | + assertThat(appended).hasSize(2); |
| 3312 | + assertThat(appended.get(1).inlineData()).isEmpty(); |
| 3313 | + assertThat(appended.get(1).text()).hasValue(placeholderFor(artifactNames().get(0))); |
| 3314 | + } |
| 3315 | + |
3045 | 3316 | @Test |
3046 | 3317 | public void runAsync_partialEvent_streamedButNotPassedToSessionService() { |
3047 | 3318 | // The model streams a partial event followed by the final aggregated event in one turn. |
|
0 commit comments