From 2e5b9b2ae12d4e89513fd6c99b9c7031dde01f4c Mon Sep 17 00:00:00 2001 From: yoni-oai <338284798+yoni-oai@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:07:54 +0000 Subject: [PATCH] feat(api): support Live sideband Decision commands Castiron-Internal-PR: https://github.com/openai/openai-java-internal/pull/275 Castiron-Source-SHA: 081bde9862a8522787beec632c71662ff19bce03 Castiron-Public-Base-SHA: 71916f9f8a456f3055b56b6b12d4334479fdc280 --- .castiron.stats.yml | 10 +-- api_reference/openapi.transformed.yml | 18 ++++++ .../okhttp/LiveSidebandConnectionTest.kt | 60 ++++++++++++++++++ .../openai/helpers/LiveSidebandConnection.kt | 9 +++ ...sionsDelegationVisitorCompatibilityTest.kt | 55 +++++++++++++++++ .../live/LiveDecisionsOmittedListTest.kt | 61 +++++++++++++++++++ 6 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 openai-java-core/src/test/kotlin/com/openai/models/live/DecisionsDelegationVisitorCompatibilityTest.kt create mode 100644 openai-java-core/src/test/kotlin/com/openai/models/live/LiveDecisionsOmittedListTest.kt diff --git a/.castiron.stats.yml b/.castiron.stats.yml index b74491d4a..531e39781 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: b493525e-caf3-4075-95fd-5472a5a73a7a -openapi_spec_hash: c9f4a7dead33c83456491dc0f08be686 -openapi_transformed_spec_hash: 04951af56e51b359c1993cc518a3892f +generation_id: a13c16f0-9cec-4e2d-926e-e84061d97ed5 +openapi_spec_hash: 7fc94110c3530672f1e30a3eb2b37ac8 +openapi_transformed_spec_hash: 151abddd75bd1cf668f2f9129200e28d config_hash: aae16cbb4861ebb9087774c3bce003a5 -codegen_sha: 9ce8f7587db9087dd5c8a8aabc0c01fd740c1b0a +codegen_sha: a9c6e4417121e38b96e483c0ac9dce3437a719a9 codegen_hash: a11a90342d2dca2e925e24b5d8e86ce491b7da65622069f19eecef36f4e93cc7 -public_codegen_sha: cded9be95af5e873d65e176e01a00a264ef32682 +public_codegen_sha: 2774a763910a830fe9b2946f8ef457384c2fc774 diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index 6d31d594c..2d71cfae3 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -108937,6 +108937,15 @@ x-oaiMeta: - type: object key: LiveCommentaryAppendParam path: + - type: object + key: LiveDecisionCreateParam + path: + - type: object + key: LiveDecisionItemCreateParam + path: + - type: object + key: LiveDecisionItemDeleteParam + path: - type: object key: LiveResponseItemCreateParam path: @@ -108999,6 +109008,15 @@ x-oaiMeta: - type: object key: LiveDelegationCreated path: + - type: object + key: LiveDecisionEvent + path: + - type: object + key: LiveDecisionItemCreated + path: + - type: object + key: LiveDecisionItemDeleted + path: - type: object key: LiveResponseEvent path: diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/LiveSidebandConnectionTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/LiveSidebandConnectionTest.kt index 331b217d1..49b41098a 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/LiveSidebandConnectionTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/LiveSidebandConnectionTest.kt @@ -12,6 +12,10 @@ import com.openai.core.http.WebSocketWriteNotAttempted import com.openai.core.jsonMapper import com.openai.helpers.LiveSidebandConnection import com.openai.helpers.LiveWebSocketOptions +import com.openai.models.live.DecisionCreateEvent +import com.openai.models.live.DecisionEvent +import com.openai.models.live.DecisionItemCreateEvent +import com.openai.models.live.DecisionItemDeleteEvent import com.openai.models.live.InputAudioMuteEvent import com.openai.models.live.InputAudioUnmuteEvent import com.openai.models.live.OutputAudioFixturePlayEvent @@ -76,6 +80,62 @@ class LiveSidebandConnectionTest { .baseUrl(server.url("/proxy/v1?inherited=one%2Ftwo&x=a%2Bb").toString()) .build() + @Test + fun decisionCommandsAndResultUseSideband() { + MockWebServer().use { server -> + val peer = Peer() + server.enqueue(MockResponse().withWebSocketUpgrade(peer)) + OkHttpClient.builder().build().use { http -> + LiveSidebandConnection.connect(options(server, http), "signaling").use { sideband -> + sideband.send(DecisionCreateEvent.builder().eventId("request").build()) + sideband.send( + DecisionItemCreateEvent.builder() + .item(DecisionItemCreateEvent.Item.builder().content("fixture").build()) + .build() + ) + sideband.send(DecisionItemDeleteEvent.builder().itemId("item-1").build()) + for (expected in + listOf( + """{"type":"decision.create","event_id":"request"}""", + """{"type":"decision.item.create","item":{"type":"message","role":"user","content":"fixture"}}""", + """{"type":"decision.item.delete","item_id":"item-1"}""", + )) { + assertThat(mapper.readTree(peer.messages.poll(8, TimeUnit.SECONDS))) + .isEqualTo(mapper.readTree(expected)) + } + peer.socket + .get(8, TimeUnit.SECONDS) + .send( + """ + { + "type":"decision.event","event_id":"result","delegation_id":null, + "trigger_id":"trigger-1","source_event_id":"transcript-1", + "source_event_type":"session.input_transcript.delta", + "event":{"type":"decision.completed","decision":{ + "model":"fixture-model","answers":[{"type":"predicate","probability":0.75}], + "usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0}, + "output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":3} + }} + } + """ + .trimIndent() + ) + val result = sideband.receive().asDecisionEvent() + assertThat(result.delegationId()).isEmpty() + assertThat(result.triggerId()).contains("trigger-1") + assertThat(result.sourceEventId()).contains("transcript-1") + assertThat(result.sourceEventType()) + .contains(DecisionEvent.SourceEventType.SESSION_INPUT_TRANSCRIPT_DELTA) + assertThat( + result.event().decision().answers().single().asPredicate().probability() + ) + .isEqualTo(0.75) + assertThat(result.event().decision().usage().totalTokens()).isEqualTo(3L) + } + } + } + } + @Test fun replayAndReflectedAudioArriveWithoutAnyFreshStartedEventOrStartupCommand() { for (async in listOf(false, true)) { diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/LiveSidebandConnection.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/LiveSidebandConnection.kt index 1acef56de..5ef3395a9 100644 --- a/openai-java-core/src/main/kotlin/com/openai/helpers/LiveSidebandConnection.kt +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/LiveSidebandConnection.kt @@ -6,6 +6,9 @@ import com.google.errorprone.annotations.MustBeClosed import com.openai.core.ClientOptions import com.openai.core.RequestOptions import com.openai.models.live.CommentaryAppendEvent +import com.openai.models.live.DecisionCreateEvent +import com.openai.models.live.DecisionItemCreateEvent +import com.openai.models.live.DecisionItemDeleteEvent import com.openai.models.live.InputAudioMuteEvent import com.openai.models.live.InputAudioUnmuteEvent import com.openai.models.live.InstructionsAppendEvent @@ -103,6 +106,12 @@ private constructor( fun send(event: CommentaryAppendEvent) = sendCommand(event) + fun send(event: DecisionCreateEvent) = sendCommand(event) + + fun send(event: DecisionItemCreateEvent) = sendCommand(event) + + fun send(event: DecisionItemDeleteEvent) = sendCommand(event) + fun send(event: ResponseItemCreateEvent) = sendCommand(event) fun send(event: ResponseCreateEvent) = sendCommand(event) diff --git a/openai-java-core/src/test/kotlin/com/openai/models/live/DecisionsDelegationVisitorCompatibilityTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/live/DecisionsDelegationVisitorCompatibilityTest.kt new file mode 100644 index 000000000..d918a043e --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/models/live/DecisionsDelegationVisitorCompatibilityTest.kt @@ -0,0 +1,55 @@ +package com.openai.models.live + +import com.openai.core.JsonValue +import com.openai.models.live.sessions.SessionAcceptParams +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DecisionsDelegationVisitorCompatibilityTest { + @Test + fun existingVisitorHandlesDecisionsAsUnknown() { + val visitor = + object : SessionConfig.Delegation.Visitor { + override fun visitClient(client: ClientDelegation): JsonValue? = null + + override fun visitResponses( + responses: SessionConfig.Delegation.Responses + ): JsonValue? = null + + override fun unknown(json: JsonValue?) = json + } + val decisions = + SessionConfig.Delegation.Decisions.builder() + .decisions( + DecisionsDelegationConfig.builder() + .model("decision-model") + .addPredicateQuestion("Is the customer asking for help?") + .build() + ) + .build() + + assertThat(SessionConfig.Delegation.ofDecisions(decisions).accept(visitor)) + .isEqualTo(JsonValue.from(decisions)) + } + + @Test + fun decisionsVisitorMethodsAreJvmDefaults() { + for ((visitor, decisions) in + listOf( + MediaSessionConfig.Delegation.Visitor::class.java to + MediaSessionConfig.Delegation.Decisions::class.java, + SessionConfig.Delegation.Visitor::class.java to + SessionConfig.Delegation.Decisions::class.java, + SessionResource.Delegation.Visitor::class.java to + SessionResource.Delegation.Decisions::class.java, + SessionUpdateConfig.Delegation.Visitor::class.java to + SessionUpdateConfig.Delegation.Decisions::class.java, + SessionAcceptParams.Session.Delegation.Visitor::class.java to + SessionAcceptParams.Session.Delegation.Decisions::class.java, + )) { + assertThat(visitor.getMethod("visitDecisions", decisions).isDefault) + .describedAs("%s.visitDecisions is a JVM default method", visitor.name) + .isTrue() + } + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/models/live/LiveDecisionsOmittedListTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/live/LiveDecisionsOmittedListTest.kt new file mode 100644 index 000000000..22af4c08f --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/models/live/LiveDecisionsOmittedListTest.kt @@ -0,0 +1,61 @@ +package com.openai.models.live + +import com.openai.core.jsonMapper +import com.openai.models.live.sessions.SessionAcceptParams +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalStateException +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +internal class LiveDecisionsOmittedListTest { + private val trigger = + DecisionTrigger.builder() + .decisions(DecisionTrigger.Decisions.builder().addPredicateQuestion("Ready?").build()) + .eventType(DecisionTrigger.EventType.SESSION_INPUT_TRANSCRIPT_DELTA) + .build() + + @Test + fun builtConfigCopyPreservesOmissionUntilFirstTrigger() { + val config = SessionUpdateConfig.builder().build() + + assertThat(config.toBuilder().build()._triggers().isMissing()).isTrue() + assertThat(config.toBuilder().addTrigger(trigger).build().triggers()) + .contains(listOf(trigger)) + } + + @Test + fun acceptedSessionCopyAcceptsFirstTrigger() { + val session = + SessionAcceptParams.Session.builder() + .model(SessionAcceptParams.Session.Model.GPT_LIVE_1) + .build() + + assertThat(session.toBuilder().addTrigger(trigger).build().triggers()) + .contains(listOf(trigger)) + } + + @Test + fun deserializedConfigCopyPreservesOmissionUntilFirstQuestion() { + val config = jsonMapper().readValue("{}", DecisionsDelegationUpdateConfig::class.java) + val question = + DecisionsDelegationUpdateConfig.Question.ofPredicate( + DecisionsDelegationUpdateConfig.Question.Predicate.builder() + .instructions("Ready?") + .build() + ) + + assertThat(config.toBuilder().build()._questions().isMissing()).isTrue() + assertThat(config.toBuilder().addQuestion(question).build().questions()) + .contains(listOf(question)) + } + + @ParameterizedTest + @ValueSource(strings = ["null", "\"not a list\""]) + fun copiedNonListTriggerValueRejectsAppend(jsonValue: String) { + val config = + jsonMapper().readValue("""{"triggers":$jsonValue}""", SessionUpdateConfig::class.java) + + assertThatIllegalStateException().isThrownBy { config.toBuilder().addTrigger(trigger) } + } +}