Skip to content

Commit f5e134e

Browse files
kvmiloscopybara-github
authored andcommitted
feat(sessions): support session expiry in VertexAiSessionService
A Vertex-only `createSession` overload now takes `ttl` or `expireTime`. At most one may be set, since the wire field is a single choice. Without one the backend applies its own default, which is long; with one, the minimum it accepts is 24 hours. The `SessionService` interface is unchanged. PiperOrigin-RevId: 970431402
1 parent 7a3818b commit f5e134e

5 files changed

Lines changed: 212 additions & 17 deletions

File tree

core/src/jvmMain/kotlin/com/google/adk/kt/sessions/VertexAiSessionService.kt

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import com.google.auth.oauth2.GoogleCredentials
2525
import io.ktor.client.HttpClient
2626
import io.ktor.client.engine.java.Java
2727
import kotlin.jvm.JvmStatic
28+
import kotlin.time.Duration
29+
import kotlin.time.Instant
2830

2931
/**
3032
* A [SessionService] backed by the managed Vertex AI Session Service.
@@ -86,8 +88,36 @@ internal constructor(
8688
reasoningEngineId,
8789
)
8890

89-
override suspend fun createSession(key: SessionKey, state: Map<String, Any>?): Session {
90-
val sessionDto = client.createSession(engine, key.userId, state).getOrThrow()
91+
override suspend fun createSession(key: SessionKey, state: Map<String, Any>?): Session =
92+
createSession(key, state, ttl = null, expireTime = null)
93+
94+
/**
95+
* Creates a session that expires, addressing the reasoning engine fixed at construction.
96+
*
97+
* At most one of [ttl] and [expireTime] may be set, because the backend models them as a single
98+
* choice; setting both is rejected. The backend also requires the expiry to be at least 24 hours
99+
* out, and applies its own default when neither is given.
100+
*
101+
* @param key The composite identifier of the session; [SessionKey.appName] is only a label.
102+
* @param state An optional map representing the initial state of the session.
103+
* @param ttl How long the session lives, measured from creation. Sub-second precision is dropped.
104+
* @param expireTime The absolute instant at which the session expires.
105+
* @return The newly created [Session].
106+
*/
107+
suspend fun createSession(
108+
key: SessionKey,
109+
state: Map<String, Any>? = null,
110+
ttl: Duration? = null,
111+
expireTime: Instant? = null,
112+
): Session {
113+
require(ttl == null || expireTime == null) {
114+
"Cannot specify both ttl and expireTime simultaneously."
115+
}
116+
// The wire format is whole seconds, so a sub-second ttl would silently travel as "0s".
117+
require(ttl == null || ttl.inWholeSeconds > 0) {
118+
"ttl must be at least one second, but was $ttl."
119+
}
120+
val sessionDto = client.createSession(engine, key.userId, state, ttl, expireTime).getOrThrow()
91121
return sessionDto.toAdk(key.appName, key.userId, key.id)
92122
}
93123

core/src/jvmMain/kotlin/com/google/adk/kt/sessions/VertexAiSessionsClient.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import java.io.IOException
3838
import java.net.URLEncoder
3939
import java.nio.charset.StandardCharsets
4040
import java.util.concurrent.TimeoutException
41+
import kotlin.time.Duration
42+
import kotlin.time.Instant
4143
import kotlinx.coroutines.delay
4244
import kotlinx.serialization.DeserializationStrategy
4345

