From e823fbac72f8ab2829a0a128686047fb1f3dc420 Mon Sep 17 00:00:00 2001 From: Damian Momot Date: Thu, 8 Oct 2026 06:33:23 -0700 Subject: [PATCH] feat: add the workflow fields Event.output, Event.nodeInfo and EventActions.route PiperOrigin-RevId: 995797763 --- .../java/com/google/adk/events/Event.java | 110 ++++++++++++++---- .../com/google/adk/events/EventActions.java | 43 ++++++- .../com/google/adk/workflow/NodeInfo.java | 98 ++++++++++++++++ .../java/com/google/adk/workflow/Route.java | 85 ++++++++++++++ .../google/adk/events/EventActionsTest.java | 61 ++++++++++ .../java/com/google/adk/events/EventTest.java | 70 +++++++++++ .../com/google/adk/workflow/NodeInfoTest.java | 66 +++++++++++ .../com/google/adk/workflow/RouteTest.java | 62 ++++++++++ 8 files changed, 568 insertions(+), 27 deletions(-) create mode 100644 core/src/main/java/com/google/adk/workflow/NodeInfo.java create mode 100644 core/src/main/java/com/google/adk/workflow/Route.java create mode 100644 core/src/test/java/com/google/adk/workflow/NodeInfoTest.java create mode 100644 core/src/test/java/com/google/adk/workflow/RouteTest.java diff --git a/core/src/main/java/com/google/adk/events/Event.java b/core/src/main/java/com/google/adk/events/Event.java index 0a654c4c1..5b63be76f 100644 --- a/core/src/main/java/com/google/adk/events/Event.java +++ b/core/src/main/java/com/google/adk/events/Event.java @@ -23,7 +23,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.adk.JsonBaseModel; +import com.google.adk.annotations.Experimental; import com.google.adk.platform.UuidProvider; +import com.google.adk.workflow.NodeInfo; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -66,6 +68,8 @@ public class Event extends JsonBaseModel { private @Nullable String modelVersion; private @Nullable Transcription inputTranscription; private @Nullable Transcription outputTranscription; + private @Nullable Object output; + private @Nullable NodeInfo nodeInfo; private long timestamp; @@ -306,6 +310,38 @@ public void setOutputTranscription(@Nullable Transcription outputTranscription) this.outputTranscription = outputTranscription; } + /** + * For a workflow node, returns the value handed to successors, distinct from {@link #content()}, + * or empty when the event carries no node output. Holds any value Jackson can serialize, such as + * a JSON-native value or a {@link Content}. An event read from JSON holds the JSON-native form (a + * map with string keys, a list, a string, a number, or a boolean). + */ + @Experimental + @JsonProperty("output") + public Optional output() { + return Optional.ofNullable(output); + } + + @Experimental + public void setOutput(@Nullable Object output) { + this.output = output; + } + + /** + * Identifies the workflow-node activation that emitted this event; outside a workflow, this value + * is empty or has an empty path. + */ + @Experimental + @JsonProperty("nodeInfo") + public Optional nodeInfo() { + return Optional.ofNullable(nodeInfo); + } + + @Experimental + public void setNodeInfo(@Nullable NodeInfo nodeInfo) { + this.nodeInfo = nodeInfo; + } + /** The timestamp of the event. */ @JsonProperty("timestamp") public long timestamp() { @@ -415,6 +451,8 @@ public static class Builder { private @Nullable String modelVersion; private @Nullable Transcription inputTranscription; private @Nullable Transcription outputTranscription; + private @Nullable Object output; + private @Nullable NodeInfo nodeInfo; private @Nullable Long timestamp; @JsonCreator @@ -592,6 +630,22 @@ public Builder outputTranscription(@Nullable Transcription value) { return this; } + @Experimental + @CanIgnoreReturnValue + @JsonProperty("output") + public Builder output(@Nullable Object value) { + this.output = value; + return this; + } + + @Experimental + @CanIgnoreReturnValue + @JsonProperty("nodeInfo") + public Builder nodeInfo(@Nullable NodeInfo value) { + this.nodeInfo = value; + return this; + } + public Event build() { Event event = new Event(); event.setId(id); @@ -616,6 +670,8 @@ public Event build() { timestamp().orElseGet(() -> InstantSource.system().instant().toEpochMilli())); event.setInputTranscription(inputTranscription); event.setOutputTranscription(outputTranscription); + event.setOutput(output); + event.setNodeInfo(nodeInfo); return event; } } @@ -631,30 +687,30 @@ public static Event fromJson(String json) { /** Creates a builder pre-filled with this event's values. */ public Builder toBuilder() { - Builder builder = - new Builder() - .id(this.id) - .invocationId(this.invocationId) - .author(this.author) - .content(this.content) - .actions(this.actions) - .longRunningToolIds(this.longRunningToolIds) - .partial(this.partial) - .turnComplete(this.turnComplete) - .errorCode(this.errorCode) - .errorMessage(this.errorMessage) - .finishReason(this.finishReason) - .usageMetadata(this.usageMetadata) - .avgLogprobs(this.avgLogprobs) - .interrupted(this.interrupted) - .branch(this.branch) - .groundingMetadata(this.groundingMetadata) - .customMetadata(this.customMetadata) - .modelVersion(this.modelVersion) - .inputTranscription(this.inputTranscription) - .outputTranscription(this.outputTranscription) - .timestamp(this.timestamp); - return builder; + return new Builder() + .id(this.id) + .invocationId(this.invocationId) + .author(this.author) + .content(this.content) + .actions(this.actions) + .longRunningToolIds(this.longRunningToolIds) + .partial(this.partial) + .turnComplete(this.turnComplete) + .errorCode(this.errorCode) + .errorMessage(this.errorMessage) + .finishReason(this.finishReason) + .usageMetadata(this.usageMetadata) + .avgLogprobs(this.avgLogprobs) + .interrupted(this.interrupted) + .branch(this.branch) + .groundingMetadata(this.groundingMetadata) + .customMetadata(this.customMetadata) + .modelVersion(this.modelVersion) + .inputTranscription(this.inputTranscription) + .outputTranscription(this.outputTranscription) + .output(this.output) + .nodeInfo(this.nodeInfo) + .timestamp(this.timestamp); } @Override @@ -685,7 +741,9 @@ public boolean equals(Object obj) { && Objects.equals(customMetadata, other.customMetadata) && Objects.equals(modelVersion, other.modelVersion) && Objects.equals(inputTranscription, other.inputTranscription) - && Objects.equals(outputTranscription, other.outputTranscription); + && Objects.equals(outputTranscription, other.outputTranscription) + && Objects.equals(output, other.output) + && Objects.equals(nodeInfo, other.nodeInfo); } @Override @@ -716,6 +774,8 @@ public int hashCode() { modelVersion, inputTranscription, outputTranscription, + output, + nodeInfo, timestamp); } } diff --git a/core/src/main/java/com/google/adk/events/EventActions.java b/core/src/main/java/com/google/adk/events/EventActions.java index 964733705..eb4ba5786 100644 --- a/core/src/main/java/com/google/adk/events/EventActions.java +++ b/core/src/main/java/com/google/adk/events/EventActions.java @@ -15,13 +15,18 @@ */ package com.google.adk.events; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.adk.JsonBaseModel; +import com.google.adk.annotations.Experimental; import com.google.adk.sessions.State; +import com.google.adk.workflow.Route; +import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -47,6 +52,7 @@ public class EventActions extends JsonBaseModel { private @Nullable EventCompaction compaction; private @Nullable Object setModelResponse; private @Nullable String rewindBeforeInvocationId; + private @Nullable ImmutableList route; /** Default constructor for Jackson. */ public EventActions() { @@ -72,6 +78,7 @@ private EventActions(Builder builder) { this.compaction = builder.compaction; this.setModelResponse = builder.setModelResponse; this.rewindBeforeInvocationId = builder.rewindBeforeInvocationId; + this.route = builder.route; } @JsonProperty("skipSummarization") @@ -252,6 +259,23 @@ public void setRewindBeforeInvocationId(@Nullable String rewindBeforeInvocationI this.rewindBeforeInvocationId = rewindBeforeInvocationId; } + /** + * For a workflow node, returns the routes this event selects; an edge is followed when it has one + * of these routes. The value is optional because an empty list is still a routing decision that + * selects no route, whereas an absent value means the event makes no routing decision. When + * merging, an empty list replaces the existing routes, while an absent value keeps them. + */ + @Experimental + @JsonProperty("route") + public Optional> route() { + return Optional.ofNullable(route); + } + + @Experimental + public void setRoute(@Nullable List route) { + this.route = route == null ? null : ImmutableList.copyOf(route); + } + public static Builder builder() { return new Builder(); } @@ -280,7 +304,8 @@ public boolean equals(Object o) { && Objects.equals(agentState, that.agentState) && Objects.equals(compaction, that.compaction) && Objects.equals(setModelResponse, that.setModelResponse) - && Objects.equals(rewindBeforeInvocationId, that.rewindBeforeInvocationId); + && Objects.equals(rewindBeforeInvocationId, that.rewindBeforeInvocationId) + && Objects.equals(route, that.route); } @Override @@ -298,7 +323,8 @@ public int hashCode() { agentState, compaction, setModelResponse, - rewindBeforeInvocationId); + rewindBeforeInvocationId, + route); } /** Builder for {@link EventActions}. */ @@ -316,6 +342,7 @@ public static class Builder { private @Nullable EventCompaction compaction; private @Nullable Object setModelResponse; private @Nullable String rewindBeforeInvocationId; + private @Nullable ImmutableList route; public Builder() { this.stateDelta = new ConcurrentHashMap<>(); @@ -340,6 +367,7 @@ private Builder(EventActions eventActions) { this.compaction = eventActions.compaction; this.setModelResponse = eventActions.setModelResponse; this.rewindBeforeInvocationId = eventActions.rewindBeforeInvocationId; + this.route = eventActions.route; } @CanIgnoreReturnValue @@ -467,6 +495,16 @@ public Builder rewindBeforeInvocationId(@Nullable String value) { return this; } + // ADK Python writes a single route as a bare value rather than a one-element list. + @Experimental + @CanIgnoreReturnValue + @JsonProperty("route") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public Builder route(@Nullable List value) { + this.route = value == null ? null : ImmutableList.copyOf(value); + return this; + } + @CanIgnoreReturnValue public Builder merge(EventActions other) { other.skipSummarization().ifPresent(this::skipSummarization); @@ -482,6 +520,7 @@ public Builder merge(EventActions other) { other.compaction().ifPresent(this::compaction); other.setModelResponse().ifPresent(this::setModelResponse); other.rewindBeforeInvocationId().ifPresent(this::rewindBeforeInvocationId); + other.route().ifPresent(this::route); return this; } diff --git a/core/src/main/java/com/google/adk/workflow/NodeInfo.java b/core/src/main/java/com/google/adk/workflow/NodeInfo.java new file mode 100644 index 000000000..9b8afc564 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/NodeInfo.java @@ -0,0 +1,98 @@ +/* + * 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.workflow; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.adk.annotations.Experimental; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.List; + +/** + * The identity of the workflow-node activation that emitted an {@link com.google.adk.events.Event}. + */ +@Experimental +@AutoValue +@JsonDeserialize(builder = NodeInfo.Builder.class) +public abstract class NodeInfo { + + /** + * Returns the emitting node's path. Segments are {@code /}-separated and each is {@code + * name@runId}, so a node of workflow {@code wf} reads {@code wf@1/a@1}; an empty path means the + * event did not come from a workflow node. + */ + @JsonProperty("path") + public abstract String path(); + + /** + * Returns the node paths this event's output counts for: the emitting node's path, followed by + * the paths of the ancestor workflows that use this event as their output. It is set on an event + * that carries the node's output, either in its {@code output} field or as a message-as-output + * event whose content is the output; an empty list means the output counts for no node. + */ + @JsonProperty("outputFor") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public abstract ImmutableList outputFor(); + + /** + * Returns {@code true} if this event's content is the node's output, so no separate output event + * follows. + */ + @JsonProperty("messageAsOutput") + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public abstract boolean messageAsOutput(); + + public static Builder builder() { + return new AutoValue_NodeInfo.Builder() + .path("") + .outputFor(ImmutableList.of()) + .messageAsOutput(false); + } + + public abstract Builder toBuilder(); + + /** Builder for {@link NodeInfo}. */ + @AutoValue.Builder + public abstract static class Builder { + + @JsonCreator + static Builder create() { + return builder(); + } + + @CanIgnoreReturnValue + @JsonProperty("path") + public abstract Builder path(String path); + + @CanIgnoreReturnValue + @JsonProperty("outputFor") + @JsonSetter(nulls = Nulls.AS_EMPTY) + public abstract Builder outputFor(List outputFor); + + @CanIgnoreReturnValue + @JsonProperty("messageAsOutput") + public abstract Builder messageAsOutput(boolean messageAsOutput); + + public abstract NodeInfo build(); + } +} diff --git a/core/src/main/java/com/google/adk/workflow/Route.java b/core/src/main/java/com/google/adk/workflow/Route.java new file mode 100644 index 000000000..4a13cbdb5 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/Route.java @@ -0,0 +1,85 @@ +/* + * 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.workflow; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.adk.annotations.Experimental; + +/** + * A value on a workflow edge, and the value a node emits to select which of its outgoing edges are + * followed. An edge fires when the value it carries matches one the node emitted. + * + *

On the wire a route is a bare scalar (a string, integer or boolean), and {@link Default} is + * the {@link #DEFAULT_ROUTE_SENTINEL} string. + */ +@Experimental +public sealed interface Route permits Route.Tag, Route.Num, Route.Flag, Route.Default { + + /** The string that stands for {@link Default} on the wire. */ + String DEFAULT_ROUTE_SENTINEL = "__DEFAULT__"; + + /** + * A route identified by a string, which is the common case. Its value cannot be {@link + * #DEFAULT_ROUTE_SENTINEL}; use {@link Default} for the default route. + */ + record Tag(@JsonValue String value) implements Route { + public Tag { + checkNotNull(value); + checkArgument( + !value.equals(DEFAULT_ROUTE_SENTINEL), + "Use Route.Default.INSTANCE for the default route."); + } + } + + /** A route identified by an integer. */ + record Num(@JsonValue long value) implements Route {} + + /** A route identified by a boolean, for a two-way branch. */ + record Flag(@JsonValue boolean value) implements Route {} + + /** + * The fallback edge, followed when no other routed edge of the node matches what it emitted, + * including when it emitted no route. A node may declare several, which all fire together, but a + * default cannot share an edge with a concrete route. + */ + enum Default implements Route { + INSTANCE; + + @JsonValue + String toJson() { + return DEFAULT_ROUTE_SENTINEL; + } + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + private static Route fromJson(Object value) { + if (value instanceof String text) { + return text.equals(DEFAULT_ROUTE_SENTINEL) ? Default.INSTANCE : new Tag(text); + } + if (value instanceof Integer || value instanceof Long) { + return new Num(((Number) value).longValue()); + } + if (value instanceof Boolean flag) { + return new Flag(flag); + } + throw new IllegalArgumentException("A route must be a string, an integer or a boolean."); + } +} diff --git a/core/src/test/java/com/google/adk/events/EventActionsTest.java b/core/src/test/java/com/google/adk/events/EventActionsTest.java index 6de5568ce..ddc040de2 100644 --- a/core/src/test/java/com/google/adk/events/EventActionsTest.java +++ b/core/src/test/java/com/google/adk/events/EventActionsTest.java @@ -20,6 +20,8 @@ import static org.junit.Assert.assertThrows; import com.google.adk.sessions.State; +import com.google.adk.workflow.Route; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.genai.types.Content; @@ -53,6 +55,7 @@ public void toBuilder_createsBuilderWithSameValues() { .skipSummarization(true) .compaction(COMPACTION) .deletedArtifactIds(ImmutableSet.of("d1")) + .route(ImmutableList.of(new Route.Tag("next"))) .build(); EventActions eventActionsAfterRebuild = eventActionsWithSkipSummarization.toBuilder().build(); @@ -91,6 +94,7 @@ public void merge_mergesAllFields() { .endOfAgent(true) .setModelResponse(ImmutableMap.of("field1", "value1")) .rewindBeforeInvocationId("inv1") + .route(ImmutableList.of(new Route.Tag("next"))) .build(); EventActions merged = eventActions1.toBuilder().merge(eventActions2).build(); @@ -113,6 +117,63 @@ public void merge_mergesAllFields() { assertThat(merged.compaction()).hasValue(COMPACTION); assertThat(merged.setModelResponse()).hasValue(ImmutableMap.of("field1", "value1")); assertThat(merged.rewindBeforeInvocationId()).hasValue("inv1"); + assertThat(merged.route()).hasValue(ImmutableList.of(new Route.Tag("next"))); + } + + @Test + public void route_roundTripsThroughJsonAsArrayOfScalars() { + EventActions actions = + EventActions.builder() + .route( + ImmutableList.of( + new Route.Tag("a"), + new Route.Num(1), + new Route.Flag(true), + Route.Default.INSTANCE)) + .build(); + + String json = actions.toJson(); + + assertThat(json).contains("\"route\":[\"a\",1,true,\"__DEFAULT__\"]"); + assertThat(EventActions.fromJsonString(json, EventActions.class).route()) + .isEqualTo(actions.route()); + } + + @Test + public void route_readsBareScalarAsOneRoute() { + EventActions actions = + EventActions.fromJsonString("{\"route\":\"approve\"}", EventActions.class); + + assertThat(actions.route()).hasValue(ImmutableList.of(new Route.Tag("approve"))); + } + + @Test + public void route_absentByDefault() { + assertThat(EventActions.builder().build().route()).isEmpty(); + } + + @Test + public void toJson_omitsAbsentRoute() { + assertThat(EventActions.builder().build().toJson()).doesNotContain("\"route\""); + } + + @Test + public void merge_route_emptyInOther_replacesThis() { + EventActions first = EventActions.builder().route(ImmutableList.of(new Route.Tag("a"))).build(); + EventActions other = EventActions.builder().route(ImmutableList.of()).build(); + + EventActions merged = first.toBuilder().merge(other).build(); + + assertThat(merged.route()).hasValue(ImmutableList.of()); + } + + @Test + public void merge_route_absentInOther_keepsThis() { + EventActions first = EventActions.builder().route(ImmutableList.of(new Route.Tag("a"))).build(); + + EventActions merged = first.toBuilder().merge(EventActions.builder().build()).build(); + + assertThat(merged.route()).hasValue(ImmutableList.of(new Route.Tag("a"))); } @Test diff --git a/core/src/test/java/com/google/adk/events/EventTest.java b/core/src/test/java/com/google/adk/events/EventTest.java index 52e09b071..7b91da2c7 100644 --- a/core/src/test/java/com/google/adk/events/EventTest.java +++ b/core/src/test/java/com/google/adk/events/EventTest.java @@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat; +import com.google.adk.workflow.NodeInfo; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -28,6 +29,7 @@ import com.google.genai.types.Part; import com.google.genai.types.Transcription; import java.time.Instant; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.junit.Test; import org.junit.runner.RunWith; @@ -55,6 +57,9 @@ public final class EventTest { "auth_config_key", new ConcurrentHashMap<>(ImmutableMap.of("auth_key", "auth_value"))))) .build(); + private static final NodeInfo NODE_INFO = + NodeInfo.builder().path("wf@1/a@1").outputFor(ImmutableList.of("wf@1/a@1")).build(); + private static final Event EVENT = Event.builder() .id("event_id") @@ -208,6 +213,67 @@ public void event_json_serialization_works() throws Exception { assertThat(deserializedEvent).isEqualTo(EVENT); } + @Test + public void toBuilder_copiesOutputAndNodeInfo() { + Event event = + EVENT.toBuilder().output(ImmutableMap.of("answer", 42)).nodeInfo(NODE_INFO).build(); + + assertThat(event.toBuilder().build()).isEqualTo(event); + } + + @Test + public void outputAndNodeInfo_roundTripThroughJson() { + Event event = + EVENT.toBuilder().output(ImmutableMap.of("answer", 42)).nodeInfo(NODE_INFO).build(); + + assertThat(Event.fromJson(event.toJson())).isEqualTo(event); + } + + @Test + public void fromJson_readsContentOutputInItsJsonForm() { + Event event = EVENT.toBuilder().output(CONTENT).build(); + + assertThat(Event.fromJson(event.toJson()).output().orElseThrow()).isInstanceOf(Map.class); + } + + @Test + public void outputAndNodeInfo_absentByDefault() { + assertThat(EVENT.output()).isEmpty(); + assertThat(EVENT.nodeInfo()).isEmpty(); + } + + @Test + public void toJson_omitsAbsentOutputAndNodeInfo() { + String json = EVENT.toJson(); + + assertThat(json).doesNotContain("\"output\""); + assertThat(json).doesNotContain("\"nodeInfo\""); + } + + @Test + public void fromJson_keepsAnEmptyNodeInfo() { + Event event = + Event.fromJson( + eventJsonWithNodeInfo("{\"path\":\"\",\"outputFor\":null,\"messageAsOutput\":null}")); + + assertThat(event.nodeInfo()).hasValue(NodeInfo.builder().build()); + } + + @Test + public void fromJson_readsNullNodeInfoAsAbsent() { + Event event = Event.fromJson(eventJsonWithNodeInfo("null")); + + assertThat(event.nodeInfo()).isEmpty(); + } + + @Test + public void json_roundTripsAnEmptyNodeInfo() { + Event event = + Event.builder().id("event_id").author("agent").nodeInfo(NodeInfo.builder().build()).build(); + + assertThat(Event.fromJson(event.toJson())).isEqualTo(event); + } + @Test public void event_builder_with_transcriptions_works() { Transcription inputTranscription = @@ -345,4 +411,8 @@ public void finalResponse_returnsTrueIfSkipSummarization() { .build(); assertThat(event.finalResponse()).isTrue(); } + + private static String eventJsonWithNodeInfo(String nodeInfoJson) { + return "{\"id\":\"event_id\",\"author\":\"agent\",\"nodeInfo\":" + nodeInfoJson + "}"; + } } diff --git a/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java b/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java new file mode 100644 index 000000000..a3ca70c27 --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/NodeInfoTest.java @@ -0,0 +1,66 @@ +/* + * 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.workflow; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.JsonBaseModel; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class NodeInfoTest { + + private static final NodeInfo NODE_INFO = + NodeInfo.builder() + .path("wf@1/a@1") + .outputFor(ImmutableList.of("wf@1/a@1", "wf@1")) + .messageAsOutput(true) + .build(); + + @Test + public void builder_defaultsToEmptyPathAndNoOutput() { + NodeInfo nodeInfo = NodeInfo.builder().build(); + + assertThat(nodeInfo.path()).isEmpty(); + assertThat(nodeInfo.outputFor()).isEmpty(); + assertThat(nodeInfo.messageAsOutput()).isFalse(); + } + + @Test + public void toBuilder_copiesEveryProperty() { + assertThat(NODE_INFO.toBuilder().build()).isEqualTo(NODE_INFO); + } + + @Test + public void json_roundTrips() throws Exception { + String json = JsonBaseModel.toJsonString(NODE_INFO); + + assertThat(JsonBaseModel.getMapper().readValue(json, NodeInfo.class)).isEqualTo(NODE_INFO); + } + + @Test + public void json_omitsUnsetOutputForAndFalseMessageAsOutput() { + String json = JsonBaseModel.toJsonString(NodeInfo.builder().path("a@1").build()); + + assertThat(json).contains("\"path\":\"a@1\""); + assertThat(json).doesNotContain("outputFor"); + assertThat(json).doesNotContain("messageAsOutput"); + } +} diff --git a/core/src/test/java/com/google/adk/workflow/RouteTest.java b/core/src/test/java/com/google/adk/workflow/RouteTest.java new file mode 100644 index 000000000..57ad7271e --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/RouteTest.java @@ -0,0 +1,62 @@ +/* + * 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.workflow; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.google.adk.JsonBaseModel; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class RouteTest { + + @Test + public void toJson_writesBareScalars() { + assertThat(JsonBaseModel.toJsonString(new Route.Tag("approve"))).isEqualTo("\"approve\""); + assertThat(JsonBaseModel.toJsonString(new Route.Num(2))).isEqualTo("2"); + assertThat(JsonBaseModel.toJsonString(new Route.Flag(true))).isEqualTo("true"); + assertThat(JsonBaseModel.toJsonString(Route.Default.INSTANCE)).isEqualTo("\"__DEFAULT__\""); + } + + @Test + public void fromJson_readsBareScalars() throws Exception { + assertThat(read("\"approve\"")).isEqualTo(new Route.Tag("approve")); + assertThat(read("\"__DEFAULT__\"")).isEqualTo(Route.Default.INSTANCE); + assertThat(read("7")).isEqualTo(new Route.Num(7)); + assertThat(read("3000000000")).isEqualTo(new Route.Num(3_000_000_000L)); + assertThat(read("false")).isEqualTo(new Route.Flag(false)); + } + + @Test + public void tag_rejectsTheDefaultSentinel() { + assertThrows(IllegalArgumentException.class, () -> new Route.Tag(Route.DEFAULT_ROUTE_SENTINEL)); + } + + @Test + public void fromJson_rejectsOtherValues() { + assertThrows(JsonMappingException.class, () -> read("1.5")); + assertThrows(JsonMappingException.class, () -> read("{}")); + } + + private static Route read(String json) throws Exception { + return JsonBaseModel.getMapper().readValue(json, Route.class); + } +}