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:
+ *
+ *
+ * - {@code mcp}: MCP App iframe, rendered with the MCP Apps AppBridge.
+ *
+ */
+ @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:
+ *
+ *
+ * - fingerprint-only, where {@link #cacheName()} is absent and only {@link #fingerprint()} and
+ * {@link #contentsCount()} describe the cacheable prefix that was hashed for prefix matching;
+ *
- 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);
+ }
+}