@@ -101,17 +103,28 @@ internal open class VertexAiSessionsClient(
101103
* @param engine The reasoning engine that owns the session.
102104
* @param userId The ID of the user owning the session.
103105
* @param state Optional initial state map to inject into the session.
106+
* @param ttl Optional lifetime of the session, measured from creation.
107+
* @param expireTime Optional absolute expiry of the session. The wire field is a oneof, so the
108+
* caller must not set both this and [ttl].
104109
* @return The created [SessionDto], or a [Result.failure] describing why creation failed.
105110
*/
106111
open suspend fun createSession(
107112
engine: ReasoningEngineRef,
108113
userId: String,
109114
state: Map<String, Any>?,
115+
ttl: Duration? = null,
116+
expireTime: Instant? = null,
110117
): Result<SessionDto> {
111118
val requestBody =
112119
adkJson.encodeToString(
113120
CreateSessionRequestDto.serializer(),
114-
CreateSessionRequestDto(userId = userId, sessionState = state?.let { anyToJsonElement(it) }),
121+
CreateSessionRequestDto(
122+
userId = userId,
123+
sessionState = state?.let { anyToJsonElement(it) },
124+
// proto3 JSON writes a Duration as seconds with an "s" suffix.
125+
ttl = ttl?.let { "${it.inWholeSeconds}s" },
126+
expireTime = expireTime?.toString(),
127+
),
115128
)
116129
val createResponse =
117130
postAndDecode(

core/src/jvmMain/kotlin/com/google/adk/kt/sessions/dto/ResponsesDto.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@ internal data class ListEventsResponseDto(
3838
internal data class CreateSessionRequestDto(
3939
val userId: String,
4040
val sessionState: JsonElement? = null,
41+
// `ttl`/`expire_time` are a proto oneof; at most one is set, in its JSON string form.
42+
val ttl: String? = null,
43+
val expireTime: String? = null,
4144
)

core/src/jvmTest/kotlin/com/google/adk/kt/sessions/VertexAiSessionServiceTest.kt

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ import com.google.adk.kt.types.Part
2828
import com.google.common.truth.Truth.assertThat
2929
import java.io.IOException
3030
import kotlin.test.assertFailsWith
31+
import kotlin.time.Duration
32+
import kotlin.time.Duration.Companion.hours
33+
import kotlin.time.Duration.Companion.milliseconds
34+
import kotlin.time.Duration.Companion.seconds
3135
import kotlin.time.Instant
36+
import kotlinx.coroutines.runBlocking
3237
import kotlinx.coroutines.test.runTest
3338
import kotlinx.serialization.json.JsonObject
3439
import kotlinx.serialization.json.JsonPrimitive
@@ -62,19 +67,24 @@ class VertexAiSessionServiceTest {
6267
reasoningEngineId = ENGINE_ID,
6368
)
6469

70+
/** A client that accepts any create call, for asserting which expiration was forwarded. */
71+
private fun expiringSessionClient() =
72+
mock<VertexAiSessionsClient> {
73+
onBlocking { createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn
74+
Result.success(SessionDto(name = "reasoningEngines/123/sessions/s"))
75+
}
76+
6577
@Test
6678
fun addressesConfiguredEngineRegardlessOfAppName() = runTest {
67-
val client =
68-
mock<VertexAiSessionsClient> {
69-
onBlocking { createSession(any(), any(), anyOrNull()) } doReturn
70-
Result.success(SessionDto(name = "reasoningEngines/123/sessions/s"))
71-
}
79+
val client = expiringSessionClient()
7280

7381
// The app name is only a label; the service always addresses the engine set at construction.
7482
val unused =
7583
service(client).createSession(SessionKey("any-label", "user", id = null), state = null)
7684

77-
verifyBlocking(client) { createSession(eq(ENGINE), eq("user"), anyOrNull()) }
85+
verifyBlocking(client) {
86+
createSession(eq(ENGINE), eq("user"), anyOrNull(), anyOrNull(), anyOrNull())
87+
}
7888
}
7989

8090
@Test
@@ -137,7 +147,9 @@ class VertexAiSessionServiceTest {
137147
fun createSession_mapsClientResponse() = runTest {
138148
val client =
139149
mock<VertexAiSessionsClient> {
140-
onBlocking { createSession(eq(ENGINE), eq("user"), anyOrNull()) } doReturn
150+
onBlocking {
151+
createSession(eq(ENGINE), eq("user"), anyOrNull(), anyOrNull(), anyOrNull())
152+
} doReturn
141153
Result.success(
142154
SessionDto(
143155
name = "reasoningEngines/123/sessions/session-1",
@@ -160,7 +172,7 @@ class VertexAiSessionServiceTest {
160172
fun createSession_clientFails_propagates() = runTest {
161173
val client =
162174
mock<VertexAiSessionsClient> {
163-
onBlocking { createSession(any(), any(), anyOrNull()) } doReturn
175+
onBlocking { createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn
164176
Result.failure(IOException("boom"))
165177
}
166178

@@ -169,6 +181,85 @@ class VertexAiSessionServiceTest {
169181
}
170182
}
171183

184+
@Test
185+
fun createSession_ttl_forwardsTtlOnly() {
186+
val client = expiringSessionClient()
187+
188+
runBlocking {
189+
val unused =
190+
service(client).createSession(SessionKey("123", "user", id = null), ttl = 24.hours)
191+
}
192+
193+
verifyBlocking(client) {
194+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(24.hours), eq(null))
195+
}
196+
}
197+
198+
@Test
199+
fun createSession_expireTime_forwardsExpireTimeOnly() {
200+
val client = expiringSessionClient()
201+
val expiry = Instant.parse("2026-10-01T00:00:00Z")
202+
203+
runBlocking {
204+
val unused =
205+
service(client).createSession(SessionKey("123", "user", id = null), expireTime = expiry)
206+
}
207+
208+
verifyBlocking(client) {
209+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(null), eq(expiry))
210+
}
211+
}
212+
213+
@Test
214+
fun createSession_noExpiration_forwardsNeither() {
215+
val client = expiringSessionClient()
216+
217+
// The SessionService overload must not invent an expiration of its own.
218+
runBlocking {
219+
val unused = service(client).createSession(SessionKey("123", "user", id = null))
220+
}
221+
222+
verifyBlocking(client) {
223+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(null), eq(null))
224+
}
225+
}
226+
227+
@Test
228+
fun createSession_ttlAndExpireTime_throwsWithoutCallingBackend() {
229+
val client = expiringSessionClient()
230+
231+
assertFailsWith<IllegalArgumentException> {
232+
runBlocking {
233+
service(client)
234+
.createSession(
235+
SessionKey("123", "user", id = null),
236+
ttl = 24.hours,
237+
expireTime = Instant.parse("2026-10-01T00:00:00Z"),
238+
)
239+
}
240+
}
241+
verifyBlocking(client, never()) {
242+
createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull())
243+
}
244+
}
245+
246+
@Test
247+
fun createSession_ttlBelowOneSecond_throwsWithoutCallingBackend() {
248+
val client = expiringSessionClient()
249+
250+
// 500ms is positive but truncates to "0s" on the wire, so it must be rejected too.
251+
for (bad in listOf(Duration.ZERO, (-1).seconds, 500.milliseconds)) {
252+
assertFailsWith<IllegalArgumentException> {
253+
runBlocking {
254+
service(client).createSession(SessionKey("123", "user", id = null), ttl = bad)
255+
}
256+
}
257+
}
258+
verifyBlocking(client, never()) {
259+
createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull())
260+
}
261+
}
262+
172263
@Test
173264
fun getSession_notFound_returnsNull() = runTest {
174265
val client =

core/src/jvmTest/kotlin/com/google/adk/kt/sessions/VertexAiSessionsClientTest.kt

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ import com.google.auth.oauth2.AccessToken
2424
import com.google.auth.oauth2.GoogleCredentials
2525
import com.google.common.truth.Truth.assertThat
2626
import java.io.IOException
27-
import java.time.Instant
28-
import java.time.temporal.ChronoUnit
2927
import java.util.Date
28+
import kotlin.time.Clock
29+
import kotlin.time.Duration.Companion.days
30+
import kotlin.time.Duration.Companion.hours
31+
import kotlin.time.Duration.Companion.milliseconds
32+
import kotlin.time.Instant
3033
import kotlinx.coroutines.runBlocking
3134
import kotlinx.serialization.json.JsonObject
3235
import kotlinx.serialization.json.JsonPrimitive
@@ -108,9 +111,7 @@ class VertexAiSessionsClientTest {
108111

109112
@Test
110113
fun createSession_nestedState_serializedAsJsonNotToString() {
111-
server.enqueue(jsonResponse("""{"name":"reasoningEngines/123/sessions/s/operations/op"}"""))
112-
server.enqueue(jsonResponse("""{"name":"operations/op","done":true}"""))
113-
server.enqueue(jsonResponse("""{"name":"reasoningEngines/123/sessions/s"}"""))
114+
enqueueCreateSessionExchange()
114115

115116
val unused = runBlocking {
116117
client.createSession(ENGINE, "user", mapOf("nested" to mapOf("a" to listOf(1L, 2L))))
@@ -121,6 +122,54 @@ class VertexAiSessionsClientTest {
121122
assertThat(body).contains("\"nested\":{\"a\":[1,2]}")
122123
}
123124

125+
@Test
126+
fun createSession_ttl_sendsProtoDurationAndNoExpireTime() {
127+
enqueueCreateSessionExchange()
128+
129+
val unused = runBlocking { client.createSession(ENGINE, "user", null, ttl = 24.hours) }
130+
131+
val body = server.takeRequest().body?.utf8()
132+
assertThat(body).contains("\"ttl\":\"86400s\"")
133+
// `ttl` and `expireTime` are a wire oneof, so the unset arm must not be sent at all.
134+
assertThat(body).doesNotContain("expireTime")
135+
}
136+
137+
@Test
138+
fun createSession_subSecondTtl_truncatesToWholeSeconds() {
139+
enqueueCreateSessionExchange()
140+
141+
val unused = runBlocking {
142+
client.createSession(ENGINE, "user", null, ttl = 24.hours + 900.milliseconds)
143+
}
144+
145+
assertThat(server.takeRequest().body?.utf8()).contains("\"ttl\":\"86400s\"")
146+
}
147+
148+
@Test
149+
fun createSession_expireTime_sendsRfc3339AndNoTtl() {
150+
enqueueCreateSessionExchange()
151+
152+
val unused = runBlocking {
153+
client.createSession(ENGINE, "user", null, expireTime = EXPIRE_TIME)
154+
}
155+
156+
val body = server.takeRequest().body?.utf8()
157+
assertThat(body).contains("\"expireTime\":\"2026-10-01T00:00:00Z\"")
158+
assertThat(body).doesNotContain("ttl")
159+
}
160+
161+
@Test
162+
fun createSession_noExpiration_sendsNeitherField() {
163+
enqueueCreateSessionExchange()
164+
165+
val unused = runBlocking { client.createSession(ENGINE, "user", null) }
166+
167+
// An unset arm is omitted entirely rather than sent as an explicit JSON null.
168+
val body = server.takeRequest().body?.utf8()
169+
assertThat(body).doesNotContain("ttl")
170+
assertThat(body).doesNotContain("expireTime")
171+
}
172+
124173
@Test
125174
fun getSession_notFound_returnsNullSuccess() = runBlocking {
126175
server.enqueue(MockResponse(code = 404))
@@ -274,17 +323,26 @@ class VertexAiSessionsClientTest {
274323
.endsWith("/reasoningEngines/123/sessions/s1:appendEvent")
275324
}
276325

326+
/** Enqueues the create response, the completed operation poll, and the materialized session. */
327+
private fun enqueueCreateSessionExchange() {
328+
server.enqueue(jsonResponse("""{"name":"reasoningEngines/123/sessions/s/operations/op"}"""))
329+
server.enqueue(jsonResponse("""{"name":"operations/op","done":true}"""))
330+
server.enqueue(jsonResponse("""{"name":"reasoningEngines/123/sessions/s"}"""))
331+
}
332+
277333
private companion object {
278334
val ENGINE =
279335
ReasoningEngineRef(project = "test-project", location = "test-location", id = "123")
280336

337+
val EXPIRE_TIME = Instant.parse("2026-10-01T00:00:00Z")
338+
281339
fun jsonResponse(body: String): MockResponse =
282340
MockResponse(headers = Headers.headersOf("Content-Type", "application/json"), body = body)
283341

284342
fun fakeCredentials(): GoogleCredentials =
285343
GoogleCredentials.newBuilder()
286344
.setAccessToken(
287-
AccessToken("fake-token", Date(Instant.now().plus(1, ChronoUnit.DAYS).toEpochMilli()))
345+
AccessToken("fake-token", Date((Clock.System.now() + 1.days).toEpochMilliseconds()))
288346
)
289347
.build()
290348
}

0 commit comments

Comments
 (0)