From a30c16edd9d04a68966a6e9d02cc0b389742f663 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 2 Sep 2026 11:19:36 -0700 Subject: [PATCH] feat: add core value, planner and error types to match the Python SDK PiperOrigin-RevId: 975232656 --- .../google/adk/agents/TranscriptionEntry.java | 87 +++++++++ .../google/adk/artifacts/ArtifactVersion.java | 71 ++++++++ .../java/com/google/adk/events/UiWidget.java | 105 +++++++++++ .../com/google/adk/models/CacheMetadata.java | 165 ++++++++++++++++++ .../com/google/adk/planners/BasePlanner.java | 53 ++++++ .../adk/sessions/StateSchemaException.java | 34 ++++ .../adk/agents/TranscriptionEntryTest.java | 93 ++++++++++ .../adk/artifacts/ArtifactVersionTest.java | 83 +++++++++ .../com/google/adk/events/UiWidgetTest.java | 73 ++++++++ .../google/adk/models/CacheMetadataTest.java | 135 ++++++++++++++ .../sessions/StateSchemaExceptionTest.java | 53 ++++++ 11 files changed, 952 insertions(+) create mode 100644 core/src/main/java/com/google/adk/agents/TranscriptionEntry.java create mode 100644 core/src/main/java/com/google/adk/artifacts/ArtifactVersion.java create mode 100644 core/src/main/java/com/google/adk/events/UiWidget.java create mode 100644 core/src/main/java/com/google/adk/models/CacheMetadata.java create mode 100644 core/src/main/java/com/google/adk/planners/BasePlanner.java create mode 100644 core/src/main/java/com/google/adk/sessions/StateSchemaException.java create mode 100644 core/src/test/java/com/google/adk/agents/TranscriptionEntryTest.java create mode 100644 core/src/test/java/com/google/adk/artifacts/ArtifactVersionTest.java create mode 100644 core/src/test/java/com/google/adk/events/UiWidgetTest.java create mode 100644 core/src/test/java/com/google/adk/models/CacheMetadataTest.java create mode 100644 core/src/test/java/com/google/adk/sessions/StateSchemaExceptionTest.java diff --git a/core/src/main/java/com/google/adk/agents/TranscriptionEntry.java b/core/src/main/java/com/google/adk/agents/TranscriptionEntry.java new file mode 100644 index 000000000..b9e50f019 --- /dev/null +++ b/core/src/main/java/com/google/adk/agents/TranscriptionEntry.java @@ -0,0 +1,87 @@ +/* + * 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.agents; + +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import com.google.genai.types.Blob; +import com.google.genai.types.Content; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * Stores the data that can be used for transcription. + * + *

A live audio invocation caches one of these per thing that was said, so that the audio can be + * bundled by speaker and transcribed later. + * + *

An entry carries either a {@link Blob} of audio that still needs recognising or a {@link + * Content} of text the model already produced, never both and never neither. The two are separate + * fields rather than one field of an open type, the same way {@link LiveRequest} carries the same + * pair. + */ +@AutoValue +public abstract class TranscriptionEntry { + + TranscriptionEntry() {} + + /** + * Returns the role that created this data, typically {@code "user"} or {@code "model"}. + * + *

Empty for a function call, which nobody spoke. + */ + public abstract Optional role(); + + /** Returns audio that still needs recognising, empty when the entry carries text instead. */ + public abstract Optional blob(); + + /** Returns text the model already produced, empty when the entry carries audio instead. */ + public abstract Optional content(); + + /** Returns a new builder for creating a {@link TranscriptionEntry}. */ + public static Builder builder() { + return new AutoValue_TranscriptionEntry.Builder(); + } + + /** Returns a new builder with a copy of this entry's values. */ + public abstract Builder toBuilder(); + + /** Builder for {@link TranscriptionEntry}. */ + @AutoValue.Builder + public abstract static class Builder { + + /** Sets the role that created this data. */ + public abstract Builder role(@Nullable String role); + + /** Sets audio that still needs recognising as the data of this entry. */ + public abstract Builder blob(@Nullable Blob blob); + + /** Sets text the model already produced as the data of this entry. */ + public abstract Builder content(@Nullable Content content); + + abstract TranscriptionEntry autoBuild(); + + /** Builds the entry, refusing one that carries neither kind of data or both. */ + public final TranscriptionEntry build() { + TranscriptionEntry entry = autoBuild(); + Preconditions.checkState( + entry.blob().isPresent() != entry.content().isPresent(), + "Exactly one of blob or content must be set"); + return entry; + } + } +} diff --git a/core/src/main/java/com/google/adk/artifacts/ArtifactVersion.java b/core/src/main/java/com/google/adk/artifacts/ArtifactVersion.java new file mode 100644 index 000000000..6e153d24d --- /dev/null +++ b/core/src/main/java/com/google/adk/artifacts/ArtifactVersion.java @@ -0,0 +1,71 @@ +/* + * 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.artifacts; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableMap; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * Metadata describing a specific version of an artifact. + * + *

This describes a stored payload without being one, so a caller can learn where a version + * lives, what type it is and what was attached to it without downloading it. + */ +@AutoValue +public abstract class ArtifactVersion { + + ArtifactVersion() {} + + /** Monotonically increasing identifier for the artifact version. */ + public abstract int version(); + + /** Canonical URI referencing the persisted artifact payload. */ + public abstract String canonicalUri(); + + /** Metadata the caller attached to the artifact, empty when none was attached. */ + public abstract ImmutableMap customMetadata(); + + /** When the version record was created. */ + public abstract Instant createTime(); + + /** MIME type of the payload, empty when the store does not know it. */ + public abstract Optional mimeType(); + + public static Builder builder() { + return new AutoValue_ArtifactVersion.Builder().customMetadata(ImmutableMap.of()); + } + + /** Builder for {@link ArtifactVersion}. */ + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder version(int version); + + public abstract Builder canonicalUri(String canonicalUri); + + public abstract Builder customMetadata(Map customMetadata); + + public abstract Builder createTime(Instant createTime); + + public abstract Builder mimeType(@Nullable String mimeType); + + public abstract ArtifactVersion build(); + } +} diff --git a/core/src/main/java/com/google/adk/events/UiWidget.java b/core/src/main/java/com/google/adk/events/UiWidget.java new file mode 100644 index 000000000..33febe295 --- /dev/null +++ b/core/src/main/java/com/google/adk/events/UiWidget.java @@ -0,0 +1,105 @@ +/* + * 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.events; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.adk.JsonBaseModel; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableMap; +import java.util.Map; + +/** + * Rendering metadata for a UI widget associated with an event. + * + *

An agent emits one of these to ask the UI to draw something richer than text. The UI picks a + * renderer from the named provider and hands it the payload. + */ +@AutoValue +@JsonDeserialize(builder = UiWidget.Builder.class) +public abstract class UiWidget extends JsonBaseModel { + + UiWidget() {} + + /** Returns the unique identifier of the UI widget. */ + @JsonProperty("id") + public abstract String id(); + + /** + * Returns the widget provider identifier, which determines the rendering strategy the UI uses. + * + *

Known values: + * + *

+ */ + @JsonProperty("provider") + public abstract String provider(); + + /** + * Returns the provider-specific data required for rendering, empty when the provider needs none. + * + *

If the provider is {@code mcp}, the payload holds {@code resource_uri}, {@code tool} and + * {@code tool_args}. Future providers bring their own fields, so the keys are the provider's + * rather than this SDK's. + */ + @JsonProperty("payload") + public abstract ImmutableMap payload(); + + /** Returns a new builder for creating a {@link UiWidget}. */ + public static Builder builder() { + return new AutoValue_UiWidget.Builder().payload(ImmutableMap.of()); + } + + /** Returns a new builder with a copy of this widget's values. */ + public abstract Builder toBuilder(); + + /** Builder for {@link UiWidget}. */ + @AutoValue.Builder + public abstract static class Builder { + + @JsonCreator + static Builder create() { + return UiWidget.builder(); + } + + /** + * Sets the unique identifier of the UI widget. + * + *

This is a required field. + */ + @JsonProperty("id") + public abstract Builder id(String id); + + /** + * Sets the widget provider identifier. + * + *

This is a required field. + */ + @JsonProperty("provider") + public abstract Builder provider(String provider); + + /** Sets the provider-specific data required for rendering, copying the given map. */ + @JsonProperty("payload") + public abstract Builder payload(Map payload); + + /** Builds the immutable {@link UiWidget} object. */ + public abstract UiWidget build(); + } +} diff --git a/core/src/main/java/com/google/adk/models/CacheMetadata.java b/core/src/main/java/com/google/adk/models/CacheMetadata.java new file mode 100644 index 000000000..d9755d670 --- /dev/null +++ b/core/src/main/java/com/google/adk/models/CacheMetadata.java @@ -0,0 +1,165 @@ +/* + * 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.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.google.adk.JsonBaseModel; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * Metadata for the context cache associated with LLM responses. + * + *

Holds the identification, usage tracking and lifecycle information for one cache instance. A + * record is in one of two states, and never in between: + * + *

    + *
  1. fingerprint-only, where {@link #cacheName()} is absent and only {@link #fingerprint()} and + * {@link #contentsCount()} describe the cacheable prefix that was hashed for prefix matching; + *
  2. active, where a cache exists and its resource name, its expiry and the number of + * invocations it has served are known as well. + *
+ * + *

{@link Builder#build()} refuses any mixture of the two. The creation time is optional in both + * states. + * + *

Token counts are carried by the LLM response's usage metadata and are deliberately not + * duplicated here. + */ +@AutoValue +@JsonDeserialize(builder = CacheMetadata.Builder.class) +public abstract class CacheMetadata extends JsonBaseModel { + + /** How long before its expiry an active cache is already considered due for a refresh. */ + private static final Duration REFRESH_BUFFER = Duration.ofMinutes(2); + + CacheMetadata() {} + + /** + * Full resource name of the cached content, for example {@code + * projects/123/locations/us-central1/cachedContents/456}. + * + *

Absent in the fingerprint-only state. + */ + @JsonProperty("cacheName") + public abstract Optional cacheName(); + + /** When the cache expires. Absent in the fingerprint-only state. */ + @JsonProperty("expireTime") + public abstract Optional expireTime(); + + /** Hash of the cacheable contents (instruction, tools and contents), used to detect changes. */ + @JsonProperty("fingerprint") + public abstract String fingerprint(); + + /** + * Number of invocations this cache has served. Absent in the fingerprint-only state. + * + *

Never negative. + */ + @JsonProperty("invocationsUsed") + public abstract Optional invocationsUsed(); + + /** + * Number of contents behind the fingerprint: the cached contents when a cache exists, the + * cacheable prefix otherwise. + * + *

Never negative. + */ + @JsonProperty("contentsCount") + public abstract int contentsCount(); + + /** When the cache was created. Optional in both states. */ + @JsonProperty("createdAt") + public abstract Optional createdAt(); + + /** + * Returns whether an active cache is close enough to its expiry that it should be refreshed + * rather than reused, allowing a buffer for the time the request itself takes. + * + *

Always false in the fingerprint-only state, where there is no cache to expire. + */ + public boolean expireSoon() { + return expireTime() + .map(expiry -> Instant.now().isAfter(expiry.minus(REFRESH_BUFFER))) + .orElse(false); + } + + public abstract Builder toBuilder(); + + public static Builder builder() { + return new AutoValue_CacheMetadata.Builder(); + } + + /** Builder for constructing {@link CacheMetadata} instances. */ + @AutoValue.Builder + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public abstract static class Builder { + + @JsonCreator + static Builder jacksonBuilder() { + return CacheMetadata.builder(); + } + + @JsonProperty("cacheName") + public abstract Builder cacheName(@Nullable String cacheName); + + @JsonProperty("expireTime") + public abstract Builder expireTime(@Nullable Instant expireTime); + + @JsonProperty("fingerprint") + public abstract Builder fingerprint(String fingerprint); + + @JsonProperty("invocationsUsed") + public abstract Builder invocationsUsed(@Nullable Integer invocationsUsed); + + @JsonProperty("contentsCount") + public abstract Builder contentsCount(int contentsCount); + + @JsonProperty("createdAt") + public abstract Builder createdAt(@Nullable Instant createdAt); + + abstract CacheMetadata autoBuild(); + + public final CacheMetadata build() { + CacheMetadata metadata = autoBuild(); + Preconditions.checkState( + metadata.cacheName().isPresent() == metadata.expireTime().isPresent() + && metadata.expireTime().isPresent() == metadata.invocationsUsed().isPresent(), + "cacheName, expireTime and invocationsUsed must all be set (active cache) or all be" + + " absent (fingerprint-only state)"); + Preconditions.checkArgument( + metadata.contentsCount() >= 0, + "contentsCount must not be negative, but was %s", + metadata.contentsCount()); + metadata + .invocationsUsed() + .ifPresent( + used -> + Preconditions.checkArgument( + used >= 0, "invocationsUsed must not be negative, but was %s", used)); + return metadata; + } + } +} diff --git a/core/src/main/java/com/google/adk/planners/BasePlanner.java b/core/src/main/java/com/google/adk/planners/BasePlanner.java new file mode 100644 index 000000000..fba68e229 --- /dev/null +++ b/core/src/main/java/com/google/adk/planners/BasePlanner.java @@ -0,0 +1,53 @@ +/* + * 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.planners; + +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.ReadonlyContext; +import com.google.adk.models.LlmRequest; +import com.google.common.collect.ImmutableList; +import com.google.genai.types.Part; +import java.util.List; +import java.util.Optional; + +/** + * Base interface for all planners. + * + *

The planner allows the agent to generate plans for the queries to guide its action. + */ +public interface BasePlanner { + + /** + * Builds the system instruction to be appended to the LLM request for planning. + * + * @param readonlyContext The readonly context of the invocation. + * @param llmRequest The LLM request. Readonly. + * @return The planning system instruction, or empty if no instruction is needed. + */ + Optional buildPlanningInstruction(ReadonlyContext readonlyContext, LlmRequest llmRequest); + + /** + * Processes the LLM response for planning. + * + * @param callbackContext The callback context of the invocation. Anything the planner writes to + * its state becomes a state delta on the invocation. + * @param responseParts The LLM response parts. Readonly. + * @return The processed response parts, or empty if no processing is needed. + */ + Optional> processPlanningResponse( + CallbackContext callbackContext, List responseParts); +} diff --git a/core/src/main/java/com/google/adk/sessions/StateSchemaException.java b/core/src/main/java/com/google/adk/sessions/StateSchemaException.java new file mode 100644 index 000000000..ee2a4c0d7 --- /dev/null +++ b/core/src/main/java/com/google/adk/sessions/StateSchemaException.java @@ -0,0 +1,34 @@ +/* + * 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.sessions; + +/** + * Thrown when a write to session state is refused by the schema that state was declared with. + * + *

A {@link SessionException}, like every other failure this package raises, so that a caller + * already handling session failures catches this one too without knowing the type exists. + */ +public class StateSchemaException extends SessionException { + + public StateSchemaException(String message) { + super(message); + } + + public StateSchemaException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/test/java/com/google/adk/agents/TranscriptionEntryTest.java b/core/src/test/java/com/google/adk/agents/TranscriptionEntryTest.java new file mode 100644 index 000000000..d84ba39da --- /dev/null +++ b/core/src/test/java/com/google/adk/agents/TranscriptionEntryTest.java @@ -0,0 +1,93 @@ +/* + * 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.agents; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.genai.types.Blob; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class TranscriptionEntryTest { + + private static final Blob AUDIO = + Blob.builder().mimeType("audio/pcm").data(new byte[] {1, 2, 3}).build(); + private static final Content TEXT = Content.builder().parts(Part.fromText("hello")).build(); + + @Test + public void build_withAudio_leavesContentEmpty() { + TranscriptionEntry entry = TranscriptionEntry.builder().role("user").blob(AUDIO).build(); + + assertThat(entry.role()).hasValue("user"); + assertThat(entry.blob()).hasValue(AUDIO); + assertThat(entry.content()).isEmpty(); + } + + @Test + public void build_withText_leavesBlobEmpty() { + TranscriptionEntry entry = TranscriptionEntry.builder().role("model").content(TEXT).build(); + + assertThat(entry.content()).hasValue(TEXT); + assertThat(entry.blob()).isEmpty(); + } + + @Test + public void build_withoutRole_leavesRoleEmpty() { + TranscriptionEntry entry = TranscriptionEntry.builder().blob(AUDIO).build(); + + assertThat(entry.role()).isEmpty(); + } + + @Test + public void build_withNeitherBlobNorContent_throws() { + TranscriptionEntry.Builder builder = TranscriptionEntry.builder().role("user"); + + IllegalStateException e = assertThrows(IllegalStateException.class, builder::build); + assertThat(e).hasMessageThat().contains("Exactly one of blob or content must be set"); + } + + @Test + public void build_withBothBlobAndContent_throws() { + TranscriptionEntry.Builder builder = TranscriptionEntry.builder().blob(AUDIO).content(TEXT); + + IllegalStateException e = assertThrows(IllegalStateException.class, builder::build); + assertThat(e).hasMessageThat().contains("Exactly one of blob or content must be set"); + } + + @Test + public void toBuilder_clearingBlobAndSettingContent_swapsTheData() { + TranscriptionEntry audioEntry = TranscriptionEntry.builder().role("user").blob(AUDIO).build(); + + TranscriptionEntry textEntry = audioEntry.toBuilder().blob(null).content(TEXT).build(); + + assertThat(textEntry.role()).hasValue("user"); + assertThat(textEntry.blob()).isEmpty(); + assertThat(textEntry.content()).hasValue(TEXT); + } + + @Test + public void toBuilder_createsBuilderWithSameValues() { + TranscriptionEntry entry = TranscriptionEntry.builder().role("user").blob(AUDIO).build(); + + assertThat(entry.toBuilder().build()).isEqualTo(entry); + } +} diff --git a/core/src/test/java/com/google/adk/artifacts/ArtifactVersionTest.java b/core/src/test/java/com/google/adk/artifacts/ArtifactVersionTest.java new file mode 100644 index 000000000..90c04a069 --- /dev/null +++ b/core/src/test/java/com/google/adk/artifacts/ArtifactVersionTest.java @@ -0,0 +1,83 @@ +/* + * 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.artifacts; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableMap; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class ArtifactVersionTest { + + private static final Instant CREATE_TIME = Instant.ofEpochSecond(1_700_000_000L); + + private static ArtifactVersion.Builder minimalBuilder() { + return ArtifactVersion.builder() + .version(3) + .canonicalUri("gs://bucket/report.pdf#3") + .createTime(CREATE_TIME); + } + + @Test + public void builder_omittingOptionalFields_leavesThemEmpty() { + ArtifactVersion artifactVersion = minimalBuilder().build(); + + assertThat(artifactVersion.customMetadata()).isEmpty(); + assertThat(artifactVersion.mimeType()).isEmpty(); + } + + @Test + public void builder_setsValues() { + ArtifactVersion artifactVersion = + minimalBuilder() + .mimeType("application/pdf") + .customMetadata(ImmutableMap.of("source", "tool")) + .build(); + + assertThat(artifactVersion.version()).isEqualTo(3); + assertThat(artifactVersion.canonicalUri()).isEqualTo("gs://bucket/report.pdf#3"); + assertThat(artifactVersion.createTime()).isEqualTo(CREATE_TIME); + assertThat(artifactVersion.mimeType()).hasValue("application/pdf"); + assertThat(artifactVersion.customMetadata()).containsExactly("source", "tool"); + } + + @Test + public void customMetadata_isCopiedFromTheCallersMap() { + Map callerMap = new HashMap<>(); + callerMap.put("source", "tool"); + + ArtifactVersion artifactVersion = minimalBuilder().customMetadata(callerMap).build(); + callerMap.put("source", "mutated"); + + assertThat(artifactVersion.customMetadata()).containsExactly("source", "tool"); + } + + @Test + public void build_withoutCanonicalUri_throws() { + ArtifactVersion.Builder builder = ArtifactVersion.builder().version(1).createTime(CREATE_TIME); + + IllegalStateException e = assertThrows(IllegalStateException.class, builder::build); + assertThat(e).hasMessageThat().contains("canonicalUri"); + } +} diff --git a/core/src/test/java/com/google/adk/events/UiWidgetTest.java b/core/src/test/java/com/google/adk/events/UiWidgetTest.java new file mode 100644 index 000000000..281d1aa25 --- /dev/null +++ b/core/src/test/java/com/google/adk/events/UiWidgetTest.java @@ -0,0 +1,73 @@ +/* + * 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.events; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.adk.JsonBaseModel; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class UiWidgetTest { + + @Test + public void builder_omittingPayload_leavesItEmpty() { + UiWidget widget = UiWidget.builder().id("w1").provider("mcp").build(); + + assertThat(widget.payload()).isEmpty(); + } + + @Test + public void build_withoutProvider_throws() { + UiWidget.Builder builder = UiWidget.builder().id("w1"); + + IllegalStateException e = assertThrows(IllegalStateException.class, builder::build); + assertThat(e).hasMessageThat().contains("provider"); + } + + @Test + public void toBuilder_createsBuilderWithSameValues() { + UiWidget widget = + UiWidget.builder() + .id("w1") + .provider("mcp") + .payload(ImmutableMap.of("resource_uri", "ui://chart")) + .build(); + + assertThat(widget.toBuilder().build()).isEqualTo(widget); + } + + @Test + public void jsonRoundTrip_keepsProviderPayloadKeysVerbatim() throws Exception { + UiWidget widget = + UiWidget.builder() + .id("w1") + .provider("mcp") + .payload(ImmutableMap.of("resource_uri", "ui://chart", "tool_args", "{}")) + .build(); + + String json = JsonBaseModel.getMapper().writeValueAsString(widget); + UiWidget deserialized = JsonBaseModel.getMapper().readValue(json, UiWidget.class); + + assertThat(deserialized).isEqualTo(widget); + assertThat(deserialized.payload().keySet()).containsExactly("resource_uri", "tool_args"); + } +} diff --git a/core/src/test/java/com/google/adk/models/CacheMetadataTest.java b/core/src/test/java/com/google/adk/models/CacheMetadataTest.java new file mode 100644 index 000000000..2e61fba3f --- /dev/null +++ b/core/src/test/java/com/google/adk/models/CacheMetadataTest.java @@ -0,0 +1,135 @@ +/* + * 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.models; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import java.time.Duration; +import java.time.Instant; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CacheMetadataTest { + + private static final String CACHE_NAME = "projects/123/locations/us-central1/cachedContents/456"; + + private static CacheMetadata.Builder fingerprintOnlyBuilder() { + return CacheMetadata.builder().fingerprint("abc123").contentsCount(4); + } + + private static CacheMetadata.Builder activeBuilder(Instant expireTime) { + return fingerprintOnlyBuilder().cacheName(CACHE_NAME).expireTime(expireTime).invocationsUsed(2); + } + + @Test + public void build_fingerprintOnly_leavesTheActiveFieldsEmpty() { + CacheMetadata metadata = fingerprintOnlyBuilder().build(); + + assertThat(metadata.fingerprint()).isEqualTo("abc123"); + assertThat(metadata.contentsCount()).isEqualTo(4); + assertThat(metadata.cacheName()).isEmpty(); + assertThat(metadata.expireTime()).isEmpty(); + assertThat(metadata.invocationsUsed()).isEmpty(); + assertThat(metadata.createdAt()).isEmpty(); + } + + @Test + public void build_active_setsTheActiveFields() { + Instant expireTime = Instant.ofEpochSecond(1_700_000_000L); + + CacheMetadata metadata = activeBuilder(expireTime).build(); + + assertThat(metadata.cacheName()).hasValue(CACHE_NAME); + assertThat(metadata.expireTime()).hasValue(expireTime); + assertThat(metadata.invocationsUsed()).hasValue(2); + } + + @Test + public void build_withCacheNameButNoExpireTime_throws() { + CacheMetadata.Builder builder = + fingerprintOnlyBuilder().cacheName(CACHE_NAME).invocationsUsed(2); + + IllegalStateException e = assertThrows(IllegalStateException.class, builder::build); + assertThat(e).hasMessageThat().contains("fingerprint-only state"); + } + + @Test + public void build_withExpireTimeButNoCacheName_throws() { + CacheMetadata.Builder builder = + fingerprintOnlyBuilder().expireTime(Instant.ofEpochSecond(1L)).invocationsUsed(2); + + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + public void build_withNegativeContentsCount_throws() { + CacheMetadata.Builder builder = CacheMetadata.builder().fingerprint("abc123").contentsCount(-1); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + assertThat(e).hasMessageThat().contains("contentsCount must not be negative"); + } + + @Test + public void build_withNegativeInvocationsUsed_throws() { + CacheMetadata.Builder builder = + fingerprintOnlyBuilder() + .cacheName(CACHE_NAME) + .expireTime(Instant.ofEpochSecond(1L)) + .invocationsUsed(-1); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + assertThat(e).hasMessageThat().contains("invocationsUsed must not be negative"); + } + + @Test + public void expireSoon_isTrueInsideTheRefreshBuffer() { + CacheMetadata metadata = activeBuilder(Instant.now().plus(Duration.ofMinutes(1))).build(); + + assertThat(metadata.expireSoon()).isTrue(); + } + + @Test + public void expireSoon_isFalseOutsideTheRefreshBuffer() { + CacheMetadata metadata = activeBuilder(Instant.now().plus(Duration.ofMinutes(10))).build(); + + assertThat(metadata.expireSoon()).isFalse(); + } + + @Test + public void expireSoon_isFalseInTheFingerprintOnlyState() { + CacheMetadata metadata = fingerprintOnlyBuilder().build(); + + assertThat(metadata.expireSoon()).isFalse(); + } + + @Test + public void jsonRoundTrip_preservesBothStates() { + CacheMetadata active = + activeBuilder(Instant.ofEpochSecond(1_700_000_000L)) + .createdAt(Instant.ofEpochSecond(1_699_999_000L)) + .build(); + CacheMetadata fingerprintOnly = fingerprintOnlyBuilder().build(); + + assertThat(CacheMetadata.fromJsonString(active.toJson(), CacheMetadata.class)) + .isEqualTo(active); + assertThat(CacheMetadata.fromJsonString(fingerprintOnly.toJson(), CacheMetadata.class)) + .isEqualTo(fingerprintOnly); + } +} diff --git a/core/src/test/java/com/google/adk/sessions/StateSchemaExceptionTest.java b/core/src/test/java/com/google/adk/sessions/StateSchemaExceptionTest.java new file mode 100644 index 000000000..90bc0ceb8 --- /dev/null +++ b/core/src/test/java/com/google/adk/sessions/StateSchemaExceptionTest.java @@ -0,0 +1,53 @@ +/* + * 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.sessions; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * The supertype is the whole contract of {@link StateSchemaException}: a caller that already + * handles session failures must catch a schema rejection without naming the type. + */ +@RunWith(JUnit4.class) +public final class StateSchemaExceptionTest { + + @Test + public void isCaughtAsSessionException() { + Throwable caught = null; + try { + throw new StateSchemaException("key 'total' is not declared in the state schema"); + } catch (SessionException e) { + caught = e; + } + + assertThat(caught).isInstanceOf(StateSchemaException.class); + assertThat(caught).hasMessageThat().contains("not declared in the state schema"); + } + + @Test + public void keepsTheCause() { + Exception cause = new IllegalArgumentException("bad value"); + + StateSchemaException e = new StateSchemaException("rejected", cause); + + assertThat(e).hasCauseThat().isSameInstanceAs(cause); + } +}