|
87 | 87 | import com.google.common.collect.ImmutableList; |
88 | 88 | import com.google.common.collect.ImmutableMap; |
89 | 89 | import com.google.common.collect.Iterables; |
| 90 | +import com.google.common.truth.Correspondence; |
90 | 91 | import com.google.genai.types.Content; |
91 | 92 | import com.google.genai.types.FunctionCall; |
92 | 93 | import com.google.genai.types.FunctionDeclaration; |
@@ -1101,6 +1102,109 @@ public void runAsync_slowAppendEvent_doesNotCauseStaleSessionInNextStep() throws |
1101 | 1102 | assertThat(foundToolResponse).isTrue(); |
1102 | 1103 | } |
1103 | 1104 |
|
| 1105 | + /** |
| 1106 | + * Ensures a slow appendEvent does not let a transfer target build its request from a stale |
| 1107 | + * session, in either direction: the sub-agent must see the root's transfer, and after the |
| 1108 | + * transfer back the root must see its own transfer response. |
| 1109 | + */ |
| 1110 | + @Test |
| 1111 | + public void runAsync_slowAppendEvent_doesNotCauseStaleSessionInTransferTarget() throws Exception { |
| 1112 | + TestLlm subTestLlm = |
| 1113 | + createTestLlm( |
| 1114 | + createFunctionCallLlmResponse( |
| 1115 | + "call_sub", "transfer_to_agent", ImmutableMap.of("agent_name", "test agent"))); |
| 1116 | + LlmAgent subAgent = createTestAgentBuilder(subTestLlm).name("sub_agent").build(); |
| 1117 | + |
| 1118 | + TestLlm rootTestLlm = |
| 1119 | + createTestLlm( |
| 1120 | + createFunctionCallLlmResponse( |
| 1121 | + "call_root", "transfer_to_agent", ImmutableMap.of("agent_name", "sub_agent")), |
| 1122 | + createTextLlmResponse("done")); |
| 1123 | + |
| 1124 | + LlmAgent rootAgent = createTestAgentBuilder(rootTestLlm).subAgents(subAgent).build(); |
| 1125 | + |
| 1126 | + Runner runnerForRace = |
| 1127 | + Runner.builder() |
| 1128 | + .app(App.builder().name("test").rootAgent(rootAgent).build()) |
| 1129 | + .sessionService(new AppendDelayingSessionService(new InMemorySessionService(), 50)) |
| 1130 | + .build(); |
| 1131 | + Session raceSession = |
| 1132 | + runnerForRace.sessionService().createSession("test", "user").blockingGet(); |
| 1133 | + |
| 1134 | + var unused = |
| 1135 | + runnerForRace |
| 1136 | + .runAsync("user", raceSession.id(), createContent("start")) |
| 1137 | + .toList() |
| 1138 | + .blockingGet(); |
| 1139 | + |
| 1140 | + // The root's transfer reaches the sub-agent as relayed text, since another agent authored it. |
| 1141 | + ImmutableList<LlmRequest> subRequests = subTestLlm.getRequests(); |
| 1142 | + assertThat(subRequests).hasSize(1); |
| 1143 | + assertThat( |
| 1144 | + subRequests.get(0).contents().stream() |
| 1145 | + .flatMap(c -> c.parts().stream().flatMap(List::stream)) |
| 1146 | + .flatMap(part -> part.text().stream()) |
| 1147 | + .collect(toImmutableList())) |
| 1148 | + .comparingElementsUsing( |
| 1149 | + Correspondence.<String, String>from(String::startsWith, "starts with")) |
| 1150 | + .contains("[test agent] `transfer_to_agent` tool returned result:"); |
| 1151 | + |
| 1152 | + // After the transfer back, the root must see its own transfer response. |
| 1153 | + ImmutableList<LlmRequest> rootRequests = rootTestLlm.getRequests(); |
| 1154 | + assertThat(rootRequests).hasSize(2); |
| 1155 | + assertThat( |
| 1156 | + rootRequests.get(1).contents().stream() |
| 1157 | + .flatMap(c -> c.parts().stream().flatMap(List::stream)) |
| 1158 | + .flatMap(part -> part.functionResponse().flatMap(FunctionResponse::id).stream()) |
| 1159 | + .collect(toImmutableList())) |
| 1160 | + .contains("call_root"); |
| 1161 | + assertThat(Iterables.getLast(rootRequests.get(1).contents()).role()).hasValue("user"); |
| 1162 | + } |
| 1163 | + |
| 1164 | + /** |
| 1165 | + * With a slow appendEvent, the append's thread releases the transfer target; the target's {@code |
| 1166 | + * invoke_agent} span must still be a child of the transferring agent's, not start a new trace. |
| 1167 | + */ |
| 1168 | + @Test |
| 1169 | + public void runAsync_slowAppendEvent_transferTargetSpanIsChildOfParentAgentSpan() { |
| 1170 | + LlmAgent subAgent = |
| 1171 | + createTestAgentBuilder(createTestLlm(createTextLlmResponse("sub response"))) |
| 1172 | + .name("sub_agent") |
| 1173 | + .build(); |
| 1174 | + LlmAgent rootAgent = |
| 1175 | + createTestAgentBuilder( |
| 1176 | + createTestLlm( |
| 1177 | + createFunctionCallLlmResponse( |
| 1178 | + "call_root", |
| 1179 | + "transfer_to_agent", |
| 1180 | + ImmutableMap.of("agent_name", "sub_agent")))) |
| 1181 | + .subAgents(subAgent) |
| 1182 | + .build(); |
| 1183 | + Runner runnerForRace = |
| 1184 | + Runner.builder() |
| 1185 | + .app(App.builder().name("test").rootAgent(rootAgent).build()) |
| 1186 | + .sessionService(new AppendDelayingSessionService(new InMemorySessionService(), 50)) |
| 1187 | + .build(); |
| 1188 | + Session raceSession = |
| 1189 | + runnerForRace.sessionService().createSession("test", "user").blockingGet(); |
| 1190 | + |
| 1191 | + var unused = |
| 1192 | + runnerForRace |
| 1193 | + .runAsync("user", raceSession.id(), createContent("start")) |
| 1194 | + .toList() |
| 1195 | + .blockingGet(); |
| 1196 | + |
| 1197 | + List<SpanData> spans = openTelemetryRule.getSpans(); |
| 1198 | + Optional<SpanData> parentSpan = |
| 1199 | + spans.stream().filter(s -> s.getName().equals("invoke_agent test agent")).findFirst(); |
| 1200 | + Optional<SpanData> targetSpan = |
| 1201 | + spans.stream().filter(s -> s.getName().equals("invoke_agent sub_agent")).findFirst(); |
| 1202 | + assertThat(parentSpan).isPresent(); |
| 1203 | + assertThat(targetSpan).isPresent(); |
| 1204 | + assertThat(targetSpan.get().getParentSpanContext().getSpanId()) |
| 1205 | + .isEqualTo(parentSpan.get().getSpanContext().getSpanId()); |
| 1206 | + } |
| 1207 | + |
1104 | 1208 | /** |
1105 | 1209 | * When an LlmAgent transfers to a sub-LlmAgent, the sub-agent's events flow back up through the |
1106 | 1210 | * parent's flow and must each be appended to the session exactly once. |
|
0 commit comments