diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c2da2..2c3088d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Add the data layer for outside calls: the contract copy brings call requests (`request_call`), `callee` on call events, and `callsOthers` on voice lines. The app reads call requests, approves or declines them, follows them every five seconds while they are pending or calling, and reads a call's transcript every two seconds while it goes on. +- Show call requests in the bot chat: a pending one reads "Wants to call for you" with the callee, number, goal and opening and has Decline and Approve; a declined one reads "Declined"; a placed one is a row with its state that opens the call, with its goal and a transcript that follows the call live. An outside call's end notice becomes the same row. +- Show voice gateway calls in bot chats, as on the web: a spoken message reads "Spoken on a call" under its bubble, and a call's start and end show as centred phone markers. A call the bot placed reads "{bot} called you" with the greeting it spoke, and the "Call ended" line that wakes the bot shows the bot's reaction instead of appearing as a typed message. +- Copy the bot contract with the voice gateway. It adds the `call` field on transcript messages, the voice gateway and voice line operations, and the `partial` query on chat events, which the app does not use yet. +- Bump the contract generator to `1.2.0`: an object that declares properties and allows others keeps the others in `additionalProperties`, so `getOpenAPI()` returns the whole document instead of only `openapi`. - CI caches Gradle and runs tests and lint as parallel jobs. Lint skips test sources. - `scripts/contract.py update --from DIRECTORY` copies the contract files from a directory such as `bot/openapi/`, replacing `--local` and `--release`. `contract/` no longer carries `pin.json`, `SHA256SUMS`, `CHANGELOG.md`, or `source-commit.txt`; the `verify` and `update-latest` commands and the weekly contract update workflow are gone. - Publish signed release APKs: a `vX.Y.Z` tag builds one with FCM and attaches it to a GitHub release. Release builds are signed when `CRUMBLE_KEYSTORE_PATH` and `CRUMBLE_KEYSTORE_PASSWORD` are set, and take their version from `crumble.versionName` and `crumble.versionCode`. diff --git a/app/src/main/java/dev/crumble/android/generated/ContractBoundary.kt b/app/src/main/java/dev/crumble/android/generated/ContractBoundary.kt index 65249ca..b9d327e 100644 --- a/app/src/main/java/dev/crumble/android/generated/ContractBoundary.kt +++ b/app/src/main/java/dev/crumble/android/generated/ContractBoundary.kt @@ -1,4 +1,4 @@ -// Generated by scripts/generate-contract.py 1.1.0. Do not edit. +// Generated by scripts/generate-contract.py 1.2.0. Do not edit. package dev.crumble.android.generated import dev.crumble.android.network.HttpRequestData diff --git a/app/src/main/java/dev/crumble/android/generated/ContractModels.kt b/app/src/main/java/dev/crumble/android/generated/ContractModels.kt index c23d6dc..21a16e0 100644 --- a/app/src/main/java/dev/crumble/android/generated/ContractModels.kt +++ b/app/src/main/java/dev/crumble/android/generated/ContractModels.kt @@ -1,5 +1,5 @@ @file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) -// Generated by scripts/generate-contract.py 1.1.0. Do not edit. +// Generated by scripts/generate-contract.py 1.2.0. Do not edit. package dev.crumble.android.generated import kotlinx.serialization.* @@ -62,7 +62,7 @@ object ActivityEventPayloadCodec : KSerializer { } } -@Serializable +@Serializable(with = ActivityEventPayloadVariant1Codec::class) data class ActivityEventPayloadVariant1( val `type`: String, val `eventId`: String, @@ -78,15 +78,75 @@ data class ActivityEventPayloadVariant1( val `groupId`: String? = null, val `groupName`: String? = null, val `waitingFor`: String? = null, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class ActivityEventPayloadVariant1Fields( + val `type`: String, + val `eventId`: String, + val `serverId`: String, + val `agentId`: String, + val `agentName`: String, + val `entryId`: String?, + val `origin`: String, + val `preview`: String, + val `createdAt`: String, + val `executionId`: String? = null, + val `jobName`: String? = null, + val `groupId`: String? = null, + val `groupName`: String? = null, + val `waitingFor`: String? = null, +) +object ActivityEventPayloadVariant1Codec : KSerializer { + private val declared = setOf("type", "eventId", "serverId", "agentId", "agentName", "entryId", "origin", "preview", "createdAt", "executionId", "jobName", "groupId", "groupName", "waitingFor") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ActivityEventPayloadVariant1") + override fun deserialize(decoder: Decoder): ActivityEventPayloadVariant1 { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return ActivityEventPayloadVariant1(fields.`type`, fields.`eventId`, fields.`serverId`, fields.`agentId`, fields.`agentName`, fields.`entryId`, fields.`origin`, fields.`preview`, fields.`createdAt`, fields.`executionId`, fields.`jobName`, fields.`groupId`, fields.`groupName`, fields.`waitingFor`, additional) + } + override fun serialize(encoder: Encoder, value: ActivityEventPayloadVariant1) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(ActivityEventPayloadVariant1Fields(value.`type`, value.`eventId`, value.`serverId`, value.`agentId`, value.`agentName`, value.`entryId`, value.`origin`, value.`preview`, value.`createdAt`, value.`executionId`, value.`jobName`, value.`groupId`, value.`groupName`, value.`waitingFor`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = ActivityEventPayloadVariant2Codec::class) data class ActivityEventPayloadVariant2( val `type`: String, val `agentId`: String, val `agentName`: String, val `lastMessage`: String, + val additionalProperties: Map = emptyMap(), ) +@Serializable +private data class ActivityEventPayloadVariant2Fields( + val `type`: String, + val `agentId`: String, + val `agentName`: String, + val `lastMessage`: String, +) +object ActivityEventPayloadVariant2Codec : KSerializer { + private val declared = setOf("type", "agentId", "agentName", "lastMessage") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ActivityEventPayloadVariant2") + override fun deserialize(decoder: Decoder): ActivityEventPayloadVariant2 { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return ActivityEventPayloadVariant2(fields.`type`, fields.`agentId`, fields.`agentName`, fields.`lastMessage`, additional) + } + override fun serialize(encoder: Encoder, value: ActivityEventPayloadVariant2) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(ActivityEventPayloadVariant2Fields(value.`type`, value.`agentId`, value.`agentName`, value.`lastMessage`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} @Serializable data class ActivityFeed( @@ -551,6 +611,24 @@ data class BrowseSkillRepositoryRequest( val `location`: String, ) +@Serializable +data class CallRequest( + val `id`: String, + val `agentId`: String, + val `number`: String, + val `callee`: String, + val `goal`: String, + val `opening`: String, + val `status`: String, + val `callId`: String?, + val `conversationId`: String?, + val `outcome`: String?, + val `error`: String?, + val `requestedAt`: String, + val `decidedAt`: String?, + val `endedAt`: String?, +) + @Serializable data class Capabilities( val `coding`: Boolean, @@ -1026,12 +1104,36 @@ data class Error( val `details`: List? = null, ) -@Serializable +@Serializable(with = ErrorDetailsItemCodec::class) data class ErrorDetailsItem( val `code`: String, val `path`: List, val `message`: String, + val additionalProperties: Map = emptyMap(), ) +@Serializable +private data class ErrorDetailsItemFields( + val `code`: String, + val `path`: List, + val `message`: String, +) +object ErrorDetailsItemCodec : KSerializer { + private val declared = setOf("code", "path", "message") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ErrorDetailsItem") + override fun deserialize(decoder: Decoder): ErrorDetailsItem { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return ErrorDetailsItem(fields.`code`, fields.`path`, fields.`message`, additional) + } + override fun serialize(encoder: Encoder, value: ErrorDetailsItem) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(ErrorDetailsItemFields(value.`code`, value.`path`, value.`message`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} @Serializable(with = ErrorDetailsItemPathItemCodec::class) sealed class ErrorDetailsItemPathItem { @@ -1091,6 +1193,77 @@ data class GetEventRelayResponse( val `error`: String?, ) +@Serializable(with = GetOpenAPIResponseCodec::class) +data class GetOpenAPIResponse( + val `openapi`: String, + val additionalProperties: Map = emptyMap(), +) +@Serializable +private data class GetOpenAPIResponseFields( + val `openapi`: String, +) +object GetOpenAPIResponseCodec : KSerializer { + private val declared = setOf("openapi") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GetOpenAPIResponse") + override fun deserialize(decoder: Decoder): GetOpenAPIResponse { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return GetOpenAPIResponse(fields.`openapi`, additional) + } + override fun serialize(encoder: Encoder, value: GetOpenAPIResponse) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(GetOpenAPIResponseFields(value.`openapi`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable +data class GetVoiceGatewayResponse( + val `config`: GetVoiceGatewayResponseConfig, + val `token`: GetVoiceGatewayResponseToken?, + val `status`: String, + val `connectedAt`: String?, + val `lines`: List, + val `unavailableLines`: List, +) + +@Serializable +data class GetVoiceGatewayResponseConfig( + val `enabled`: Boolean, + val `ownerNumbers`: List, + val `lines`: List, +) + +@Serializable +data class GetVoiceGatewayResponseConfigLinesItem( + val `username`: String, + val `botId`: String, + val `displayName`: String?, + val `callsOwner`: Boolean, + val `callsOthers`: Boolean, +) + +@Serializable +data class GetVoiceGatewayResponseLinesItem( + val `username`: String, + val `registered`: Boolean, + val `error`: String?, +) + +@Serializable +data class GetVoiceGatewayResponseToken( + val `issuedAt`: String, +) + +@Serializable +data class GetVoiceGatewayResponseUnavailableLinesItem( + val `username`: String, + val `reason`: String, +) + @Serializable data class GetWebSearchResponse( val `defaultProvider`: String, @@ -1212,6 +1385,12 @@ data class Invite( val `createdAt`: String, ) +@Serializable +data class IssueVoiceGatewayTokenResponse( + val `token`: String, + val `issuedAt`: String, +) + @Serializable data class JudgeChoice( val `provider`: String, @@ -1882,6 +2061,60 @@ data class ReplyEvent( val `waitingFor`: String? = null, ) +@Serializable +data class ReportVoiceLinesRequest( + val `lines`: List, +) + +@Serializable +data class ReportVoiceLinesRequestLinesItem( + val `username`: String, + val `registered`: Boolean, + val `error`: String?, +) + +@Serializable(with = RequestVoiceCallReportCodec::class) +sealed class RequestVoiceCallReport { + data class Variant1(val value: RequestVoiceCallReportVariant1) : RequestVoiceCallReport() + data class Variant2(val value: RequestVoiceCallReportVariant2) : RequestVoiceCallReport() +} +object RequestVoiceCallReportCodec : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("RequestVoiceCallReport") + override fun deserialize(decoder: Decoder): RequestVoiceCallReport { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement() + val matches = listOf((element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ringing\"") || element["outcome"] == Json.parseToJsonElement("\"answered\"") || element["outcome"] == Json.parseToJsonElement("\"unanswered\"") || element["outcome"] == Json.parseToJsonElement("\"busy\"") || element["outcome"] == Json.parseToJsonElement("\"failed\""))), (element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ended\"")) && element["reason"] != null && (element["reason"] == Json.parseToJsonElement("\"hungUp\"") || element["reason"] == Json.parseToJsonElement("\"maxDuration\"") || element["reason"] == Json.parseToJsonElement("\"failed\"") || element["reason"] == Json.parseToJsonElement("\"stopping\"")))) + require(matches.count { it } == 1) { "Expected exactly one RequestVoiceCallReport variant" } + return when { + (element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ringing\"") || element["outcome"] == Json.parseToJsonElement("\"answered\"") || element["outcome"] == Json.parseToJsonElement("\"unanswered\"") || element["outcome"] == Json.parseToJsonElement("\"busy\"") || element["outcome"] == Json.parseToJsonElement("\"failed\""))) -> RequestVoiceCallReport.Variant1(input.json.decodeFromJsonElement(element)) + (element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ended\"")) && element["reason"] != null && (element["reason"] == Json.parseToJsonElement("\"hungUp\"") || element["reason"] == Json.parseToJsonElement("\"maxDuration\"") || element["reason"] == Json.parseToJsonElement("\"failed\"") || element["reason"] == Json.parseToJsonElement("\"stopping\""))) -> RequestVoiceCallReport.Variant2(input.json.decodeFromJsonElement(element)) + else -> throw SerializationException("Unknown RequestVoiceCallReport variant") + } + } + override fun serialize(encoder: Encoder, value: RequestVoiceCallReport) { + val output = encoder as JsonEncoder + output.encodeJsonElement(when (value) { + is RequestVoiceCallReport.Variant1 -> output.json.encodeToJsonElement(value.value) + is RequestVoiceCallReport.Variant2 -> output.json.encodeToJsonElement(value.value) + }) + } +} + +@Serializable +data class RequestVoiceCallReportVariant1( + val `outcome`: String, + @EncodeDefault(EncodeDefault.Mode.NEVER) + val `error`: OptionalField = OptionalField.Absent, +) + +@Serializable +data class RequestVoiceCallReportVariant2( + val `outcome`: String, + val `reason`: String, + @EncodeDefault(EncodeDefault.Mode.NEVER) + val `error`: OptionalField = OptionalField.Absent, +) + @Serializable data class ReviewResult( val `executionId`: String, @@ -2006,7 +2239,7 @@ data class ScheduleRun( val `result`: ScheduleRunResult? = null, ) -@Serializable +@Serializable(with = ScheduleRunResultCodec::class) data class ScheduleRunResult( val `id`: String? = null, val `agentId`: String? = null, @@ -2021,7 +2254,41 @@ data class ScheduleRunResult( val `error`: String? = null, val `createdAt`: String? = null, val `updatedAt`: String? = null, + val additionalProperties: Map = emptyMap(), ) +@Serializable +private data class ScheduleRunResultFields( + val `id`: String? = null, + val `agentId`: String? = null, + val `conversationId`: String? = null, + val `scheduleId`: String? = null, + val `submissionId`: String? = null, + val `requestId`: String? = null, + val `prompt`: String? = null, + val `replyTo`: String? = null, + val `attachmentsDigest`: String? = null, + val `status`: String? = null, + val `error`: String? = null, + val `createdAt`: String? = null, + val `updatedAt`: String? = null, +) +object ScheduleRunResultCodec : KSerializer { + private val declared = setOf("id", "agentId", "conversationId", "scheduleId", "submissionId", "requestId", "prompt", "replyTo", "attachmentsDigest", "status", "error", "createdAt", "updatedAt") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ScheduleRunResult") + override fun deserialize(decoder: Decoder): ScheduleRunResult { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return ScheduleRunResult(fields.`id`, fields.`agentId`, fields.`conversationId`, fields.`scheduleId`, fields.`submissionId`, fields.`requestId`, fields.`prompt`, fields.`replyTo`, fields.`attachmentsDigest`, fields.`status`, fields.`error`, fields.`createdAt`, fields.`updatedAt`, additional) + } + override fun serialize(encoder: Encoder, value: ScheduleRunResult) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(ScheduleRunResultFields(value.`id`, value.`agentId`, value.`conversationId`, value.`scheduleId`, value.`submissionId`, value.`requestId`, value.`prompt`, value.`replyTo`, value.`attachmentsDigest`, value.`status`, value.`error`, value.`createdAt`, value.`updatedAt`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} @Serializable data class ScriptRun( @@ -2081,6 +2348,13 @@ data class SendMessageRequestAttachmentsItem( val `data`: String, ) +@Serializable +data class SendSpokenMessageRequest( + val `callId`: String, + val `requestId`: String, + val `content`: String, +) + @Serializable data class ServerSettings( val `signUpOpen`: Boolean, @@ -2240,7 +2514,7 @@ data class SkillUpdate( val `removed`: Boolean, ) -@Serializable +@Serializable(with = SnapshotEventPayloadCodec::class) data class SnapshotEventPayload( val `messages`: List, val `hasMore`: Boolean, @@ -2254,9 +2528,42 @@ data class SnapshotEventPayload( val `status`: String, val `updatedAt`: Double, val `error`: String?, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadFields( + val `messages`: List, + val `hasMore`: Boolean, + val `oldestEntryId`: String?, + val `conversationId`: String, + val `agentId`: String, + val `conversation`: SnapshotEventPayloadConversation, + val `live`: JsonElement? = null, + val `partial`: String, + val `usage`: JsonElement? = null, + val `status`: String, + val `updatedAt`: Double, + val `error`: String?, +) +object SnapshotEventPayloadCodec : KSerializer { + private val declared = setOf("messages", "hasMore", "oldestEntryId", "conversationId", "agentId", "conversation", "live", "partial", "usage", "status", "updatedAt", "error") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayload") + override fun deserialize(decoder: Decoder): SnapshotEventPayload { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayload(fields.`messages`, fields.`hasMore`, fields.`oldestEntryId`, fields.`conversationId`, fields.`agentId`, fields.`conversation`, fields.`live`, fields.`partial`, fields.`usage`, fields.`status`, fields.`updatedAt`, fields.`error`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayload) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadFields(value.`messages`, value.`hasMore`, value.`oldestEntryId`, value.`conversationId`, value.`agentId`, value.`conversation`, value.`live`, value.`partial`, value.`usage`, value.`status`, value.`updatedAt`, value.`error`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadConversationCodec::class) data class SnapshotEventPayloadConversation( val `id`: String, val `agentId`: String, @@ -2264,9 +2571,36 @@ data class SnapshotEventPayloadConversation( val `createdAt`: String, val `updatedAt`: String, val `archived`: Boolean? = null, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadConversationFields( + val `id`: String, + val `agentId`: String, + val `title`: String, + val `createdAt`: String, + val `updatedAt`: String, + val `archived`: Boolean? = null, +) +object SnapshotEventPayloadConversationCodec : KSerializer { + private val declared = setOf("id", "agentId", "title", "createdAt", "updatedAt", "archived") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadConversation") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadConversation { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadConversation(fields.`id`, fields.`agentId`, fields.`title`, fields.`createdAt`, fields.`updatedAt`, fields.`archived`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadConversation) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadConversationFields(value.`id`, value.`agentId`, value.`title`, value.`createdAt`, value.`updatedAt`, value.`archived`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemCodec::class) data class SnapshotEventPayloadMessagesItem( val `id`: String, val `entryId`: String, @@ -2283,23 +2617,123 @@ data class SnapshotEventPayloadMessagesItem( val `communication`: SnapshotEventPayloadMessagesItemCommunication? = null, val `jobQuestion`: SnapshotEventPayloadMessagesItemJobQuestion? = null, val `heldAction`: SnapshotEventPayloadMessagesItemHeldAction? = null, + val `call`: SnapshotEventPayloadMessagesItemCall? = null, val `attachments`: List? = null, val `toolCallId`: String? = null, val `toolName`: String? = null, val `isError`: Boolean? = null, val `toolDetails`: JsonElement? = null, val `sources`: List? = null, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadMessagesItemFields( + val `id`: String, + val `entryId`: String, + val `requestId`: String? = null, + val `role`: String, + val `content`: String, + val `blocks`: JsonElement? = null, + val `stopReason`: String? = null, + val `timestamp`: Double? = null, + val `kind`: String? = null, + val `replyTo`: String? = null, + val `replyPreview`: String? = null, + val `origin`: SnapshotEventPayloadMessagesItemOrigin? = null, + val `communication`: SnapshotEventPayloadMessagesItemCommunication? = null, + val `jobQuestion`: SnapshotEventPayloadMessagesItemJobQuestion? = null, + val `heldAction`: SnapshotEventPayloadMessagesItemHeldAction? = null, + val `call`: SnapshotEventPayloadMessagesItemCall? = null, + val `attachments`: List? = null, + val `toolCallId`: String? = null, + val `toolName`: String? = null, + val `isError`: Boolean? = null, + val `toolDetails`: JsonElement? = null, + val `sources`: List? = null, +) +object SnapshotEventPayloadMessagesItemCodec : KSerializer { + private val declared = setOf("id", "entryId", "requestId", "role", "content", "blocks", "stopReason", "timestamp", "kind", "replyTo", "replyPreview", "origin", "communication", "jobQuestion", "heldAction", "call", "attachments", "toolCallId", "toolName", "isError", "toolDetails", "sources") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItem") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItem { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItem(fields.`id`, fields.`entryId`, fields.`requestId`, fields.`role`, fields.`content`, fields.`blocks`, fields.`stopReason`, fields.`timestamp`, fields.`kind`, fields.`replyTo`, fields.`replyPreview`, fields.`origin`, fields.`communication`, fields.`jobQuestion`, fields.`heldAction`, fields.`call`, fields.`attachments`, fields.`toolCallId`, fields.`toolName`, fields.`isError`, fields.`toolDetails`, fields.`sources`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItem) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemFields(value.`id`, value.`entryId`, value.`requestId`, value.`role`, value.`content`, value.`blocks`, value.`stopReason`, value.`timestamp`, value.`kind`, value.`replyTo`, value.`replyPreview`, value.`origin`, value.`communication`, value.`jobQuestion`, value.`heldAction`, value.`call`, value.`attachments`, value.`toolCallId`, value.`toolName`, value.`isError`, value.`toolDetails`, value.`sources`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemAttachmentsItemCodec::class) data class SnapshotEventPayloadMessagesItemAttachmentsItem( val `name`: String, val `mimeType`: String, val `size`: Double, val `kind`: String, + val additionalProperties: Map = emptyMap(), +) +@Serializable +private data class SnapshotEventPayloadMessagesItemAttachmentsItemFields( + val `name`: String, + val `mimeType`: String, + val `size`: Double, + val `kind`: String, ) +object SnapshotEventPayloadMessagesItemAttachmentsItemCodec : KSerializer { + private val declared = setOf("name", "mimeType", "size", "kind") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemAttachmentsItem") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemAttachmentsItem { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemAttachmentsItem(fields.`name`, fields.`mimeType`, fields.`size`, fields.`kind`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemAttachmentsItem) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemAttachmentsItemFields(value.`name`, value.`mimeType`, value.`size`, value.`kind`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} +@Serializable(with = SnapshotEventPayloadMessagesItemCallCodec::class) +data class SnapshotEventPayloadMessagesItemCall( + val `callId`: String, + val `direction`: String, + val `callee`: String? = null, + val additionalProperties: Map = emptyMap(), +) @Serializable +private data class SnapshotEventPayloadMessagesItemCallFields( + val `callId`: String, + val `direction`: String, + val `callee`: String? = null, +) +object SnapshotEventPayloadMessagesItemCallCodec : KSerializer { + private val declared = setOf("callId", "direction", "callee") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemCall") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemCall { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemCall(fields.`callId`, fields.`direction`, fields.`callee`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemCall) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemCallFields(value.`callId`, value.`direction`, value.`callee`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemCommunicationCodec::class) data class SnapshotEventPayloadMessagesItemCommunication( val `executionId`: String, val `fromAgentId`: String, @@ -2307,36 +2741,168 @@ data class SnapshotEventPayloadMessagesItemCommunication( val `toAgentId`: String, val `toName`: String, val `status`: String, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadMessagesItemCommunicationFields( + val `executionId`: String, + val `fromAgentId`: String, + val `fromName`: String, + val `toAgentId`: String, + val `toName`: String, + val `status`: String, +) +object SnapshotEventPayloadMessagesItemCommunicationCodec : KSerializer { + private val declared = setOf("executionId", "fromAgentId", "fromName", "toAgentId", "toName", "status") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemCommunication") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemCommunication { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemCommunication(fields.`executionId`, fields.`fromAgentId`, fields.`fromName`, fields.`toAgentId`, fields.`toName`, fields.`status`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemCommunication) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemCommunicationFields(value.`executionId`, value.`fromAgentId`, value.`fromName`, value.`toAgentId`, value.`toName`, value.`status`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemHeldActionCodec::class) data class SnapshotEventPayloadMessagesItemHeldAction( val `id`: String, val `job`: String, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadMessagesItemHeldActionFields( + val `id`: String, + val `job`: String, +) +object SnapshotEventPayloadMessagesItemHeldActionCodec : KSerializer { + private val declared = setOf("id", "job") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemHeldAction") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemHeldAction { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemHeldAction(fields.`id`, fields.`job`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemHeldAction) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemHeldActionFields(value.`id`, value.`job`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemJobQuestionCodec::class) data class SnapshotEventPayloadMessagesItemJobQuestion( val `id`: String, val `kind`: String, val `job`: String, val `options`: List? = null, val `multiple`: Boolean? = null, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadMessagesItemJobQuestionFields( + val `id`: String, + val `kind`: String, + val `job`: String, + val `options`: List? = null, + val `multiple`: Boolean? = null, +) +object SnapshotEventPayloadMessagesItemJobQuestionCodec : KSerializer { + private val declared = setOf("id", "kind", "job", "options", "multiple") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemJobQuestion") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemJobQuestion { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemJobQuestion(fields.`id`, fields.`kind`, fields.`job`, fields.`options`, fields.`multiple`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemJobQuestion) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemJobQuestionFields(value.`id`, value.`kind`, value.`job`, value.`options`, value.`multiple`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemOriginCodec::class) data class SnapshotEventPayloadMessagesItemOrigin( val `kind`: String, val `name`: String? = null, val `agentId`: String? = null, val `via`: String? = null, val `source`: String? = null, + val additionalProperties: Map = emptyMap(), ) - @Serializable +private data class SnapshotEventPayloadMessagesItemOriginFields( + val `kind`: String, + val `name`: String? = null, + val `agentId`: String? = null, + val `via`: String? = null, + val `source`: String? = null, +) +object SnapshotEventPayloadMessagesItemOriginCodec : KSerializer { + private val declared = setOf("kind", "name", "agentId", "via", "source") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemOrigin") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemOrigin { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemOrigin(fields.`kind`, fields.`name`, fields.`agentId`, fields.`via`, fields.`source`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemOrigin) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemOriginFields(value.`kind`, value.`name`, value.`agentId`, value.`via`, value.`source`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable(with = SnapshotEventPayloadMessagesItemSourcesItemCodec::class) data class SnapshotEventPayloadMessagesItemSourcesItem( val `url`: String, val `title`: String, + val additionalProperties: Map = emptyMap(), +) +@Serializable +private data class SnapshotEventPayloadMessagesItemSourcesItemFields( + val `url`: String, + val `title`: String, +) +object SnapshotEventPayloadMessagesItemSourcesItemCodec : KSerializer { + private val declared = setOf("url", "title") + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SnapshotEventPayloadMessagesItemSourcesItem") + override fun deserialize(decoder: Decoder): SnapshotEventPayloadMessagesItemSourcesItem { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement().jsonObject + val fields = input.json.decodeFromJsonElement(JsonObject(element.filterKeys { it in declared })) + val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement(it.value) } + return SnapshotEventPayloadMessagesItemSourcesItem(fields.`url`, fields.`title`, additional) + } + override fun serialize(encoder: Encoder, value: SnapshotEventPayloadMessagesItemSourcesItem) { + val output = encoder as JsonEncoder + val fields = output.json.encodeToJsonElement(SnapshotEventPayloadMessagesItemSourcesItemFields(value.`url`, value.`title`)).jsonObject + val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement(it.value) } + output.encodeJsonElement(JsonObject(additional + fields)) + } +} + +@Serializable +data class SpokenExecution( + val `execution`: Execution, + val `reply`: List?, + val `hangUp`: Boolean, ) @Serializable @@ -2349,6 +2915,9 @@ data class StartProviderLoginRequest( val `method`: String, ) +@Serializable +class StartVoiceCallRequest + @Serializable data class TranscriptMessage( val `id`: String, @@ -2366,6 +2935,7 @@ data class TranscriptMessage( val `communication`: BotCommunication? = null, val `jobQuestion`: TranscriptMessageJobQuestion? = null, val `heldAction`: TranscriptMessageHeldAction? = null, + val `call`: TranscriptMessageCall? = null, val `attachments`: List? = null, val `toolCallId`: String? = null, val `toolName`: String? = null, @@ -2374,6 +2944,13 @@ data class TranscriptMessage( val `sources`: List? = null, ) +@Serializable +data class TranscriptMessageCall( + val `callId`: String, + val `direction`: String, + val `callee`: String? = null, +) + @Serializable data class TranscriptMessageHeldAction( val `id`: String, @@ -2735,6 +3312,66 @@ data class UpdateVisionModelRequestVisionModel( val `model`: String, ) +@Serializable +data class UpdateVoiceGatewayRequest( + val `enabled`: Boolean, + val `ownerNumbers`: List, + val `lines`: List, +) + +@Serializable +data class UpdateVoiceGatewayRequestLinesItem( + val `username`: String, + val `botId`: String, + val `displayName`: String?, + val `callsOwner`: Boolean, + val `callsOthers`: Boolean, +) + +@Serializable +data class UpdateVoiceGatewayResponse( + val `config`: UpdateVoiceGatewayResponseConfig, + val `token`: UpdateVoiceGatewayResponseToken?, + val `status`: String, + val `connectedAt`: String?, + val `lines`: List, + val `unavailableLines`: List, +) + +@Serializable +data class UpdateVoiceGatewayResponseConfig( + val `enabled`: Boolean, + val `ownerNumbers`: List, + val `lines`: List, +) + +@Serializable +data class UpdateVoiceGatewayResponseConfigLinesItem( + val `username`: String, + val `botId`: String, + val `displayName`: String?, + val `callsOwner`: Boolean, + val `callsOthers`: Boolean, +) + +@Serializable +data class UpdateVoiceGatewayResponseLinesItem( + val `username`: String, + val `registered`: Boolean, + val `error`: String?, +) + +@Serializable +data class UpdateVoiceGatewayResponseToken( + val `issuedAt`: String, +) + +@Serializable +data class UpdateVoiceGatewayResponseUnavailableLinesItem( + val `username`: String, + val `reason`: String, +) + @Serializable data class UpdateWebSearchRequest( val `defaultProvider`: String, @@ -2764,3 +3401,76 @@ data class VisionModelVisionModel( val `provider`: String, val `model`: String, ) + +@Serializable +data class VoiceCallCancelEvent( + val `callId`: String, +) + +@Serializable +data class VoiceCallEvent( + val `callId`: String, + val `username`: String, + val `to`: String, + val `greeting`: String, + val `party`: String? = null, +) + +@Serializable(with = VoiceCallReportCodec::class) +sealed class VoiceCallReport { + data class Variant1(val value: VoiceCallReportVariant1) : VoiceCallReport() + data class Variant2(val value: VoiceCallReportVariant2) : VoiceCallReport() +} +object VoiceCallReportCodec : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("VoiceCallReport") + override fun deserialize(decoder: Decoder): VoiceCallReport { + val input = decoder as JsonDecoder + val element = input.decodeJsonElement() + val matches = listOf((element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ringing\"") || element["outcome"] == Json.parseToJsonElement("\"answered\"") || element["outcome"] == Json.parseToJsonElement("\"unanswered\"") || element["outcome"] == Json.parseToJsonElement("\"busy\"") || element["outcome"] == Json.parseToJsonElement("\"failed\""))), (element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ended\"")) && element["reason"] != null && (element["reason"] == Json.parseToJsonElement("\"hungUp\"") || element["reason"] == Json.parseToJsonElement("\"maxDuration\"") || element["reason"] == Json.parseToJsonElement("\"failed\"") || element["reason"] == Json.parseToJsonElement("\"stopping\"")))) + require(matches.count { it } == 1) { "Expected exactly one VoiceCallReport variant" } + return when { + (element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ringing\"") || element["outcome"] == Json.parseToJsonElement("\"answered\"") || element["outcome"] == Json.parseToJsonElement("\"unanswered\"") || element["outcome"] == Json.parseToJsonElement("\"busy\"") || element["outcome"] == Json.parseToJsonElement("\"failed\""))) -> VoiceCallReport.Variant1(input.json.decodeFromJsonElement(element)) + (element is JsonObject && element["outcome"] != null && (element["outcome"] == Json.parseToJsonElement("\"ended\"")) && element["reason"] != null && (element["reason"] == Json.parseToJsonElement("\"hungUp\"") || element["reason"] == Json.parseToJsonElement("\"maxDuration\"") || element["reason"] == Json.parseToJsonElement("\"failed\"") || element["reason"] == Json.parseToJsonElement("\"stopping\""))) -> VoiceCallReport.Variant2(input.json.decodeFromJsonElement(element)) + else -> throw SerializationException("Unknown VoiceCallReport variant") + } + } + override fun serialize(encoder: Encoder, value: VoiceCallReport) { + val output = encoder as JsonEncoder + output.encodeJsonElement(when (value) { + is VoiceCallReport.Variant1 -> output.json.encodeToJsonElement(value.value) + is VoiceCallReport.Variant2 -> output.json.encodeToJsonElement(value.value) + }) + } +} + +@Serializable +data class VoiceCallReportVariant1( + val `outcome`: String, + val `error`: String? = null, +) + +@Serializable +data class VoiceCallReportVariant2( + val `outcome`: String, + val `reason`: String, + val `error`: String? = null, +) + +@Serializable +data class VoiceGatewayConfigEvent( + val `enabled`: Boolean, + val `ownerNumbers`: List, + val `lines`: List, +) + +@Serializable +data class VoiceGatewayConfigEventLinesItem( + val `username`: String, + val `botId`: String, + val `botName`: String, + val `displayName`: String, + val `callsOwner`: Boolean, +) + +@Serializable +class VoiceGatewayReplacedEvent diff --git a/app/src/main/java/dev/crumble/android/generated/ContractOperations.kt b/app/src/main/java/dev/crumble/android/generated/ContractOperations.kt index 4499a62..c099c5d 100644 --- a/app/src/main/java/dev/crumble/android/generated/ContractOperations.kt +++ b/app/src/main/java/dev/crumble/android/generated/ContractOperations.kt @@ -1,4 +1,4 @@ -// Generated by scripts/generate-contract.py 1.1.0. Do not edit. +// Generated by scripts/generate-contract.py 1.2.0. Do not edit. package dev.crumble.android.generated import kotlinx.serialization.* @@ -17,6 +17,13 @@ class ContractOperations(private val boundary: ContractBoundary) { return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) } + suspend fun getOpenAPI(timeoutMs: Int = 20_000): GetOpenAPIResponse { + val query = mutableListOf() + val path = "/openapi.json" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("GET", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + suspend fun getCapabilities(timeoutMs: Int = 20_000): Capabilities { val query = mutableListOf() val path = "/v1/capabilities" + if (query.isEmpty()) "" else "?" + query.joinToString("&") @@ -139,6 +146,81 @@ class ContractOperations(private val boundary: ContractBoundary) { return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) } + suspend fun getVoiceGateway(timeoutMs: Int = 20_000): GetVoiceGatewayResponse { + val query = mutableListOf() + val path = "/v1/voice-gateway" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("GET", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun updateVoiceGateway(body: UpdateVoiceGatewayRequest, timeoutMs: Int = 20_000): UpdateVoiceGatewayResponse { + val query = mutableListOf() + val path = "/v1/voice-gateway" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("PUT", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun issueVoiceGatewayToken(timeoutMs: Int = 20_000): IssueVoiceGatewayTokenResponse { + val query = mutableListOf() + val path = "/v1/voice-gateway/token" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("POST", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun revokeVoiceGatewayToken(timeoutMs: Int = 20_000): Unit { + val query = mutableListOf() + val path = "/v1/voice-gateway/token" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + boundary.send("DELETE", path, null, timeoutMs) + } + + suspend fun streamVoiceGatewayEvents(`calls`: String? = null, timeoutMs: Int = 20_000): HttpRequestData { + val query = mutableListOf() + `calls`?.let { query.add("calls=" + boundary.escape(it)) } + val path = "/v1/voice/events" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + return boundary.request("GET", path, null, timeoutMs) + } + + suspend fun sendSpokenMessage(body: SendSpokenMessageRequest, `username`: String, timeoutMs: Int = 20_000): Execution { + val query = mutableListOf() + val path = "/v1/voice/lines/${boundary.escape(`username`)}/messages" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("POST", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun streamVoiceLineEvents(`username`: String, `limit`: Long? = null, `partial`: String? = null, `callId`: String? = null, timeoutMs: Int = 20_000): HttpRequestData { + val query = mutableListOf() + `limit`?.let { query.add("limit=" + boundary.escape(it.toString())) } + `partial`?.let { query.add("partial=" + boundary.escape(it)) } + `callId`?.let { query.add("callId=" + boundary.escape(it)) } + val path = "/v1/voice/lines/${boundary.escape(`username`)}/events" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + return boundary.request("GET", path, null, timeoutMs) + } + + suspend fun getSpokenExecution(`username`: String, `executionId`: String, timeoutMs: Int = 20_000): SpokenExecution { + val query = mutableListOf() + val path = "/v1/voice/lines/${boundary.escape(`username`)}/executions/${boundary.escape(`executionId`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("GET", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun startVoiceCall(body: StartVoiceCallRequest, `username`: String, `callId`: String, timeoutMs: Int = 20_000): Unit { + val query = mutableListOf() + val path = "/v1/voice/lines/${boundary.escape(`username`)}/calls/${boundary.escape(`callId`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + boundary.send("PUT", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs) + } + + suspend fun reportVoiceCall(body: RequestVoiceCallReport, `callId`: String, timeoutMs: Int = 20_000): Unit { + val query = mutableListOf() + val path = "/v1/voice/calls/${boundary.escape(`callId`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + boundary.send("POST", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs) + } + + suspend fun reportVoiceLines(body: ReportVoiceLinesRequest, timeoutMs: Int = 20_000): Unit { + val query = mutableListOf() + val path = "/v1/voice/lines" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + boundary.send("PUT", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs) + } + suspend fun listGithubApps(timeoutMs: Int = 20_000): ListGithubAppsResponse { val query = mutableListOf() val path = "/v1/github-apps" + if (query.isEmpty()) "" else "?" + query.joinToString("&") @@ -461,9 +543,10 @@ class ContractOperations(private val boundary: ContractBoundary) { return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) } - suspend fun streamChatEvents(`id`: String, `limit`: Long? = null, timeoutMs: Int = 20_000): HttpRequestData { + suspend fun streamChatEvents(`id`: String, `limit`: Long? = null, `partial`: String? = null, timeoutMs: Int = 20_000): HttpRequestData { val query = mutableListOf() `limit`?.let { query.add("limit=" + boundary.escape(it.toString())) } + `partial`?.let { query.add("partial=" + boundary.escape(it)) } val path = "/v1/agents/${boundary.escape(`id`)}/events" + if (query.isEmpty()) "" else "?" + query.joinToString("&") return boundary.request("GET", path, null, timeoutMs) } @@ -579,6 +662,36 @@ class ContractOperations(private val boundary: ContractBoundary) { return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) } + suspend fun listCallRequests(`id`: String, timeoutMs: Int = 20_000): List { + val query = mutableListOf() + val path = "/v1/agents/${boundary.escape(`id`)}/call-requests" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("GET", path, null, timeoutMs) + return boundary.json.decodeFromString>(bytes.toString(Charsets.UTF_8)) + } + + suspend fun approveCallRequest(`id`: String, `requestId`: String, timeoutMs: Int = 20_000): CallRequest { + val query = mutableListOf() + val path = "/v1/agents/${boundary.escape(`id`)}/call-requests/${boundary.escape(`requestId`)}/approve" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("POST", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun declineCallRequest(`id`: String, `requestId`: String, timeoutMs: Int = 20_000): CallRequest { + val query = mutableListOf() + val path = "/v1/agents/${boundary.escape(`id`)}/call-requests/${boundary.escape(`requestId`)}/decline" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("POST", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + + suspend fun getCallRequestTranscript(`id`: String, `requestId`: String, `limit`: Long? = null, `before`: String? = null, timeoutMs: Int = 20_000): TranscriptPage { + val query = mutableListOf() + `limit`?.let { query.add("limit=" + boundary.escape(it.toString())) } + `before`?.let { query.add("before=" + boundary.escape(it)) } + val path = "/v1/agents/${boundary.escape(`id`)}/call-requests/${boundary.escape(`requestId`)}/transcript" + if (query.isEmpty()) "" else "?" + query.joinToString("&") + val bytes = boundary.send("GET", path, null, timeoutMs) + return boundary.json.decodeFromString(bytes.toString(Charsets.UTF_8)) + } + suspend fun saveAgentCredential(body: SaveAgentCredentialRequest, `id`: String, `name`: String, timeoutMs: Int = 20_000): Credential { val query = mutableListOf() val path = "/v1/agents/${boundary.escape(`id`)}/credentials/${boundary.escape(`name`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&") diff --git a/app/src/main/java/dev/crumble/android/generated/event-projection.json b/app/src/main/java/dev/crumble/android/generated/event-projection.json index bd56149..20592b3 100644 --- a/app/src/main/java/dev/crumble/android/generated/event-projection.json +++ b/app/src/main/java/dev/crumble/android/generated/event-projection.json @@ -153,6 +153,29 @@ ], "additionalProperties": true }, + "call": { + "type": "object", + "properties": { + "callId": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "inbound", + "outbound" + ] + }, + "callee": { + "type": "string" + } + }, + "required": [ + "callId", + "direction" + ], + "additionalProperties": true + }, "attachments": { "type": "array", "items": { diff --git a/app/src/main/java/dev/crumble/android/model/AppModel.kt b/app/src/main/java/dev/crumble/android/model/AppModel.kt index bb3b013..98ebf28 100644 --- a/app/src/main/java/dev/crumble/android/model/AppModel.kt +++ b/app/src/main/java/dev/crumble/android/model/AppModel.kt @@ -11,6 +11,7 @@ import dev.crumble.android.generated.MemorySource import dev.crumble.android.generated.ScriptRun import dev.crumble.android.generated.SkillPackage import dev.crumble.android.generated.BotRequest +import dev.crumble.android.generated.CallRequest import dev.crumble.android.generated.Credential import dev.crumble.android.generated.CredentialRequest import dev.crumble.android.generated.Credentials @@ -268,6 +269,8 @@ private fun defaultAppScope(): CoroutineScope { } private const val DECISION_POLL_MS = 5_000L +private const val CALL_TRANSCRIPT_POLL_MS = 2_000L +private const val CALL_TRANSCRIPT_PAGE_SIZE = 200 private const val BOT_STATUS_POLL_MS = 5_000L private const val PROVIDER_LOGIN_POLL_MS = 1_000L private const val JOB_REFRESH_MS = 3_000L @@ -314,6 +317,9 @@ class AppModel( var heldActions by mutableStateOf>(emptyList()) var codingJobs by mutableStateOf>(emptyList()) var botRequests by mutableStateOf>(emptyList()) + var callRequests by mutableStateOf>(emptyList()) + var callTranscript by mutableStateOf(null) + private var callTranscriptJob: Job? = null var decisionsLoaded by mutableStateOf(false) var decisionsFailed by mutableStateOf(false) var decidingIDs by mutableStateOf>(emptySet()) @@ -916,6 +922,7 @@ class AppModel( heldActions = emptyList() codingJobs = emptyList() botRequests = emptyList() + callRequests = emptyList() decisionsLoaded = false decisionsFailed = false decisionErrors = emptyMap() @@ -997,6 +1004,7 @@ class AppModel( streamStatus = "" decisionsJob?.cancel() decisionsKey = "" + closeCallTranscript() closeJobs() } @@ -1005,10 +1013,16 @@ class AppModel( message.heldAction?.let { ref -> heldActions.any { it.id == ref.id && it.status == "pending" } } == true || message.jobQuestion?.let { ref -> codingJobs.any { it.question?.id == ref.id } } == true || message.botRequest?.let { ref -> botRequests.any { it.id == ref.id && it.status == "pending" } } == true || + message.callRequest?.let { ref -> callRequests.any { it.id == ref.id && (it.status == "pending" || it.status == "calling") } } == true || message.credentialRequest?.let { ref -> botCredentials[selectedAgentID]?.requests?.any { ref.answers(it.name, it.requestedAt) } } == true || message.loginRequest?.let { ref -> botLogins[selectedAgentID]?.requests?.any { ref.answers(it.name, it.requestedAt) } } == true } + private fun isCallNotice(message: Message) = + message.role == "user" && (message.origin?.kind == "call" || message.origin?.kind == "call_request") + + private fun concernsCalls(message: Message) = message.callRequest != null || isCallNotice(message) + private fun secretRefs() = messages.flatMap { listOfNotNull(it.credentialRequest?.let(::credentialReadKey), it.loginRequest?.let(::loginReadKey)) } @@ -1018,6 +1032,7 @@ class AppModel( val held = if (messages.any { it.heldAction != null }) api.heldActions(agentID) else emptyList() val jobs = if (messages.any { it.jobQuestion != null }) api.codingJobs(agentID) else emptyList() val requests = if (messages.any { it.botRequest != null }) api.botRequests(agentID) else emptyList() + val calls = if (messages.any(::concernsCalls)) api.callRequests(agentID) else emptyList() val asked = secretRefs().toSet() val credentialMark = secretMark("credential", agentID) val loginMark = secretMark("login", agentID) @@ -1027,6 +1042,7 @@ class AppModel( heldActions = held codingJobs = jobs botRequests = requests + callRequests = calls if (credentialList != null && secretSettled("credential", agentID, credentialMark)) botCredentials = botCredentials + (agentID to credentialList) if (loginList != null && secretSettled("login", agentID, loginMark)) botLogins = botLogins + (agentID to loginList) freshSecretRequests = asked @@ -1043,7 +1059,9 @@ class AppModel( // Another client, a stopped job, or a restart can settle a decision, so keep reading while one waits. private fun followDecisions() { - val refs = messages.flatMap { listOfNotNull(it.heldAction?.id, it.jobQuestion?.id, it.botRequest?.id) } + secretRefs() + val refs = messages.flatMap { + listOfNotNull(it.heldAction?.id, it.jobQuestion?.id, it.botRequest?.id, it.callRequest?.id, it.id.takeIf { _ -> isCallNotice(it) }) + } + secretRefs() val key = selectedAgentID + "\n" + refs.joinToString("\n") val api = client if (refs.isEmpty() || key == decisionsKey || api == null) return @@ -1103,6 +1121,45 @@ class AppModel( null } + fun decideCallRequest(id: String, approve: Boolean) = decide(id) { api, agentID -> + val decided = if (approve) api.approveCallRequest(agentID, id) else api.declineCallRequest(agentID, id) + return@decide { + if (selectedAgentID == agentID) callRequests = callRequests.map { if (it.id == decided.id) decided else it } + } + } + + /** Reads the call's transcript, then follows it every two seconds while the call goes on. */ + fun openCallTranscript(requestID: String) { + val api = client ?: return + val agentID = selectedAgentID + val gen = generation + callTranscriptJob?.cancel() + callTranscript = CallTranscript(agentID, requestID) + callTranscriptJob = scope.launch { + do { + // Checked before the read, so a call that ends meanwhile still gets one last read. + val calling = callRequests.any { it.id == requestID && it.status == "calling" } + try { + val page = api.callRequestTranscript(agentID, requestID, CALL_TRANSCRIPT_PAGE_SIZE) + if (generation != gen || selectedAgentID != agentID) return@launch + callTranscript = CallTranscript(agentID, requestID, callLines(page.messages.orEmpty()), loaded = true) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + if (generation != gen || selectedAgentID != agentID) return@launch + callTranscript = callTranscript?.copy(loaded = true, failed = true) + } + if (calling) delay(CALL_TRANSCRIPT_POLL_MS) + } while (isActive && calling) + } + } + + fun closeCallTranscript() { + callTranscriptJob?.cancel() + callTranscriptJob = null + callTranscript = null + } + private fun addCreatedBot(created: Agent) { if (agents.none { it.id == created.id }) agents = agents + created } diff --git a/app/src/main/java/dev/crumble/android/model/CallRequests.kt b/app/src/main/java/dev/crumble/android/model/CallRequests.kt new file mode 100644 index 0000000..f39e5eb --- /dev/null +++ b/app/src/main/java/dev/crumble/android/model/CallRequests.kt @@ -0,0 +1,57 @@ +package dev.crumble.android.model + +import dev.crumble.android.generated.CallRequest + +private val OUTCOME_LABELS = mapOf( + "answered" to "Answered", + "unanswered" to "Not answered", + "busy" to "Busy", + "failed" to "Failed", +) + +/** The state a call request shows once the owner has decided; an unknown outcome reads as ended. */ +fun callStateLabel(request: CallRequest): String = when { + request.status == "declined" -> "Declined" + request.status == "calling" -> "Calling" + else -> OUTCOME_LABELS[request.outcome] ?: "Ended" +} + +private val ENDED_CALL = Regex("^voice-call:([^:]+):ended$") +private val SWEPT_REQUEST = Regex("^call-request:([^:]+)$") + +/** The call request an outside call's notice belongs to; an owner call has none. */ +fun callRequestForNotice(message: Message, requests: List): CallRequest? { + if (message.role != "user") return null + val kind = message.origin?.kind + if (kind != "call" && kind != "call_request") return null + val requestID = message.requestId.orEmpty() + val callID = ENDED_CALL.matchEntire(requestID)?.groupValues?.get(1) + val swept = SWEPT_REQUEST.matchEntire(requestID)?.groupValues?.get(1) + return requests.firstOrNull { (callID != null && it.callId == callID) || (swept != null && it.id == swept) } +} + +/** What the notice says after the callee's name, such as "ended after 2 minutes". */ +fun callNoticeDetail(message: Message, request: CallRequest): String { + val lead = "Call to ${request.callee} " + return if (message.text.startsWith(lead)) message.text.removePrefix(lead) else message.text +} + +/** One spoken line of a call: the callee's or the bot's. */ +data class CallLine(val id: String, val fromCallee: Boolean, val text: String) + +fun callLines(messages: List): List = messages.mapNotNull { message -> + when { + message.role == "user" -> CallLine(message.id, true, message.text) + message.role == "assistant" && message.text.isNotBlank() -> CallLine(message.id, false, message.text) + else -> null + } +} + +/** The open call's transcript for one bot; `loaded` stays false until the first read settles. */ +data class CallTranscript( + val agentID: String, + val requestID: String, + val lines: List = emptyList(), + val loaded: Boolean = false, + val failed: Boolean = false, +) diff --git a/app/src/main/java/dev/crumble/android/model/Models.kt b/app/src/main/java/dev/crumble/android/model/Models.kt index b3f2fae..315d9bd 100644 --- a/app/src/main/java/dev/crumble/android/model/Models.kt +++ b/app/src/main/java/dev/crumble/android/model/Models.kt @@ -86,6 +86,15 @@ data class MessageOrigin( val name: String? = null, val agentId: String? = null, val source: String? = null, + val via: String? = null, +) + +/** The voice gateway call a `call-started` or `call-ended` event marks. */ +@Serializable +data class MessageCall( + val callId: String = "", + val direction: String = "", + val callee: String? = null, ) /** A persisted request or reply exchanged between two bots. */ @@ -111,6 +120,10 @@ data class JobQuestionRef(val id: String = "", val job: String = "") @Serializable data class BotRequestRef(val id: String = "", val name: String = "") +/** A `request_call` result in the transcript; its live state comes from the bot's call requests. */ +@Serializable +data class CallRequestRef(val id: String = "", val callee: String = "") + /** A page a group message's bot searched or read, stored with the message. */ @Serializable data class MessageSource(val url: String = "", val title: String = "") @@ -143,7 +156,9 @@ data class Message( val deliveryState: MessageDeliveryState = MessageDeliveryState.confirmed, val heldAction: HeldActionRef? = null, val jobQuestion: JobQuestionRef? = null, + val call: MessageCall? = null, val botRequest: BotRequestRef? = null, + val callRequest: CallRequestRef? = null, val credentialRequest: SecretRequestRef? = null, val loginRequest: SecretRequestRef? = null, val toolName: String? = null, @@ -433,8 +448,9 @@ sealed class ChatRow { fun shownInChat(message: Message): Boolean { return when (message.role) { "user" -> (message.text.isNotEmpty() || !message.attachments.isNullOrEmpty()) && !isJobOrigin(message.origin?.kind) - "event" -> message.text.isNotEmpty() - "toolResult" -> message.botRequest != null || message.credentialRequest != null || message.loginRequest != null || + // An outside call shows as one card where its end notice is. + "event" -> message.text.isNotEmpty() && message.call?.callee == null + "toolResult" -> message.botRequest != null || message.callRequest != null || message.credentialRequest != null || message.loginRequest != null || jobOf(message) != null || displayOf(message) != null || isDesktopScreenshot(message) "assistant" -> { val blocks = message.blocks @@ -595,12 +611,27 @@ fun noticeTitle(message: Message): String? { "credential" -> "Credential · $name" "bot" -> "From $name" "bot_request" -> "Bot request" + "call_request" -> "Call request" + "call" -> "Call" else -> null } } fun isNotice(message: Message) = message.role == "user" && noticeTitle(message) != null +/** A centred call line and, on a call the bot placed, the greeting it spoke. */ +data class CallMarker(val title: String, val quote: String? = null) + +fun callMarkerOf(message: Message, botName: String): CallMarker? { + if (message.role == "user") return if (message.origin?.kind == "call") CallMarker(message.text) else null + val call = message.call ?: return null + return when (message.kind) { + "call-ended" -> CallMarker(message.text) + "call-started" -> if (call.direction == "outbound") CallMarker("$botName called you", message.text) else CallMarker("Call started") + else -> null + } +} + fun preview(text: String): String { var plain = text for (mark in listOf("**", "__", "`", "# ", "> ")) { diff --git a/app/src/main/java/dev/crumble/android/network/APIClient.kt b/app/src/main/java/dev/crumble/android/network/APIClient.kt index 2b2a6c6..e325217 100644 --- a/app/src/main/java/dev/crumble/android/network/APIClient.kt +++ b/app/src/main/java/dev/crumble/android/network/APIClient.kt @@ -375,6 +375,12 @@ class APIClient( operations.approveBotRequest(request(proposal?.let { mapOf("proposal" to JsonObject(it)) }.orEmpty()), agentID, id).agent.domain() suspend fun declineBotRequest(agentID: String, id: String) { operations.declineBotRequest(agentID, id) } + suspend fun callRequests(agentID: String) = operations.listCallRequests(agentID) + suspend fun approveCallRequest(agentID: String, id: String) = operations.approveCallRequest(agentID, id) + suspend fun declineCallRequest(agentID: String, id: String) = operations.declineCallRequest(agentID, id) + suspend fun callRequestTranscript(agentID: String, id: String, limit: Int): ChatHistoryResponse = + operations.getCallRequestTranscript(agentID, id, limit.toLong()).domain() + suspend fun agents(): List = operations.listAgents().map { it.domain() } suspend fun createAgent(agent: Agent): Agent = operations.createAgent(agent.wire()).domain() diff --git a/app/src/main/java/dev/crumble/android/network/ContractAdapters.kt b/app/src/main/java/dev/crumble/android/network/ContractAdapters.kt index 5eeb89a..f62710e 100644 --- a/app/src/main/java/dev/crumble/android/network/ContractAdapters.kt +++ b/app/src/main/java/dev/crumble/android/network/ContractAdapters.kt @@ -79,6 +79,14 @@ private fun botRequestRef(toolName: String?, isError: Boolean?, details: JsonEle return BotRequestRef(id, name) } +private fun callRequestRef(toolName: String?, isError: Boolean?, details: JsonElement?): CallRequestRef? { + if (toolName != "request_call" || isError == true) return null + val request = details as? JsonObject ?: return null + val id = (request["id"] as? JsonPrimitive)?.contentOrNull ?: return null + val callee = (request["callee"] as? JsonPrimitive)?.contentOrNull ?: return null + return CallRequestRef(id, callee) +} + private fun secretRequestRef(tool: String, toolName: String?, isError: Boolean?, details: JsonElement?): SecretRequestRef? { if (toolName != tool || isError == true) return null val request = details as? JsonObject ?: return null @@ -90,13 +98,15 @@ private fun secretRequestRef(tool: String, toolName: String?, isError: Boolean?, internal fun TranscriptMessage.domain() = Message( id = id, entryId = entryId, role = role, content = JsonPrimitive(content), blocks = blocks, kind = kind, replyTo = replyTo, replyPreview = replyPreview, - origin = origin?.let { dev.crumble.android.model.MessageOrigin(it.kind, it.name, it.agentId, it.source) }, + origin = origin?.let { dev.crumble.android.model.MessageOrigin(it.kind, it.name, it.agentId, it.source, it.via) }, communication = communication?.let { dev.crumble.android.model.BotCommunication(it.executionId, it.fromAgentId, it.fromName, it.toAgentId, it.toName, it.status) }, timestamp = timestamp?.toLong(), attachments = attachments?.map { dev.crumble.android.model.AttachmentInfo(it.name, it.mimeType, it.size.toInt(), it.kind) }, requestId = requestId, heldAction = heldAction?.let { HeldActionRef(it.id, it.job) }, jobQuestion = jobQuestion?.let { JobQuestionRef(it.id, it.job) }, + call = call?.let { MessageCall(it.callId, it.direction, it.callee) }, botRequest = botRequestRef(toolName, isError, toolDetails), + callRequest = callRequestRef(toolName, isError, toolDetails), credentialRequest = secretRequestRef("request_credential", toolName, isError, toolDetails), loginRequest = secretRequestRef("request_login", toolName, isError, toolDetails), toolName = toolName, toolCallId = toolCallId, isError = isError, toolDetails = toolDetails, @@ -106,13 +116,15 @@ internal fun TranscriptMessage.domain() = Message( internal fun SnapshotEventPayloadMessagesItem.domain() = Message( id = id, entryId = entryId, role = role, content = JsonPrimitive(content), blocks = blocks, kind = kind, replyTo = replyTo, replyPreview = replyPreview, - origin = origin?.let { dev.crumble.android.model.MessageOrigin(it.kind, it.name, it.agentId, it.source) }, + origin = origin?.let { dev.crumble.android.model.MessageOrigin(it.kind, it.name, it.agentId, it.source, it.via) }, communication = communication?.let { dev.crumble.android.model.BotCommunication(it.executionId, it.fromAgentId, it.fromName, it.toAgentId, it.toName, it.status) }, timestamp = timestamp?.toLong(), attachments = attachments?.map { dev.crumble.android.model.AttachmentInfo(it.name, it.mimeType, it.size.toInt(), it.kind) }, requestId = requestId, heldAction = heldAction?.let { HeldActionRef(it.id, it.job) }, jobQuestion = jobQuestion?.let { JobQuestionRef(it.id, it.job) }, + call = call?.let { MessageCall(it.callId, it.direction, it.callee) }, botRequest = botRequestRef(toolName, isError, toolDetails), + callRequest = callRequestRef(toolName, isError, toolDetails), credentialRequest = secretRequestRef("request_credential", toolName, isError, toolDetails), loginRequest = secretRequestRef("request_login", toolName, isError, toolDetails), toolName = toolName, toolCallId = toolCallId, isError = isError, toolDetails = toolDetails, diff --git a/app/src/main/java/dev/crumble/android/ui/CallScreen.kt b/app/src/main/java/dev/crumble/android/ui/CallScreen.kt new file mode 100644 index 0000000..7662baf --- /dev/null +++ b/app/src/main/java/dev/crumble/android/ui/CallScreen.kt @@ -0,0 +1,135 @@ +package dev.crumble.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.BorderStroke +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.crumble.android.generated.CallRequest +import dev.crumble.android.model.AppModel +import dev.crumble.android.model.CallTranscript +import dev.crumble.android.model.callStateLabel + +@Composable +private fun CallTitle(request: CallRequest?) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Default.Phone, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Column { + Text( + request?.callee ?: "Call", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (request != null) { + Text( + request.number, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun CallLineView(speaker: String, mine: Boolean, text: String) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (mine) Alignment.End else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(speaker, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Surface( + shape = RoundedCornerShape(12.dp), + color = if (mine) cardColor else MaterialTheme.colorScheme.surface, + border = if (mine) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) + } + } +} + +/** A call request's call over the chat: its goal, then what was said, live while it goes on. */ +@Composable +fun CallDialog(model: AppModel, transcript: CallTranscript) { + val request = model.callRequests.firstOrNull { it.id == transcript.requestID } + val botName = model.selectedAgent?.name ?: "Bot" + val calling = request?.status == "calling" + JobDialog( + onClose = { model.closeCallTranscript() }, + title = { CallTitle(request) }, + actions = { + if (request != null) { + Text( + callStateLabel(request), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + } + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (request == null) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + return@JobDialog + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + GroupLabel("Goal") + Text(request.goal, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 16.dp)) + } + if (request.outcome == "failed" && !request.error.isNullOrEmpty()) { + Text(request.error, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + CallLineView(botName, mine = true, text = request.opening) + for (line in transcript.lines) { + CallLineView(if (line.fromCallee) request.callee else botName, mine = !line.fromCallee, text = line.text) + } + if (!transcript.loaded) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + if (transcript.failed) { + Text("Couldn't load the transcript.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (transcript.loaded && !transcript.failed && !calling && transcript.lines.isEmpty()) { + Text("No transcript.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (calling) { + Text("On the call…", style = MaterialTheme.typography.bodyMedium) + } + } + } + } +} diff --git a/app/src/main/java/dev/crumble/android/ui/ChatScreen.kt b/app/src/main/java/dev/crumble/android/ui/ChatScreen.kt index 7aba295..0eab3e8 100644 --- a/app/src/main/java/dev/crumble/android/ui/ChatScreen.kt +++ b/app/src/main/java/dev/crumble/android/ui/ChatScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Phone import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material.icons.outlined.Image @@ -106,6 +107,7 @@ import dev.crumble.android.model.Agent import dev.crumble.android.model.AppModel import dev.crumble.android.model.ChatRow import dev.crumble.android.model.MentionCompletion +import dev.crumble.android.model.CallMarker import dev.crumble.android.model.Message import dev.crumble.android.model.MessageDeliveryState import dev.crumble.android.model.PendingAttachment @@ -114,6 +116,9 @@ import dev.crumble.android.model.Sources import dev.crumble.android.model.SplitReply import dev.crumble.android.model.botReactions import dev.crumble.android.model.displayOf +import dev.crumble.android.model.callMarkerOf +import dev.crumble.android.model.callNoticeDetail +import dev.crumble.android.model.callRequestForNotice import dev.crumble.android.model.isDesktopScreenshot import dev.crumble.android.model.isNotice import dev.crumble.android.model.isWebLink @@ -257,6 +262,7 @@ fun ChatRowView( MessageView( model = model, message = row.message, + botName = agent.name, sources = sources[row.message.id], reactions = reactions[row.message.entryId].orEmpty(), ) @@ -352,6 +358,7 @@ internal fun TimeRow(label: String) { fun MessageView( model: AppModel, message: Message, + botName: String, sources: Sources? = null, reactions: List = emptyList(), ) { @@ -359,6 +366,16 @@ fun MessageView( var menuExpanded by remember { mutableStateOf(false) } val clipboard = LocalClipboardManager.current + val outsideCall = callRequestForNotice(message, model.callRequests)?.takeIf { it.status != "declined" } + if (outsideCall != null) { + PlacedCallCard(model, outsideCall, callNoticeDetail(message, outsideCall)) + return + } + val call = callMarkerOf(message, botName) + if (call != null) { + CallMarkerRow(call, reactions) + return + } if (isNotice(message)) { NoticeRow(model, message) return @@ -379,6 +396,7 @@ fun MessageView( display != null -> DisplayCardView(display) isDesktopScreenshot(message) -> DesktopScreenshotCard(model, message) message.botRequest != null -> BotRequestCard(model, message.botRequest) + message.callRequest != null -> CallRequestCard(model, message.callRequest) message.credentialRequest != null -> CredentialRequestCard(model, message.credentialRequest) message.loginRequest != null -> LoginRequestCard(model, message.loginRequest) job != null -> JobCard(model, job) @@ -445,6 +463,7 @@ fun MessageView( } } Reactions(reactions) + if (message.origin?.via == "voice") SpokenOrigin() DeliveryStatus(message = message, onRetry = { model.retryMessage(message) }) } } @@ -705,6 +724,55 @@ private fun noticeIcon(message: Message): ImageVector? = when (message.origin?.k else -> null } +@Composable +private fun SpokenOrigin() { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Default.Phone, contentDescription = null, modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = "Spoken on a call", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Where a voice call started or ended, centred like the website's call marker. */ +@Composable +fun CallMarkerRow(marker: CallMarker, reactions: List = emptyList()) { + val tint = MaterialTheme.colorScheme.onSurfaceVariant + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Default.Phone, contentDescription = null, modifier = Modifier.size(14.dp), tint = tint) + Text( + text = marker.title, + style = MaterialTheme.typography.labelMedium, + color = tint, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false), + ) + Reactions(reactions) + } + marker.quote?.nilIfEmpty?.let { quote -> + Row( + modifier = Modifier.widthIn(max = 320.dp).height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant), + ) + Text(text = quote, style = MaterialTheme.typography.bodySmall, color = tint) + } + } + } +} + @Composable fun EventView( model: AppModel, diff --git a/app/src/main/java/dev/crumble/android/ui/CrumbleAppScreen.kt b/app/src/main/java/dev/crumble/android/ui/CrumbleAppScreen.kt index 6970f4b..bf3b80d 100644 --- a/app/src/main/java/dev/crumble/android/ui/CrumbleAppScreen.kt +++ b/app/src/main/java/dev/crumble/android/ui/CrumbleAppScreen.kt @@ -226,6 +226,9 @@ fun CrumbleAppScreen(model: AppModel) { model.jobPanel?.let { panel -> JobPanelDialog(model = model, panel = panel) } + model.callTranscript?.let { transcript -> + CallDialog(model = model, transcript = transcript) + } model.botDesktop?.let { desktop -> BotDesktopDialog(model = model, desktop = desktop) } diff --git a/app/src/main/java/dev/crumble/android/ui/DecisionCards.kt b/app/src/main/java/dev/crumble/android/ui/DecisionCards.kt index 6c9d908..37bfc60 100644 --- a/app/src/main/java/dev/crumble/android/ui/DecisionCards.kt +++ b/app/src/main/java/dev/crumble/android/ui/DecisionCards.kt @@ -2,7 +2,13 @@ package dev.crumble.android.ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +22,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.PanTool +import androidx.compose.material.icons.filled.Phone import androidx.compose.material.icons.filled.QuestionAnswer import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -37,20 +44,24 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.crumble.android.generated.BotRequest +import dev.crumble.android.generated.CallRequest import dev.crumble.android.generated.CodingJobQuestion import dev.crumble.android.generated.CredentialRequest import dev.crumble.android.generated.HeldAction import dev.crumble.android.generated.LoginRequest import dev.crumble.android.model.AppModel import dev.crumble.android.model.BotRequestRef +import dev.crumble.android.model.CallRequestRef import dev.crumble.android.model.HeldActionRef import dev.crumble.android.model.JobQuestionRef import dev.crumble.android.model.SecretRequestRef import dev.crumble.android.model.SecretRequestState +import dev.crumble.android.model.callStateLabel import dev.crumble.android.model.credentialReadKey import dev.crumble.android.model.loginReadKey import dev.crumble.android.model.secretKey @@ -425,3 +436,76 @@ private fun SecretRequestPending( } } } + +@Composable +fun CallRequestCard(model: AppModel, ref: CallRequestRef) { + val request = model.callRequests.firstOrNull { it.id == ref.id } + when { + request == null -> SettledCard(Icons.Default.Phone, ref.callee, model.unresolvedOutcome()) + request.status == "pending" -> CallRequestPending(model, request) + request.status == "declined" -> SettledCard(Icons.Default.Phone, request.callee, "Declined") + else -> PlacedCallCard(model, request) + } +} + +@Composable +private fun CallRequestPending(model: AppModel, request: CallRequest) { + val busy = request.id in model.decidingIDs + DecisionSurface { + DecisionHeader(Icons.Default.Phone, "Wants to call for you", request.callee) + Text( + request.number, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(request.goal, style = MaterialTheme.typography.bodySmall) + Text( + "Check that the opening says it's an AI assistant, whom it calls for, that the call is transcribed, and why it calls.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant)) + Text(request.opening, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + DecisionError(model, request.id) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + DecisionPending(model, request.id) + TextButton(enabled = !busy, onClick = { model.decideCallRequest(request.id, approve = false) }) { Text("Decline") } + Button(enabled = !busy, onClick = { model.decideCallRequest(request.id, approve = true) }) { Text("Approve") } + } + } +} + +/** A call the bot placed, as a row that opens the call; `detail` is a notice's text after the callee's name. */ +@Composable +fun PlacedCallCard(model: AppModel, request: CallRequest, detail: String? = null) { + val second = detail?.replaceFirstChar { it.uppercase() } + ?: request.error.takeIf { request.outcome == "failed" && !it.isNullOrEmpty() } + Surface( + onClick = { model.openCallTranscript(request.id) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (request.status == "calling") { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Phone, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(request.callee, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(callStateLabel(request), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (!second.isNullOrEmpty()) { + Text(second, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 28.dp)) + } + } + } +} diff --git a/app/src/test/java/dev/crumble/android/CallRequestTests.kt b/app/src/test/java/dev/crumble/android/CallRequestTests.kt new file mode 100644 index 0000000..b7e989c --- /dev/null +++ b/app/src/test/java/dev/crumble/android/CallRequestTests.kt @@ -0,0 +1,295 @@ +package dev.crumble.android + +import dev.crumble.android.generated.CallRequest +import dev.crumble.android.model.AppModel +import dev.crumble.android.model.CallLine +import dev.crumble.android.model.CallRequestRef +import dev.crumble.android.model.ChatRow +import dev.crumble.android.model.ChatSnapshot +import dev.crumble.android.model.CrumbleJson +import dev.crumble.android.model.Message +import dev.crumble.android.model.MessageCall +import dev.crumble.android.model.MessageOrigin +import dev.crumble.android.model.callLines +import dev.crumble.android.model.callNoticeDetail +import dev.crumble.android.model.callRequestForNotice +import dev.crumble.android.model.callStateLabel +import dev.crumble.android.model.noticeTitle +import dev.crumble.android.network.APIClient +import dev.crumble.android.network.HttpRequestData +import dev.crumble.android.network.HttpResponseData +import dev.crumble.android.network.HttpStreamResponse +import dev.crumble.android.network.HttpTransport +import java.nio.charset.StandardCharsets +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +private class CallTransport(private val handler: (HttpRequestData) -> Pair) : HttpTransport { + val requests = mutableListOf() + val held = mutableMapOf>() + + override suspend fun execute(request: HttpRequestData): HttpResponseData { + requests.add(request) + held["${request.method} ${request.pathWithQuery}"]?.await() + val (status, body) = handler(request) + return HttpResponseData(status, body.toByteArray(StandardCharsets.UTF_8)) + } + + override suspend fun openStream(request: HttpRequestData): HttpStreamResponse = + throw UnsupportedOperationException("streams are not used here") + + fun count(key: String) = requests.count { "${it.method} ${it.pathWithQuery}" == key } +} + +private fun callRequestFixture( + status: String = "pending", + outcome: String? = null, + id: String = "c1", + callId: String? = null, +) = """{"id":"$id","agentId":"a1","number":"+4930123","callee":"Dr. Weber","goal":"Book a visit","opening":"Hello, I am an AI assistant","status":"$status","callId":${callId?.let { "\"$it\"" }},"conversationId":null,"outcome":${outcome?.let { "\"$it\"" }},"error":null,"requestedAt":"2026-11-05T10:00:00Z","decidedAt":null,"endedAt":null}""" + +private fun decoded(status: String = "pending", outcome: String? = null, id: String = "c1", callId: String? = null) = + CrumbleJson.decodeFromString(callRequestFixture(status, outcome, id, callId)) + +private val callRequestMessage = Message(id = "t1", entryId = "t", role = "toolResult", callRequest = CallRequestRef("c1", "Dr. Weber")) + +private fun notice(kind: String, requestId: String?, text: String) = Message( + id = "n1", role = "user", content = kotlinx.serialization.json.JsonPrimitive(text), + origin = MessageOrigin(kind = kind), requestId = requestId, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class CallRequestTests { + private val calls = "/v1/agents/a1/call-requests" + + private fun TestScope.open(transport: CallTransport): AppModel { + val client = APIClient(baseURL = "http://crumble.test/", token = "", transport = transport) + val model = AppModel(client = client, scope = TestScope(StandardTestDispatcher(testScheduler))) + model.selectAgent("a1") + model.applySnapshot(ChatSnapshot(agentId = "a1", messages = listOf(callRequestMessage))) + testScheduler.runCurrent() + return model + } + + @Test + fun requestCallResultBecomesARef() = runTest { + val transport = CallTransport { + 200 to """{"messages":[{"id":"t1","entryId":"t","role":"toolResult","content":"","toolName":"request_call","toolDetails":${callRequestFixture()}},{"id":"t2","entryId":"t","role":"toolResult","content":"","toolName":"request_call","isError":true}],"hasMore":false,"oldestEntryId":null}""" + } + val client = APIClient(baseURL = "http://crumble.test/", token = "", transport = transport) + val messages = client.chatHistory("a1", before = "t", limit = 10).messages.orEmpty() + assertEquals(CallRequestRef("c1", "Dr. Weber"), messages[0].callRequest) + assertNull(messages[1].callRequest) + assertEquals(listOf(true, false), messages.map { ChatRow.shownInChat(it) }) + } + + @Test + fun calleeCarriesFromTheCallEvent() = runTest { + val transport = CallTransport { + 200 to """{"messages":[{"id":"e1","entryId":"e","role":"event","content":"started","kind":"call-started","call":{"callId":"v1","direction":"outbound","callee":"Dr. Weber"}}],"hasMore":false,"oldestEntryId":null}""" + } + val client = APIClient(baseURL = "http://crumble.test/", token = "", transport = transport) + val event = client.chatHistory("a1", before = "e", limit = 10).messages.orEmpty().single() + assertEquals("Dr. Weber", event.call?.callee) + } + + @Test + fun anOutsideCallStartedEventHasNoRow() { + val ownerCall = Message(id = "e1", role = "event", kind = "call-started", content = kotlinx.serialization.json.JsonPrimitive("Hi"), call = MessageCall("v1", "outbound")) + val outside = ownerCall.copy(call = MessageCall("v1", "outbound", "Dr. Weber")) + assertTrue(ChatRow.shownInChat(ownerCall)) + assertEquals(false, ChatRow.shownInChat(outside)) + } + + @Test + fun loadsRequestsWithTheChatAndApprovesWithAPendingState() = runTest { + var status = "pending" + val transport = CallTransport { req -> + when (req.pathWithQuery) { + calls -> 200 to "[${callRequestFixture(status)}]" + "$calls/c1/approve" -> { status = "calling"; 200 to callRequestFixture("calling") } + else -> 503 to "" + } + } + val gate = CompletableDeferred().also { transport.held["POST $calls/c1/approve"] = it } + val model = open(transport) + assertEquals("pending", model.callRequests.single().status) + + model.decideCallRequest("c1", approve = true) + testScheduler.runCurrent() + assertTrue("c1" in model.decidingIDs) + assertEquals("pending", model.callRequests.single().status) + + gate.complete(Unit) + testScheduler.runCurrent() + assertTrue(model.decidingIDs.isEmpty()) + assertEquals("calling", model.callRequests.single().status) + model.cancelStream() + } + + @Test + fun aRefusedApprovalKeepsTheRequestPendingWithTheServersError() = runTest { + val transport = CallTransport { req -> + when (req.pathWithQuery) { + calls -> 200 to "[${callRequestFixture()}]" + "$calls/c1/approve" -> 409 to """{"error":"The voice line is busy."}""" + else -> 503 to "" + } + } + val model = open(transport) + model.decideCallRequest("c1", approve = true) + testScheduler.runCurrent() + assertEquals("pending", model.callRequests.single().status) + assertEquals("The voice line is busy.", model.decisionErrors["c1"]) + assertTrue(model.decidingIDs.isEmpty()) + model.cancelStream() + } + + @Test + fun decliningReplacesTheRequestWithTheServersAnswer() = runTest { + var status = "pending" + val transport = CallTransport { req -> + when (req.pathWithQuery) { + calls -> 200 to "[${callRequestFixture(status)}]" + "$calls/c1/decline" -> { status = "declined"; 200 to callRequestFixture("declined") } + else -> 503 to "" + } + } + val model = open(transport) + model.decideCallRequest("c1", approve = false) + testScheduler.runCurrent() + assertEquals("declined", model.callRequests.single().status) + model.cancelStream() + } + + @Test + fun aLateAnswerForAnotherBotDoesNotLand() = runTest { + val transport = CallTransport { req -> + when (req.pathWithQuery) { + calls -> 200 to "[${callRequestFixture()}]" + "$calls/c1/approve" -> 200 to callRequestFixture("calling") + else -> 503 to "" + } + } + val gate = CompletableDeferred().also { transport.held["POST $calls/c1/approve"] = it } + val model = open(transport) + model.decideCallRequest("c1", approve = true) + testScheduler.runCurrent() + model.selectAgent("a2") + gate.complete(Unit) + testScheduler.runCurrent() + assertTrue(model.callRequests.isEmpty()) + model.cancelStream() + } + + @Test + fun pendingAndCallingRequestsRefreshEveryFiveSeconds() = runTest { + var status = "pending" + val transport = CallTransport { req -> if (req.pathWithQuery == calls) 200 to "[${callRequestFixture(status)}]" else 503 to "" } + val model = open(transport) + assertEquals(1, transport.count("GET $calls")) + + status = "calling" + testScheduler.advanceTimeBy(5_100) + testScheduler.runCurrent() + assertEquals("calling", model.callRequests.single().status) + + status = "ended" + testScheduler.advanceTimeBy(5_100) + testScheduler.runCurrent() + assertEquals("ended", model.callRequests.single().status) + val reads = transport.count("GET $calls") + testScheduler.advanceTimeBy(15_000) + testScheduler.runCurrent() + assertEquals(reads, transport.count("GET $calls")) + model.cancelStream() + } + + @Test + fun theTranscriptRefreshesEveryTwoSecondsWhileTheCallGoesOn() = runTest { + var status = "calling" + var lines = 1 + val transport = CallTransport { req -> + when (req.pathWithQuery) { + calls -> 200 to "[${callRequestFixture(status)}]" + "$calls/c1/transcript?limit=200" -> 200 to """{"messages":[${(1..lines).joinToString(",") { """{"id":"m$it","entryId":"m$it","role":"user","content":"line $it"}""" }}],"hasMore":false,"oldestEntryId":null}""" + else -> 503 to "" + } + } + val model = open(transport) + model.openCallTranscript("c1") + testScheduler.runCurrent() + assertEquals(listOf("line 1"), model.callTranscript!!.lines.map { it.text }) + + lines = 2 + testScheduler.advanceTimeBy(2_100) + testScheduler.runCurrent() + assertEquals(2, model.callTranscript!!.lines.size) + + status = "ended" + testScheduler.advanceTimeBy(5_100) + testScheduler.runCurrent() + val reads = transport.count("GET $calls/c1/transcript?limit=200") + testScheduler.advanceTimeBy(10_000) + testScheduler.runCurrent() + assertEquals(reads, transport.count("GET $calls/c1/transcript?limit=200")) + model.cancelStream() + } + + @Test + fun stateLabels() { + assertEquals("Declined", callStateLabel(decoded("declined"))) + assertEquals("Calling", callStateLabel(decoded("calling"))) + assertEquals("Answered", callStateLabel(decoded("ended", "answered"))) + assertEquals("Not answered", callStateLabel(decoded("ended", "unanswered"))) + assertEquals("Busy", callStateLabel(decoded("ended", "busy"))) + assertEquals("Failed", callStateLabel(decoded("ended", "failed"))) + assertEquals("Ended", callStateLabel(decoded("ended"))) + assertEquals("Ended", callStateLabel(decoded("ended", "somethingNew"))) + } + + @Test + fun noticesBelongToTheirRequest() { + val requests = listOf(decoded("ended", id = "c1", callId = "v1"), decoded("declined", id = "c2")) + assertEquals("c1", callRequestForNotice(notice("call", "voice-call:v1:ended", "x"), requests)?.id) + assertEquals("c2", callRequestForNotice(notice("call_request", "call-request:c2", "x"), requests)?.id) + assertNull(callRequestForNotice(notice("call", "voice-call:v9:ended", "x"), requests)) + assertNull(callRequestForNotice(notice("call", null, "x"), requests)) + assertNull(callRequestForNotice(notice("task", "voice-call:v1:ended", "x"), requests)) + } + + @Test + fun noticeDetailDropsTheLeadingCallTo() { + val request = decoded() + assertEquals("ended after 2 minutes", callNoticeDetail(notice("call", null, "Call to Dr. Weber ended after 2 minutes"), request)) + assertEquals("Nobody picked up", callNoticeDetail(notice("call", null, "Nobody picked up"), request)) + } + + @Test + fun callRequestNoticesHaveATitle() { + assertEquals("Call request", noticeTitle(notice("call_request", "call-request:c1", "x"))) + assertEquals("Bot request", noticeTitle(notice("bot_request", null, "x"))) + } + + @Test + fun transcriptLinesKeepTheCalleeAndTheBotsText() { + fun msg(id: String, role: String, text: String) = Message(id = id, role = role, content = kotlinx.serialization.json.JsonPrimitive(text)) + val lines = callLines( + listOf( + msg("1", "user", "Hello"), + msg("2", "assistant", "Good day"), + msg("3", "assistant", " "), + msg("4", "toolResult", "ignored"), + msg("5", "event", "ignored"), + ), + ) + assertEquals(listOf(CallLine("1", true, "Hello"), CallLine("2", false, "Good day")), lines) + } +} diff --git a/app/src/test/java/dev/crumble/android/GeneratedContractTests.kt b/app/src/test/java/dev/crumble/android/GeneratedContractTests.kt index d6dd87f..69d7029 100644 --- a/app/src/test/java/dev/crumble/android/GeneratedContractTests.kt +++ b/app/src/test/java/dev/crumble/android/GeneratedContractTests.kt @@ -1,8 +1,10 @@ package dev.crumble.android +import dev.crumble.android.generated.GetOpenAPIResponse import dev.crumble.android.generated.OptionalField import dev.crumble.android.generated.UpdateScheduleRequest import dev.crumble.android.model.ChatSnapshot +import dev.crumble.android.model.CrumbleJson import dev.crumble.android.model.CrumblePatchJson import dev.crumble.android.model.OutgoingAttachment import dev.crumble.android.network.APIClient @@ -165,4 +167,13 @@ class GeneratedContractTests { val message = CrumblePatchJson.decodeFromString("""{"id":"m1","entryId":"e1","role":"assistant","content":"","blocks":{"provider":[null,true,1,"text"]}}""") assertEquals(JsonPrimitive(true), message.blocks!!.jsonObject["provider"]!!.jsonArray[1]) } + + @Test + fun openObjectKeepsUndeclaredProperties() { + val text = """{"openapi":"3.1.0","info":{"version":"6.0.0"},"paths":{}}""" + val document = CrumbleJson.decodeFromString(text) + assertEquals("3.1.0", document.openapi) + assertEquals(setOf("info", "paths"), document.additionalProperties.keys) + assertEquals(CrumbleJson.parseToJsonElement(text), CrumbleJson.parseToJsonElement(CrumbleJson.encodeToString(document))) + } } diff --git a/contract/assets/chat-snapshot.schema.json b/contract/assets/chat-snapshot.schema.json index 7374638..16275c4 100644 --- a/contract/assets/chat-snapshot.schema.json +++ b/contract/assets/chat-snapshot.schema.json @@ -145,6 +145,29 @@ ], "additionalProperties": true }, + "call": { + "type": "object", + "properties": { + "callId": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "inbound", + "outbound" + ] + }, + "callee": { + "type": "string" + } + }, + "required": [ + "callId", + "direction" + ], + "additionalProperties": true + }, "attachments": { "type": "array", "items": { diff --git a/contract/assets/openapi.json b/contract/assets/openapi.json index 7d57ba5..34532a2 100644 --- a/contract/assets/openapi.json +++ b/contract/assets/openapi.json @@ -12,6 +12,11 @@ "scheme": "bearer", "description": "Better Auth session token from /api/auth/sign-in/email. The legacy server token is not accepted." }, + "VoiceGatewayToken": { + "type": "http", + "scheme": "bearer", + "description": "The voice gateway token the owner issues in settings. It reaches only /v1/voice/ routes, which accept nothing else." + }, "SessionCookie": { "type": "apiKey", "in": "cookie", @@ -78,7 +83,7 @@ "credential", "login" ], - "description": "Set when the bot waits for the owner's answer: approval (a desktop job action held by an intent check), credential, or login (a request the owner saves or declines). Clients may receive other values later." + "description": "Set when the bot waits for the owner's answer: approval (a desktop job action held by an intent check, or a call request), credential, or login (a request the owner saves or declines). Clients may receive other values later." } }, "required": [ @@ -208,6 +213,113 @@ ], "description": "A bot's review of one of its turns changed its skills or memory, sent as the `learned` event on /v1/events. Clients show a short notice in the bot chat and link to the change record." }, + "VoiceGatewayConfigEvent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "ownerNumbers": { + "type": "array", + "items": { + "type": "string" + } + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "botId": { + "type": "string" + }, + "botName": { + "type": "string" + }, + "displayName": { + "type": "string", + "description": "The line's display name, or the bot's name." + }, + "callsOwner": { + "type": "boolean" + } + }, + "required": [ + "username", + "botId", + "botName", + "displayName", + "callsOwner" + ] + }, + "description": "Lines whose bot is enabled; empty while the voice gateway is disabled." + } + }, + "required": [ + "enabled", + "ownerNumbers", + "lines" + ] + }, + "VoiceCallEvent": { + "type": "object", + "properties": { + "callId": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string", + "description": "The line the call is placed from." + }, + "to": { + "type": "string", + "description": "The number to dial: for a call to the owner, the first in the owner's list; for an outside call, the number the owner approved, digits with an optional leading +." + }, + "greeting": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "What the bot says when the call is answered: for an outside call, the opening the owner approved, to be spoken verbatim." + }, + "party": { + "type": "string", + "enum": [ + "owner", + "outside" + ], + "description": "Who is called: owner, or outside, someone other than the owner after the owner approved the bot's call request. Absent means owner. Dial an outside call only when the voice gateway's own config allows it, else report it failed." + } + }, + "required": [ + "callId", + "username", + "to", + "greeting" + ], + "description": "A bot asks to call the owner, or someone else after the owner approved it. Sent only to a stream opened with calls=true, and sent again after a reconnect until the call is acknowledged; ignore a callId you already dial." + }, + "VoiceCallCancelEvent": { + "type": "object", + "properties": { + "callId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "callId" + ], + "description": "The call's turn was aborted or its wait ran out: hang up if it still rings." + }, + "VoiceGatewayReplacedEvent": { + "type": "object", + "properties": {}, + "description": "Another connection with the same voice gateway token took over; sent before this stream closes." + }, "Health": { "type": "object", "properties": { @@ -443,6 +555,171 @@ "questionGraceMinutes" ] }, + "Execution": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agentId": { + "type": "string" + }, + "conversationId": { + "type": "string" + }, + "scheduleId": { + "type": [ + "string", + "null" + ] + }, + "submissionId": { + "type": [ + "string", + "null" + ] + }, + "requestId": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "replyTo": { + "type": [ + "string", + "null" + ] + }, + "attachmentsDigest": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "done", + "failed", + "cancelled" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "agentId", + "conversationId", + "scheduleId", + "submissionId", + "requestId", + "prompt", + "status", + "error", + "createdAt", + "updatedAt" + ] + }, + "SpokenExecution": { + "type": "object", + "properties": { + "execution": { + "$ref": "#/components/schemas/Execution" + }, + "reply": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "The bot's reply, read by the message's submission; null unless the execution's status is done. A failed or cancelled execution never gets one." + }, + "hangUp": { + "type": "boolean", + "description": "True when the bot asked to end the call: hang up once the reply was read aloud, then report the end with reason hungUp." + } + }, + "required": [ + "execution", + "reply", + "hangUp" + ] + }, + "VoiceCallReport": { + "oneOf": [ + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "ringing", + "answered", + "unanswered", + "busy", + "failed" + ], + "description": "For a call a bot placed: ringing acknowledges it as soon as dialing starts; the others settle it." + }, + "error": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "outcome" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "ended" + ], + "description": "A started call ended. Accepted for an active, ended, or unknown call; an unknown one can't be started afterwards." + }, + "reason": { + "type": "string", + "enum": [ + "hungUp", + "maxDuration", + "failed", + "stopping" + ], + "description": "hungUp: the media ended or the dialog closed. maxDuration: the call reached its maximum length. failed: any other error. stopping: the voice gateway shut down." + }, + "error": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "outcome", + "reason" + ], + "additionalProperties": false + } + ] + }, "GitHubApp": { "type": "object", "properties": { @@ -1017,7 +1294,8 @@ "description": "Pi Durable live progress state. In-flight assistant message content is omitted." }, "partial": { - "type": "string" + "type": "string", + "description": "Text of the reply the bot is writing now. Empty unless the event stream was opened with partial=true." }, "usage": { "description": "Pi Durable usage document." @@ -1080,7 +1358,8 @@ "type": "number" }, "kind": { - "type": "string" + "type": "string", + "description": "The entry's kind. Events include bot-message, job-question, held-action, job-failure, reset, compaction, call-started (content: the greeting of a call the bot placed, or Call started), and call-ended (content: how the call ended, when it woke no bot turn). Clients may receive other values later." }, "replyTo": { "type": "string" @@ -1139,6 +1418,31 @@ ], "description": "On a `held-action` event: the desktop job action waiting for the owner. Its state is under `/v1/agents/{id}/held-actions`." }, + "call": { + "type": "object", + "properties": { + "callId": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "inbound", + "outbound" + ], + "description": "outbound: the bot called the owner, or the callee of an outside call." + }, + "callee": { + "type": "string", + "description": "Set on an outside call, which the bot placed to someone other than the owner: who it called. A call-started event then reads as the bot calling the callee, its content the opening." + } + }, + "required": [ + "callId", + "direction" + ], + "description": "On a `call-started` or `call-ended` event: the call." + }, "attachments": { "type": "array", "items": { @@ -1189,7 +1493,7 @@ "properties": { "kind": { "type": "string", - "description": "Where the message came from: owner, task, event (an event automation's prompt), script (a script task's result), credential, bot, or group. Clients may receive other values later." + "description": "Where the message came from: owner, task, event (an event automation's prompt), script (a script task's result), credential, bot, group, call (a voice gateway call ended, which starts a bot turn), call_request (the owner declined a call request), or callee (what the callee of an outside call said, in that call's conversation only). Clients may receive other values later." }, "name": { "type": "string" @@ -1199,7 +1503,7 @@ }, "via": { "type": "string", - "description": "On an owner message sent through a message gateway: the gateway's ID, such as `discord`." + "description": "On an owner message sent through a message gateway: the gateway's ID, such as `discord`. On a spoken message from a voice gateway call: `voice`." }, "source": { "type": "string", @@ -1558,113 +1862,34 @@ "expiresAt" ] }, - "Execution": { + "Compaction": { "type": "object", "properties": { - "id": { + "taskId": { "type": "string" - }, - "agentId": { + } + }, + "required": [ + "taskId" + ] + }, + "GroupChat": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "conversationId": { + "name": { "type": "string" }, - "scheduleId": { - "type": [ - "string", - "null" - ] - }, - "submissionId": { - "type": [ - "string", - "null" - ] + "memberIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Member bots in the order they joined." }, - "requestId": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "replyTo": { - "type": [ - "string", - "null" - ] - }, - "attachmentsDigest": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string", - "enum": [ - "pending", - "running", - "done", - "failed", - "cancelled" - ] - }, - "error": { - "type": [ - "string", - "null" - ] - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": [ - "id", - "agentId", - "conversationId", - "scheduleId", - "submissionId", - "requestId", - "prompt", - "status", - "error", - "createdAt", - "updatedAt" - ] - }, - "Compaction": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "GroupChat": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "memberIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Member bots in the order they joined." - }, - "createdAt": { + "createdAt": { "type": "string" }, "updatedAt": { @@ -2228,6 +2453,106 @@ "decidedAt" ] }, + "CallRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agentId": { + "type": "string" + }, + "number": { + "type": "string", + "description": "The number to dial, normalized: digits with an optional leading +." + }, + "callee": { + "type": "string", + "description": "Who the bot calls." + }, + "goal": { + "type": "string" + }, + "opening": { + "type": "string", + "description": "What the bot says first when the callee answers." + }, + "status": { + "type": "string", + "enum": [ + "pending", + "declined", + "calling", + "ended" + ], + "description": "pending: waiting for the owner. calling: approved, and the call rings or runs. ended: see outcome." + }, + "callId": { + "type": [ + "string", + "null" + ] + }, + "conversationId": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": [ + "string", + "null" + ], + "enum": [ + "answered", + "unanswered", + "busy", + "failed", + null + ], + "description": "Set once the status is ended." + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Why a failed call failed." + }, + "requestedAt": { + "type": "string" + }, + "decidedAt": { + "type": [ + "string", + "null" + ] + }, + "endedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "agentId", + "number", + "callee", + "goal", + "opening", + "status", + "callId", + "conversationId", + "outcome", + "error", + "requestedAt", + "decidedAt", + "endedAt" + ] + }, "CredentialUse": { "type": "object", "properties": { @@ -4168,6 +4493,34 @@ } } }, + "/openapi.json": { + "get": { + "operationId": "getOpenAPI", + "description": "This document, generated by the running server.", + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "openapi": { + "type": "string" + } + }, + "required": [ + "openapi" + ], + "additionalProperties": {} + } + } + } + } + } + } + }, "/v1/capabilities": { "get": { "operationId": "getCapabilities", @@ -6704,10 +7057,10 @@ } } }, - "/v1/github-apps": { + "/v1/voice-gateway": { "get": { - "operationId": "listGithubApps", - "description": "The account's GitHub Apps, each the GitHub identity its bots act through in one GitHub account's repositories.", + "operationId": "getVoiceGateway", + "description": "The account's voice gateway: its lines and the owner's numbers, when its token was issued, and the connection status.", "security": [ { "BearerAuth": [] @@ -6724,22 +7077,162 @@ "schema": { "type": "object", "properties": { - "apps": { + "config": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "ownerNumbers": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\+?[0-9*#]{1,32}$/u" + }, + "maxItems": 20, + "description": "The owner's phone numbers. A call from one of them reaches the line's bot as the owner; a bot calling the owner dials the first." + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9._~+-]{1,64}$/u" + }, + "botId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": [ + "string", + "null" + ], + "maxLength": 64, + "description": "The name callers hear; null uses the bot's name." + }, + "callsOwner": { + "type": "boolean" + }, + "callsOthers": { + "type": "boolean", + "description": "Whether the line's bot may ask to call people other than the owner, each call after the owner approved it. Absent means false." + } + }, + "required": [ + "username", + "botId", + "displayName", + "callsOwner", + "callsOthers" + ], + "additionalProperties": false + }, + "maxItems": 64 + } + }, + "required": [ + "enabled", + "ownerNumbers", + "lines" + ], + "additionalProperties": false + }, + "token": { + "type": [ + "object", + "null" + ], + "properties": { + "issuedAt": { + "type": "string" + } + }, + "required": [ + "issuedAt" + ] + }, + "status": { + "type": "string", + "enum": [ + "disconnected", + "connecting", + "connected", + "error" + ] + }, + "connectedAt": { + "type": [ + "string", + "null" + ] + }, + "lines": { "type": "array", "items": { - "$ref": "#/components/schemas/GitHubApp" - } - } - }, - "required": [ - "apps" - ] - } - } - } - }, - "400": { - "description": "Invalid request or operation", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "registered": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "username", + "registered", + "error" + ] + }, + "description": "Line registration as the connected voice gateway last reported it; empty until it reports and after it disconnects." + }, + "unavailableLines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "botDeleted", + "botDisabled" + ] + } + }, + "required": [ + "username", + "reason" + ] + }, + "description": "Configured lines whose bot was deleted or is disabled. The voice gateway does not receive them; a line comes back when its bot is enabled again." + } + }, + "required": [ + "config", + "token", + "status", + "connectedAt", + "lines", + "unavailableLines" + ] + } + } + } + }, + "400": { + "description": "Invalid request or operation", "content": { "application/json": { "schema": { @@ -6819,12 +7312,10 @@ } } } - } - }, - "/v1/github-apps/manifest": { - "post": { - "operationId": "createGithubAppManifest", - "description": "Starts creating a private GitHub App. The client posts `manifest` as a form field to `url`; GitHub then sends the owner to `{origin}/github-app/callback` with a code and state.", + }, + "put": { + "operationId": "updateVoiceGateway", + "description": "Saves the voice gateway's lines and the owner's numbers. 400 for an unknown bot.", "security": [ { "BearerAuth": [] @@ -6840,24 +7331,63 @@ "schema": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 34 + "enabled": { + "type": "boolean" }, - "origin": { - "type": "string", - "maxLength": 2000, - "format": "uri" + "ownerNumbers": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\+?[0-9*#]{1,32}$/u" + }, + "maxItems": 20, + "description": "The owner's phone numbers. A call from one of them reaches the line's bot as the owner; a bot calling the owner dials the first." }, - "organization": { - "type": "string", - "pattern": "^[A-Za-z0-9-]{1,39}$" + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9._~+-]{1,64}$/u" + }, + "botId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": [ + "string", + "null" + ], + "maxLength": 64, + "description": "The name callers hear; null uses the bot's name." + }, + "callsOwner": { + "type": "boolean" + }, + "callsOthers": { + "type": "boolean", + "description": "Whether the line's bot may ask to call people other than the owner, each call after the owner approved it. Absent means false." + } + }, + "required": [ + "username", + "botId", + "displayName", + "callsOwner", + "callsOthers" + ], + "additionalProperties": false + }, + "maxItems": 64 } }, "required": [ - "name", - "origin" + "enabled", + "ownerNumbers", + "lines" ], "additionalProperties": false } @@ -6872,16 +7402,155 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string" + "config": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "ownerNumbers": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\+?[0-9*#]{1,32}$/u" + }, + "maxItems": 20, + "description": "The owner's phone numbers. A call from one of them reaches the line's bot as the owner; a bot calling the owner dials the first." + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9._~+-]{1,64}$/u" + }, + "botId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": [ + "string", + "null" + ], + "maxLength": 64, + "description": "The name callers hear; null uses the bot's name." + }, + "callsOwner": { + "type": "boolean" + }, + "callsOthers": { + "type": "boolean", + "description": "Whether the line's bot may ask to call people other than the owner, each call after the owner approved it. Absent means false." + } + }, + "required": [ + "username", + "botId", + "displayName", + "callsOwner", + "callsOthers" + ], + "additionalProperties": false + }, + "maxItems": 64 + } + }, + "required": [ + "enabled", + "ownerNumbers", + "lines" + ], + "additionalProperties": false }, - "manifest": { - "type": "string" + "token": { + "type": [ + "object", + "null" + ], + "properties": { + "issuedAt": { + "type": "string" + } + }, + "required": [ + "issuedAt" + ] + }, + "status": { + "type": "string", + "enum": [ + "disconnected", + "connecting", + "connected", + "error" + ] + }, + "connectedAt": { + "type": [ + "string", + "null" + ] + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "registered": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "username", + "registered", + "error" + ] + }, + "description": "Line registration as the connected voice gateway last reported it; empty until it reports and after it disconnects." + }, + "unavailableLines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "botDeleted", + "botDisabled" + ] + } + }, + "required": [ + "username", + "reason" + ] + }, + "description": "Configured lines whose bot was deleted or is disabled. The voice gateway does not receive them; a line comes back when its bot is enabled again." } }, "required": [ - "url", - "manifest" + "config", + "token", + "status", + "connectedAt", + "lines", + "unavailableLines" ] } } @@ -6970,10 +7639,10 @@ } } }, - "/v1/github-apps/complete": { + "/v1/voice-gateway/token": { "post": { - "operationId": "completeGithubApp", - "description": "Exchanges the code GitHub redirected back with for the App's keys, within an hour of starting. 400 for a stale or reused state.", + "operationId": "issueVoiceGatewayToken", + "description": "Issues the voice gateway token, returned only once. An earlier token stops working at once.", "security": [ { "BearerAuth": [] @@ -6982,40 +7651,25 @@ "SessionCookie": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "state": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "code", - "state" - ], - "additionalProperties": false - } - } - } - }, "responses": { - "200": { + "201": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GitHubApp" + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "issuedAt": { + "type": "string" + } + }, + "required": [ + "token", + "issuedAt" + ] } } } @@ -7101,12 +7755,10 @@ } } } - } - }, - "/v1/github-apps/{id}/connect": { - "post": { - "operationId": "connectGithubApp", - "description": "Finds the App's installation on its account and gives its bots the GH_TOKEN credential. 409 until the owner installs the App.", + }, + "delete": { + "operationId": "revokeVoiceGatewayToken", + "description": "Revokes the voice gateway token. 404 when none is issued.", "security": [ { "BearerAuth": [] @@ -7115,26 +7767,9 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } - ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitHubApp" - } - } - } + "204": { + "description": "Success, no response body" }, "400": { "description": "Invalid request or operation", @@ -7219,59 +7854,51 @@ } } }, - "/v1/github-apps/{id}/bots": { - "put": { - "operationId": "setGithubAppBots", - "description": "Replaces the bots that act through the App. A bot uses at most one App per GitHub account; 409 when a bot already uses another App for this App's account.", - "security": [ - { - "BearerAuth": [] + "/v1/voice/events": { + "get": { + "operationId": "streamVoiceGatewayEvents", + "x-sse-events": { + "config": { + "$ref": "#/components/schemas/VoiceGatewayConfigEvent" + }, + "call": { + "$ref": "#/components/schemas/VoiceCallEvent" }, + "cancel": { + "$ref": "#/components/schemas/VoiceCallCancelEvent" + }, + "replaced": { + "$ref": "#/components/schemas/VoiceGatewayReplacedEvent" + }, + "heartbeat": { + "$ref": "#/components/schemas/HeartbeatEvent" + } + }, + "description": "Server-sent events for the voice gateway. A config event comes first and after every change to lines, owner numbers, or their bots. With calls=true, call and cancel events ask the gateway to place and hang up calls to the owner. One stream is live per account: a new connection sends replaced to the older one and closes it. Revoking or replacing the token closes the stream.", + "security": [ { - "SessionCookie": [] + "VoiceGatewayToken": [] } ], "parameters": [ { "schema": { - "type": "string" + "type": "string", + "description": "With calls=true, the voice gateway declares it can place calls: bots with a line that may call the owner are offered the call_owner tool, approved call requests can dial, and call events arrive on this stream." }, - "required": true, - "name": "id", - "in": "path" + "required": false, + "description": "With calls=true, the voice gateway declares it can place calls: bots with a line that may call the owner are offered the call_owner tool, approved call requests can dial, and call events arrive on this stream.", + "name": "calls", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "botIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "maxItems": 200 - } - }, - "required": [ - "botIds" - ], - "additionalProperties": false - } - } - } - }, "responses": { "200": { - "description": "Success", + "description": "SSE stream, not a JSON array", "content": { - "application/json": { + "text/event-stream": { "schema": { - "$ref": "#/components/schemas/GitHubApp" + "type": "string" } } } @@ -7287,11 +7914,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -7359,31 +7986,69 @@ } } }, - "/v1/github-apps/{id}": { - "delete": { - "operationId": "deleteGithubApp", - "description": "Forgets the App, signs the owner out of it, and takes GH_TOKEN from bots left without an installed App. The App stays on GitHub until the owner deletes it there.", + "/v1/voice/lines/{username}/messages": { + "post": { + "operationId": "sendSpokenMessage", + "description": "Sends what the owner said on a call to the line's bot. On the line's active outside call, what the callee said goes to that call's conversation instead, never the bot chat, under requestId outside-call:{callId}:{requestId}. The same IDs with other content answer 409, and so do a callId whose call ended and an outside call's ID that is not the line's active call: hang up. A callId the server doesn't know is accepted.", "security": [ { - "BearerAuth": [] - }, - { - "SessionCookie": [] + "VoiceGatewayToken": [] } ], "parameters": [ { "schema": { - "type": "string" + "type": "string", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404." }, "required": true, - "name": "id", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404.", + "name": "username", "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "callId": { + "type": "string", + "format": "uuid", + "description": "The call the message was spoken on." + }, + "requestId": { + "type": "string", + "format": "uuid", + "description": "The message's ID within the call. Retrying with the same IDs and content returns the same execution." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 32000 + } + }, + "required": [ + "callId", + "requestId", + "content" + ] + } + } + } + }, "responses": { - "204": { - "description": "Success, no response body" + "202": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Execution" + } + } + } }, "400": { "description": "Invalid request or operation", @@ -7396,11 +8061,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -7468,64 +8133,73 @@ } } }, - "/v1/github-apps/{id}/sign-in": { - "post": { - "operationId": "signInGithubApp", - "description": "Starts signing the owner in to GitHub through the App, so its bots act as the owner. The client opens `url`; GitHub sends the owner to the App's callback URL, `{origin}/github-app/authorized`, or `{origin}/ui/github-app/authorized` for Apps created before the web UI moved to `/`, with a code and state. `origin` is no longer used. 409 without a client secret.", - "security": [ - { - "BearerAuth": [] + "/v1/voice/lines/{username}/events": { + "get": { + "operationId": "streamVoiceLineEvents", + "x-sse-events": { + "snapshot": { + "$ref": "#/components/schemas/ChatSnapshot" }, + "heartbeat": { + "$ref": "#/components/schemas/HeartbeatEvent" + } + }, + "description": "The line's bot chat as server-sent events, the same as /v1/agents/{id}/events. While the line's active call is an outside call, the snapshots are that call's conversation instead. The stream closes when the line gets another bot, is removed, or its bot is deleted or disabled.", + "security": [ { - "SessionCookie": [] + "VoiceGatewayToken": [] } ], "parameters": [ { "schema": { - "type": "string" + "type": "string", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404." }, "required": true, - "name": "id", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404.", + "name": "username", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "With partial=true, each snapshot's partial carries the reply text the bot is writing, updated about every 100 ms. Without it, partial stays empty and replies appear only when the turn ends." + }, + "required": false, + "description": "With partial=true, each snapshot's partial carries the reply text the bot is writing, updated about every 100 ms. Without it, partial stays empty and replies appear only when the turn ends.", + "name": "partial", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "uuid", + "description": "The call the turn belongs to. For the line's active outside call, the stream sends that call's conversation; for another outside call's ID it answers 409: hang up. Without it, the stream follows the line's active call." + }, + "required": false, + "description": "The call the turn belongs to. For the line's active outside call, the stream sends that call's conversation; for another outside call's ID it answers 409: hang up. Without it, the stream follows the line's active call.", + "name": "callId", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "origin": { - "type": "string", - "maxLength": 2000, - "format": "uri" - } - }, - "required": [ - "origin" - ], - "additionalProperties": false - } - } - } - }, "responses": { "200": { - "description": "Success", + "description": "SSE stream, not a JSON array", "content": { - "application/json": { + "text/event-stream": { "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ] + "type": "string" } } } @@ -7541,11 +8215,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -7613,52 +8287,42 @@ } } }, - "/v1/github-apps/sign-in/complete": { - "post": { - "operationId": "completeGithubAppSignIn", - "description": "Exchanges the code GitHub sent the owner back with for the owner's tokens, within ten minutes of starting. 400 for a stale or reused state or a refused sign-in.", + "/v1/voice/lines/{username}/executions/{executionId}": { + "get": { + "operationId": "getSpokenExecution", + "description": "A spoken message's execution, the bot's reply to it when its status is done, and whether the bot asked to hang up. Stop polling when the status is failed or cancelled: reply stays null. 404 unless the execution is a spoken message to the line's bot or a callee's message on its outside call; 409 for a callee's message whose outside call is not the line's active call.", "security": [ { - "BearerAuth": [] + "VoiceGatewayToken": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404." + }, + "required": true, + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404.", + "name": "username", + "in": "path" }, { - "SessionCookie": [] + "schema": { + "type": "string" + }, + "required": true, + "name": "executionId", + "in": "path" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "state": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "code", - "state" - ], - "additionalProperties": false - } - } - } - }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GitHubApp" + "$ref": "#/components/schemas/SpokenExecution" } } } @@ -7674,11 +8338,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -7746,38 +8410,53 @@ } } }, - "/v1/github-apps/{id}/user": { - "delete": { - "operationId": "signOutGithubApp", - "description": "Revokes and forgets the owner's sign-in through the App; its bots act as the App again.", + "/v1/voice/lines/{username}/calls/{callId}": { + "put": { + "operationId": "startVoiceCall", + "description": "Reports that a call on the line started, before the greeting is spoken; send it again after every config event while the call runs. For a call a bot placed, it also settles the call as answered. A call nobody confirms within 15 seconds of a new events stream, or 60 seconds without one, ends. 409 when the call ended, another call is active on the line, or the callId is an outside call's that no longer rings: hang up.", "security": [ { - "BearerAuth": [] - }, - { - "SessionCookie": [] + "VoiceGatewayToken": [] } ], "parameters": [ { "schema": { - "type": "string" + "type": "string", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404." }, "required": true, - "name": "id", + "description": "A line's SIP username. An unknown line, or one whose bot is deleted or disabled, answers 404.", + "name": "username", + "in": "path" + }, + { + "schema": { + "type": "string", + "format": "uuid", + "description": "The call's ID, the callId its spoken messages carry: a call event's callId for a call a bot placed." + }, + "required": true, + "description": "The call's ID, the callId its spoken messages carry: a call event's callId for a call a bot placed.", + "name": "callId", "in": "path" } ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitHubApp" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {}, + "additionalProperties": false } } + } + }, + "responses": { + "204": { + "description": "Success, no response body" }, "400": { "description": "Invalid request or operation", @@ -7790,11 +8469,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -7862,25 +8541,24 @@ } } }, - "/v1/github-apps/{id}/client-secret": { - "put": { - "operationId": "setGithubAppClientSecret", - "description": "Stores a client secret generated in the App's GitHub settings, for an App created before Crumble kept it.", + "/v1/voice/calls/{callId}": { + "post": { + "operationId": "reportVoiceCall", + "description": "Reports a call a bot placed: ringing as soon as dialing starts, then how it settled. A call not acknowledged within 15 seconds, or not settled within 3 minutes 30 seconds of its call event, is cancelled. 404 for a call that is neither waiting to settle nor active: hang up. ended reports that a started call ended, and answers 204 for any callId.", "security": [ { - "BearerAuth": [] - }, - { - "SessionCookie": [] + "VoiceGatewayToken": [] } ], "parameters": [ { "schema": { - "type": "string" + "type": "string", + "description": "The callId of a call event." }, "required": true, - "name": "id", + "description": "The callId of a call event.", + "name": "callId", "in": "path" } ], @@ -7889,50 +8567,14 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 16000 - }, - "reference": { - "type": "object", - "properties": { - "passwordManager": { - "type": "string", - "enum": [ - "1password", - "bitwarden" - ] - }, - "reference": { - "type": "string", - "minLength": 1, - "maxLength": 1000 - } - }, - "required": [ - "passwordManager", - "reference" - ] - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/VoiceCallReport" } } } }, "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitHubApp" - } - } - } + "204": { + "description": "Success, no response body" }, "400": { "description": "Invalid request or operation", @@ -7945,11 +8587,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -8017,39 +8659,63 @@ } } }, - "/v1/github-accounts": { - "get": { - "operationId": "listGithubAccounts", - "description": "The account's machine accounts: GitHub users made for bots. A bot acts as its machine account wherever none of its GitHub Apps does.", + "/v1/voice/lines": { + "put": { + "operationId": "reportVoiceLines", + "description": "Reports which lines the voice gateway registered with the telephone system. Accepted only while this token's /v1/voice/events stream is live, 409 otherwise; the owner sees the report in settings until that stream closes.", "security": [ { - "BearerAuth": [] - }, - { - "SessionCookie": [] + "VoiceGatewayToken": [] } ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accounts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitHubAccount" - } - } - }, - "required": [ - "accounts" - ] - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "maxLength": 64 + }, + "registered": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ], + "maxLength": 500 + } + }, + "required": [ + "username", + "registered", + "error" + ], + "additionalProperties": false + }, + "maxItems": 64 + } + }, + "required": [ + "lines" + ], + "additionalProperties": false } } + } + }, + "responses": { + "204": { + "description": "Success, no response body" }, "400": { "description": "Invalid request or operation", @@ -8062,11 +8728,11 @@ } }, "401": { - "description": "Missing, expired, or invalid account session", + "description": "Missing, revoked, or replaced voice gateway token", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Error" } } } @@ -8132,10 +8798,12 @@ } } } - }, - "post": { - "operationId": "addGithubAccount", - "description": "Adds the GitHub user a personal access token signs in as. 400 when GitHub refuses the token; 409 when the user is already added.", + } + }, + "/v1/github-apps": { + "get": { + "operationId": "listGithubApps", + "description": "The account's GitHub Apps, each the GitHub identity its bots act through in one GitHub account's repositories.", "security": [ { "BearerAuth": [] @@ -8144,52 +8812,24 @@ "SessionCookie": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 16000 - }, - "reference": { - "type": "object", - "properties": { - "passwordManager": { - "type": "string", - "enum": [ - "1password", - "bitwarden" - ] - }, - "reference": { - "type": "string", - "minLength": 1, - "maxLength": 1000 - } - }, - "required": [ - "passwordManager", - "reference" - ] - } - }, - "additionalProperties": false - } - } - } - }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GitHubAccount" + "type": "object", + "properties": { + "apps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GitHubApp" + } + } + }, + "required": [ + "apps" + ] } } } @@ -8277,10 +8917,10 @@ } } }, - "/v1/github-accounts/{id}/token": { - "put": { - "operationId": "setGithubAccountToken", - "description": "Replaces the machine account's token. 400 when GitHub refuses it or it signs in as another user.", + "/v1/github-apps/manifest": { + "post": { + "operationId": "createGithubAppManifest", + "description": "Starts creating a private GitHub App. The client posts `manifest` as a form field to `url`; GitHub then sends the owner to `{origin}/github-app/callback` with a code and state.", "security": [ { "BearerAuth": [] @@ -8289,16 +8929,6 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } - ], "requestBody": { "required": true, "content": { @@ -8306,33 +8936,25 @@ "schema": { "type": "object", "properties": { - "value": { + "name": { "type": "string", "minLength": 1, - "maxLength": 16000 + "maxLength": 34 }, - "reference": { - "type": "object", - "properties": { - "passwordManager": { - "type": "string", - "enum": [ - "1password", - "bitwarden" - ] - }, - "reference": { - "type": "string", - "minLength": 1, - "maxLength": 1000 - } - }, - "required": [ - "passwordManager", - "reference" - ] + "origin": { + "type": "string", + "maxLength": 2000, + "format": "uri" + }, + "organization": { + "type": "string", + "pattern": "^[A-Za-z0-9-]{1,39}$" } }, + "required": [ + "name", + "origin" + ], "additionalProperties": false } } @@ -8344,7 +8966,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GitHubAccount" + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "manifest": { + "type": "string" + } + }, + "required": [ + "url", + "manifest" + ] } } } @@ -8432,10 +9066,10 @@ } } }, - "/v1/github-accounts/{id}/bots": { - "put": { - "operationId": "setGithubAccountBots", - "description": "Replaces the bots that act as the machine account, giving them GH_TOKEN. A bot acts as at most one; 409 when a bot already acts as another.", + "/v1/github-apps/complete": { + "post": { + "operationId": "completeGithubApp", + "description": "Exchanges the code GitHub redirected back with for the App's keys, within an hour of starting. 400 for a stale or reused state.", "security": [ { "BearerAuth": [] @@ -8444,16 +9078,6 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } - ], "requestBody": { "required": true, "content": { @@ -8461,17 +9085,20 @@ "schema": { "type": "object", "properties": { - "botIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "maxItems": 200 + "code": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "state": { + "type": "string", + "minLength": 1, + "maxLength": 200 } }, "required": [ - "botIds" + "code", + "state" ], "additionalProperties": false } @@ -8484,7 +9111,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GitHubAccount" + "$ref": "#/components/schemas/GitHubApp" } } } @@ -8572,10 +9199,10 @@ } } }, - "/v1/github-accounts/{id}": { - "delete": { - "operationId": "deleteGithubAccount", - "description": "Forgets the machine account and its token, and takes GH_TOKEN from bots left without an installed App. The user stays on GitHub.", + "/v1/github-apps/{id}/connect": { + "post": { + "operationId": "connectGithubApp", + "description": "Finds the App's installation on its account and gives its bots the GH_TOKEN credential. 409 until the owner installs the App.", "security": [ { "BearerAuth": [] @@ -8595,8 +9222,15 @@ } ], "responses": { - "204": { - "description": "Success, no response body" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GitHubApp" + } + } + } }, "400": { "description": "Invalid request or operation", @@ -8681,10 +9315,10 @@ } } }, - "/v1/event-sources": { - "get": { - "operationId": "listEventSources", - "description": "The account's event sources, each with the number of automations that use it.", + "/v1/github-apps/{id}/bots": { + "put": { + "operationId": "setGithubAppBots", + "description": "Replaces the bots that act through the App. A bot uses at most one App per GitHub account; 409 when a bot already uses another App for this App's account.", "security": [ { "BearerAuth": [] @@ -8693,45 +9327,47 @@ "SessionCookie": [] } ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "botIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "maxItems": 200 + } + }, + "required": [ + "botIds" + ], + "additionalProperties": false + } + } + } + }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "sources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "usedBy": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "id", - "name", - "createdAt", - "usedBy" - ] - } - } - }, - "required": [ - "sources" - ] + "$ref": "#/components/schemas/GitHubApp" } } } @@ -8817,10 +9453,12 @@ } } } - }, - "post": { - "operationId": "createEventSource", - "description": "Creates a source on the event relay. The response's `address` contains the source's secret and is returned only once. 400 when the relay URL or token is missing, or the relay can't be reached or rejects the token.", + } + }, + "/v1/github-apps/{id}": { + "delete": { + "operationId": "deleteGithubApp", + "description": "Forgets the App, signs the owner out of it, and takes GH_TOKEN from bots left without an installed App. The App stays on GitHub until the owner deletes it there.", "security": [ { "BearerAuth": [] @@ -8829,65 +9467,22 @@ "SessionCookie": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "name" - ], - "additionalProperties": false - } - } + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" } - }, + ], "responses": { - "201": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "usedBy": { - "type": "integer", - "minimum": 0 - }, - "address": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "createdAt", - "usedBy", - "address" - ] - } - } - } - }, - "400": { - "description": "Invalid request or operation", + "204": { + "description": "Success, no response body" + }, + "400": { + "description": "Invalid request or operation", "content": { "application/json": { "schema": { @@ -8969,10 +9564,10 @@ } } }, - "/v1/event-sources/{id}": { - "delete": { - "operationId": "deleteEventSource", - "description": "Deletes the source on the event relay and in Crumble; its address stops working. 409 while an automation uses it.", + "/v1/github-apps/{id}/sign-in": { + "post": { + "operationId": "signInGithubApp", + "description": "Starts signing the owner in to GitHub through the App, so its bots act as the owner. The client opens `url`; GitHub sends the owner to the App's callback URL, `{origin}/github-app/authorized`, or `{origin}/ui/github-app/authorized` for Apps created before the web UI moved to `/`, with a code and state. `origin` is no longer used. 409 without a client secret.", "security": [ { "BearerAuth": [] @@ -8984,18 +9579,52 @@ "parameters": [ { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": "string" }, "required": true, "name": "id", "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "origin": { + "type": "string", + "maxLength": 2000, + "format": "uri" + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + } + } + } + }, "responses": { - "204": { - "description": "Success, no response body" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } + } }, "400": { "description": "Invalid request or operation", @@ -9080,9 +9709,10 @@ } } }, - "/v1/web-search": { - "get": { - "operationId": "getWebSearch", + "/v1/github-apps/sign-in/complete": { + "post": { + "operationId": "completeGithubAppSignIn", + "description": "Exchanges the code GitHub sent the owner back with for the owner's tokens, within ten minutes of starting. 400 for a stale or reused state or a refused sign-in.", "security": [ { "BearerAuth": [] @@ -9091,29 +9721,40 @@ "SessionCookie": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "state": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "code", + "state" + ], + "additionalProperties": false + } + } + } + }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "defaultProvider": { - "type": "string", - "enum": [ - "mixed", - "exa" - ] - }, - "exaKeyConfigured": { - "type": "boolean" - } - }, - "required": [ - "defaultProvider", - "exaKeyConfigured" - ] + "$ref": "#/components/schemas/GitHubApp" } } } @@ -9199,9 +9840,12 @@ } } } - }, - "put": { - "operationId": "updateWebSearch", + } + }, + "/v1/github-apps/{id}/user": { + "delete": { + "operationId": "signOutGithubApp", + "description": "Revokes and forgets the owner's sign-in through the App; its bots act as the App again.", "security": [ { "BearerAuth": [] @@ -9210,51 +9854,23 @@ "SessionCookie": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "defaultProvider": { - "type": "string", - "enum": [ - "mixed", - "exa" - ] - } - }, - "required": [ - "defaultProvider" - ] - } - } + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" } - }, + ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "defaultProvider": { - "type": "string", - "enum": [ - "mixed", - "exa" - ] - }, - "exaKeyConfigured": { - "type": "boolean" - } - }, - "required": [ - "defaultProvider", - "exaKeyConfigured" - ] + "$ref": "#/components/schemas/GitHubApp" } } } @@ -9342,10 +9958,10 @@ } } }, - "/v1/judge-model": { - "get": { - "operationId": "getJudgeModel", - "description": "The account's judge model for desktop jobs' intent checks, and the models the account's provider keys reach.", + "/v1/github-apps/{id}/client-secret": { + "put": { + "operationId": "setGithubAppClientSecret", + "description": "Stores a client secret generated in the App's GitHub settings, for an App created before Crumble kept it.", "security": [ { "BearerAuth": [] @@ -9354,13 +9970,62 @@ "SessionCookie": [] } ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 16000 + }, + "reference": { + "type": "object", + "properties": { + "passwordManager": { + "type": "string", + "enum": [ + "1password", + "bitwarden" + ] + }, + "reference": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "passwordManager", + "reference" + ] + } + }, + "additionalProperties": false + } + } + } + }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JudgeModel" + "$ref": "#/components/schemas/GitHubApp" } } } @@ -9446,9 +10111,12 @@ } } } - }, - "put": { - "operationId": "updateJudgeModel", + } + }, + "/v1/github-accounts": { + "get": { + "operationId": "listGithubAccounts", + "description": "The account's machine accounts: GitHub users made for bots. A bot acts as its machine account wherever none of its GitHub Apps does.", "security": [ { "BearerAuth": [] @@ -9457,51 +10125,24 @@ "SessionCookie": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "judgeModel": { - "type": [ - "object", - "null" - ], - "properties": { - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "provider", - "model" - ] - } - }, - "required": [ - "judgeModel" - ] - } - } - } - }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JudgeModel" + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GitHubAccount" + } + } + }, + "required": [ + "accounts" + ] } } } @@ -9587,12 +10228,10 @@ } } } - } - }, - "/v1/vision-model": { - "get": { - "operationId": "getVisionModel", - "description": "The account's vision model, which describes images for bots whose model can't read them, and the models that read images the account's provider keys reach.", + }, + "post": { + "operationId": "addGithubAccount", + "description": "Adds the GitHub user a personal access token signs in as. 400 when GitHub refuses the token; 409 when the user is already added.", "security": [ { "BearerAuth": [] @@ -9601,13 +10240,52 @@ "SessionCookie": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 16000 + }, + "reference": { + "type": "object", + "properties": { + "passwordManager": { + "type": "string", + "enum": [ + "1password", + "bitwarden" + ] + }, + "reference": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "passwordManager", + "reference" + ] + } + }, + "additionalProperties": false + } + } + } + }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VisionModel" + "$ref": "#/components/schemas/GitHubAccount" } } } @@ -9693,10 +10371,12 @@ } } } - }, + } + }, + "/v1/github-accounts/{id}/token": { "put": { - "operationId": "updateVisionModel", - "description": "Choose the account's vision model, or turn it off with null. It must be one of the choices `getVisionModel` lists.", + "operationId": "setGithubAccountToken", + "description": "Replaces the machine account's token. 400 when GitHub refuses it or it signs in as another user.", "security": [ { "BearerAuth": [] @@ -9705,6 +10385,16 @@ "SessionCookie": [] } ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "requestBody": { "required": true, "content": { @@ -9712,33 +10402,34 @@ "schema": { "type": "object", "properties": { - "visionModel": { - "type": [ - "object", - "null" - ], + "value": { + "type": "string", + "minLength": 1, + "maxLength": 16000 + }, + "reference": { + "type": "object", "properties": { - "provider": { + "passwordManager": { "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" + "enum": [ + "1password", + "bitwarden" + ] }, - "model": { + "reference": { "type": "string", "minLength": 1, - "maxLength": 200 + "maxLength": 1000 } }, "required": [ - "provider", - "model" + "passwordManager", + "reference" ] } }, - "required": [ - "visionModel" - ] + "additionalProperties": false } } } @@ -9749,7 +10440,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VisionModel" + "$ref": "#/components/schemas/GitHubAccount" } } } @@ -9837,9 +10528,10 @@ } } }, - "/v1/agents": { - "get": { - "operationId": "listAgents", + "/v1/github-accounts/{id}/bots": { + "put": { + "operationId": "setGithubAccountBots", + "description": "Replaces the bots that act as the machine account, giving them GH_TOKEN. A bot acts as at most one; 409 when a bot already acts as another.", "security": [ { "BearerAuth": [] @@ -9848,16 +10540,47 @@ "SessionCookie": [] } ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "botIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "maxItems": 200 + } + }, + "required": [ + "botIds" + ], + "additionalProperties": false + } + } + } + }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentPreview" - } + "$ref": "#/components/schemas/GitHubAccount" } } } @@ -9943,9 +10666,12 @@ } } } - }, - "post": { - "operationId": "createAgent", + } + }, + "/v1/github-accounts/{id}": { + "delete": { + "operationId": "deleteGithubAccount", + "description": "Forgets the machine account and its token, and takes GH_TOKEN from bots left without an installed App. The user stays on GitHub.", "security": [ { "BearerAuth": [] @@ -9954,320 +10680,154 @@ "SessionCookie": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 80 - }, - "avatar": { - "type": "object", - "properties": { - "definition": { - "type": "object", - "properties": { - "version": { - "type": "number", - "enum": [ - 1 - ] - }, - "id": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "body": { - "type": "object", - "properties": { - "path": { - "type": "string", - "minLength": 1, - "maxLength": 6000 - } - }, - "required": [ - "path" - ], - "additionalProperties": false - }, - "eyes": { - "type": "object", - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "spacing": { - "type": "number" - }, - "scale": { - "type": "number" - } - }, - "required": [ - "x", - "y", - "spacing", - "scale" - ], - "additionalProperties": false + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Success, no response body" + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/event-sources": { + "get": { + "operationId": "listEventSources", + "description": "The account's event sources, each with the number of automations that use it.", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "usedBy": { + "type": "integer", + "minimum": 0 } }, "required": [ - "version", "id", "name", - "body", - "eyes" - ], - "additionalProperties": false - }, - "color": { - "type": "string", - "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" - }, - "eyeColor": { - "type": "string", - "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + "createdAt", + "usedBy" + ] } - }, - "required": [ - "definition", - "color", - "eyeColor" - ] - }, - "description": { - "type": "string", - "maxLength": 1000, - "default": "" - }, - "instructions": { - "type": "string", - "maxLength": 32000, - "default": "You are a thoughtful, capable personal assistant." - }, - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$", - "default": "openai" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "default": "gpt-4.1" - }, - "webSearchProvider": { - "type": [ - "string", - "null" - ], - "enum": [ - "mixed", - "exa", - null - ] - }, - "enabled": { - "type": "boolean", - "default": true - }, - "tools": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "maxItems": 100, - "default": [ - "memory" - ] - }, - "skills": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "instructions": { - "type": "string", - "maxLength": 32000 - } - }, - "required": [ - "name", - "instructions" - ] - }, - "maxItems": 100, - "default": [] - }, - "sharedSkills": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "maxItems": 100 - }, - "sharedMemory": { - "type": "boolean" + } }, - "mcpServers": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "maxItems": 30, - "default": [] - }, - "delegates": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "maxItems": 30, - "default": [] - }, - "inference": { - "type": "object", - "properties": { - "thinkingLevel": { - "type": "string", - "enum": [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max" - ], - "default": "off" - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "maxOutputTokens": { - "type": "integer", - "minimum": 1, - "maximum": 1000000 - } - } - }, - "review": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "default": true - }, - "skillThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 10 - }, - "memoryThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 10 - }, - "maxInputTokens": { - "type": "integer", - "minimum": 1000, - "maximum": 2000000 - } - } - }, - "reviewModel": { - "type": [ - "object", - "null" - ], - "properties": { - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "provider", - "model" - ] - }, - "desktopModel": { - "type": [ - "object", - "null" - ], - "properties": { - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "provider", - "model" - ] - } - }, - "required": [ - "name" - ] - } - } - } - }, - "responses": { - "201": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Agent" + "required": [ + "sources" + ] } } } @@ -10353,11 +10913,10 @@ } } } - } - }, - "/v1/agents/{id}": { - "get": { - "operationId": "getAgent", + }, + "post": { + "operationId": "createEventSource", + "description": "Creates a source on the event relay. The response's `address` contains the source's secret and is returned only once. 400 when the relay URL or token is missing, or the relay can't be reached or rejects the token.", "security": [ { "BearerAuth": [] @@ -10366,23 +10925,59 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + } } - ], + }, "responses": { - "200": { + "201": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Agent" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "usedBy": { + "type": "integer", + "minimum": 0 + }, + "address": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "createdAt", + "usedBy", + "address" + ] } } } @@ -10468,9 +11063,12 @@ } } } - }, - "patch": { - "operationId": "updateAgent", + } + }, + "/v1/event-sources/{id}": { + "delete": { + "operationId": "deleteEventSource", + "description": "Deletes the source on the event relay and in Crumble; its address stops working. 409 while an automation uses it.", "security": [ { "BearerAuth": [] @@ -10482,319 +11080,135 @@ "parameters": [ { "schema": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 200 }, "required": true, "name": "id", "in": "path" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 80 - }, - "avatar": { - "type": "object", - "properties": { - "definition": { - "type": "object", - "properties": { - "version": { - "type": "number", - "enum": [ - 1 - ] - }, - "id": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "body": { - "type": "object", - "properties": { - "path": { - "type": "string", - "minLength": 1, - "maxLength": 6000 - } - }, - "required": [ - "path" - ], - "additionalProperties": false - }, - "eyes": { - "type": "object", - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "spacing": { - "type": "number" - }, - "scale": { - "type": "number" - } - }, - "required": [ - "x", - "y", - "spacing", - "scale" - ], - "additionalProperties": false - } - }, - "required": [ - "version", - "id", - "name", - "body", - "eyes" - ], - "additionalProperties": false - }, - "color": { - "type": "string", - "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" - }, - "eyeColor": { - "type": "string", - "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" - } - }, - "required": [ - "definition", - "color", - "eyeColor" - ] - }, - "description": { - "type": "string", - "maxLength": 1000 - }, - "instructions": { - "type": "string", - "maxLength": 32000 - }, - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "webSearchProvider": { - "type": [ - "string", - "null" - ], - "enum": [ - "mixed", - "exa", - null - ] - }, - "enabled": { - "type": "boolean" - }, - "tools": { - "type": "array", - "items": { + "responses": { + "204": { + "description": "Success, no response body" + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/web-search": { + "get": { + "operationId": "getWebSearch", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defaultProvider": { "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "maxItems": 100 - }, - "skills": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "instructions": { - "type": "string", - "maxLength": 32000 - } - }, - "required": [ - "name", - "instructions" + "enum": [ + "mixed", + "exa" ] }, - "maxItems": 100 - }, - "sharedSkills": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "maxItems": 100 - }, - "sharedMemory": { - "type": "boolean" - }, - "mcpServers": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "maxItems": 30 - }, - "delegates": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "maxItems": 30 + "exaKeyConfigured": { + "type": "boolean" + } }, - "inference": { - "type": "object", - "properties": { - "thinkingLevel": { - "type": "string", - "enum": [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max" - ], - "default": "off" - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "maxOutputTokens": { - "type": "integer", - "minimum": 1, - "maximum": 1000000 - } - } - }, - "review": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "default": true - }, - "skillThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 10 - }, - "memoryThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 10 - }, - "maxInputTokens": { - "type": "integer", - "minimum": 1000, - "maximum": 2000000 - } - } - }, - "reviewModel": { - "type": [ - "object", - "null" - ], - "properties": { - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "provider", - "model" - ] - }, - "desktopModel": { - "type": [ - "object", - "null" - ], - "properties": { - "provider": { - "type": "string", - "minLength": 1, - "maxLength": 80, - "pattern": "^[a-z0-9-]+$" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - "required": [ - "provider", - "model" - ] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/Agent" - }, - { - "type": "null" - } + "required": [ + "defaultProvider", + "exaKeyConfigured" ] } } @@ -10882,8 +11296,8 @@ } } }, - "delete": { - "operationId": "deleteAgent", + "put": { + "operationId": "updateWebSearch", "security": [ { "BearerAuth": [] @@ -10892,19 +11306,54 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defaultProvider": { + "type": "string", + "enum": [ + "mixed", + "exa" + ] + } + }, + "required": [ + "defaultProvider" + ] + } + } } - ], + }, "responses": { - "204": { - "description": "Success, no response body" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defaultProvider": { + "type": "string", + "enum": [ + "mixed", + "exa" + ] + }, + "exaKeyConfigured": { + "type": "boolean" + } + }, + "required": [ + "defaultProvider", + "exaKeyConfigured" + ] + } + } + } }, "400": { "description": "Invalid request or operation", @@ -10989,9 +11438,10 @@ } } }, - "/v1/agents/{id}/duplicate": { - "post": { - "operationId": "duplicateAgent", + "/v1/judge-model": { + "get": { + "operationId": "getJudgeModel", + "description": "The account's judge model for desktop jobs' intent checks, and the models the account's provider keys reach.", "security": [ { "BearerAuth": [] @@ -11000,30 +11450,13 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - } - ], "responses": { - "201": { + "200": { "description": "Success", "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/Agent" - }, - { - "type": "null" - } - ] + "$ref": "#/components/schemas/JudgeModel" } } } @@ -11109,11 +11542,9 @@ } } } - } - }, - "/v1/agents/{id}/chat": { - "get": { - "operationId": "getChat", + }, + "put": { + "operationId": "updateJudgeModel", "security": [ { "BearerAuth": [] @@ -11122,45 +11553,63 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChatSnapshot" - } - } - } - }, - "400": { - "description": "Invalid request or operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "judgeModel": { + "type": [ + "object", + "null" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "provider", + "model" + ] + } + }, + "required": [ + "judgeModel" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JudgeModel" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } } }, "401": { @@ -11236,9 +11685,10 @@ } } }, - "/v1/agents/{id}/chat/history": { + "/v1/vision-model": { "get": { - "operationId": "getChatHistory", + "operationId": "getVisionModel", + "description": "The account's vision model, which describes images for bots whose model can't read them, and the models that read images the account's provider keys reach.", "security": [ { "BearerAuth": [] @@ -11247,43 +11697,13 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "before", - "in": "query" - } - ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TranscriptPage" + "$ref": "#/components/schemas/VisionModel" } } } @@ -11369,11 +11789,10 @@ } } } - } - }, - "/v1/agents/{id}/coding-jobs": { - "get": { - "operationId": "listCodingJobs", + }, + "put": { + "operationId": "updateVisionModel", + "description": "Choose the account's vision model, or turn it off with null. It must be one of the choices `getVisionModel` lists.", "security": [ { "BearerAuth": [] @@ -11382,23 +11801,51 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visionModel": { + "type": [ + "object", + "null" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "provider", + "model" + ] + } + }, + "required": [ + "visionModel" + ] + } + } } - ], + }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CodingJobs" + "$ref": "#/components/schemas/VisionModel" } } } @@ -11486,10 +11933,9 @@ } } }, - "/v1/agents/{id}/coding-jobs/{name}/answer": { - "post": { - "operationId": "answerCodingJob", - "description": "Answers the coding job's open question, which resumes the job. 409 when the job no longer waits on that question.", + "/v1/agents": { + "get": { + "operationId": "listAgents", "security": [ { "BearerAuth": [] @@ -11498,52 +11944,19 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "name", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "questionId": { - "type": "string", - "minLength": 1 - }, - "text": { - "type": "string", - "minLength": 1, - "maxLength": 32000 + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentPreview" } - }, - "required": [ - "questionId", - "text" - ] + } } } - } - }, - "responses": { - "204": { - "description": "Success, no response body" }, "400": { "description": "Invalid request or operation", @@ -11626,11 +12039,9 @@ } } } - } - }, - "/v1/agents/{id}/coding-jobs/{name}/history": { - "get": { - "operationId": "getCodingJobHistory", + }, + "post": { + "operationId": "createAgent", "security": [ { "BearerAuth": [] @@ -11639,43 +12050,1728 @@ "SessionCookie": [] } ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "name", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "before", - "in": "query" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "avatar": { + "type": "object", + "properties": { + "definition": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "body": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 6000 + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "eyes": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "spacing": { + "type": "number" + }, + "scale": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "spacing", + "scale" + ], + "additionalProperties": false + } + }, + "required": [ + "version", + "id", + "name", + "body", + "eyes" + ], + "additionalProperties": false + }, + "color": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + "eyeColor": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + } + }, + "required": [ + "definition", + "color", + "eyeColor" + ] + }, + "description": { + "type": "string", + "maxLength": 1000, + "default": "" + }, + "instructions": { + "type": "string", + "maxLength": 32000, + "default": "You are a thoughtful, capable personal assistant." + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$", + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "default": "gpt-4.1" + }, + "webSearchProvider": { + "type": [ + "string", + "null" + ], + "enum": [ + "mixed", + "exa", + null + ] + }, + "enabled": { + "type": "boolean", + "default": true + }, + "tools": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "maxItems": 100, + "default": [ + "memory" + ] + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "instructions": { + "type": "string", + "maxLength": 32000 + } + }, + "required": [ + "name", + "instructions" + ] + }, + "maxItems": 100, + "default": [] + }, + "sharedSkills": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 100 + }, + "sharedMemory": { + "type": "boolean" + }, + "mcpServers": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 30, + "default": [] + }, + "delegates": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 30, + "default": [] + }, + "inference": { + "type": "object", + "properties": { + "thinkingLevel": { + "type": "string", + "enum": [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default": "off" + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "maxOutputTokens": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + } + }, + "review": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "skillThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 10 + }, + "memoryThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 10 + }, + "maxInputTokens": { + "type": "integer", + "minimum": 1000, + "maximum": 2000000 + } + } + }, + "reviewModel": { + "type": [ + "object", + "null" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "provider", + "model" + ] + }, + "desktopModel": { + "type": [ + "object", + "null" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "provider", + "model" + ] + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Agent" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}": { + "get": { + "operationId": "getAgent", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Agent" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "updateAgent", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "avatar": { + "type": "object", + "properties": { + "definition": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "body": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 6000 + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "eyes": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "spacing": { + "type": "number" + }, + "scale": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "spacing", + "scale" + ], + "additionalProperties": false + } + }, + "required": [ + "version", + "id", + "name", + "body", + "eyes" + ], + "additionalProperties": false + }, + "color": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + }, + "eyeColor": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" + } + }, + "required": [ + "definition", + "color", + "eyeColor" + ] + }, + "description": { + "type": "string", + "maxLength": 1000 + }, + "instructions": { + "type": "string", + "maxLength": 32000 + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "webSearchProvider": { + "type": [ + "string", + "null" + ], + "enum": [ + "mixed", + "exa", + null + ] + }, + "enabled": { + "type": "boolean" + }, + "tools": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "maxItems": 100 + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "instructions": { + "type": "string", + "maxLength": 32000 + } + }, + "required": [ + "name", + "instructions" + ] + }, + "maxItems": 100 + }, + "sharedSkills": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 100 + }, + "sharedMemory": { + "type": "boolean" + }, + "mcpServers": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 30 + }, + "delegates": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 30 + }, + "inference": { + "type": "object", + "properties": { + "thinkingLevel": { + "type": "string", + "enum": [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default": "off" + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "maxOutputTokens": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + } + }, + "review": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "skillThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 10 + }, + "memoryThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 10 + }, + "maxInputTokens": { + "type": "integer", + "minimum": 1000, + "maximum": 2000000 + } + } + }, + "reviewModel": { + "type": [ + "object", + "null" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "provider", + "model" + ] + }, + "desktopModel": { + "type": [ + "object", + "null" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9-]+$" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "provider", + "model" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Agent" + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteAgent", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Success, no response body" + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/duplicate": { + "post": { + "operationId": "duplicateAgent", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Agent" + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/chat": { + "get": { + "operationId": "getChat", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatSnapshot" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/chat/history": { + "get": { + "operationId": "getChatHistory", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TranscriptPage" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/coding-jobs": { + "get": { + "operationId": "listCodingJobs", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodingJobs" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/coding-jobs/{name}/answer": { + "post": { + "operationId": "answerCodingJob", + "description": "Answers the coding job's open question, which resumes the job. 409 when the job no longer waits on that question.", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "name", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "questionId": { + "type": "string", + "minLength": 1 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 32000 + } + }, + "required": [ + "questionId", + "text" + ] + } + } + } + }, + "responses": { + "204": { + "description": "Success, no response body" + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/coding-jobs/{name}/history": { + "get": { + "operationId": "getCodingJobHistory", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "name", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "before", + "in": "query" + } ], "responses": { "200": { @@ -13232,6 +15328,16 @@ "required": false, "name": "limit", "in": "query" + }, + { + "schema": { + "type": "string", + "description": "With partial=true, each snapshot's partial carries the reply text the bot is writing, updated about every 100 ms. Without it, partial stays empty and replies appear only when the turn ends." + }, + "required": false, + "description": "With partial=true, each snapshot's partial carries the reply text the bot is writing, updated about every 100 ms. Without it, partial stays empty and replies appear only when the turn ends.", + "name": "partial", + "in": "query" } ], "responses": { @@ -15444,26 +17550,393 @@ } } } - }, + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request": { + "$ref": "#/components/schemas/BotRequest" + }, + "agent": { + "$ref": "#/components/schemas/Agent" + } + }, + "required": [ + "request", + "agent" + ] + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/bot-requests/{requestId}/decline": { + "post": { + "operationId": "declineBotRequest", + "description": "Declines a pending bot request.", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "requestId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotRequest" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/call-requests": { + "get": { + "operationId": "listCallRequests", + "description": "The bot's call requests, newest first: its proposals to phone someone other than the owner, and how each call went.", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CallRequest" + } + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/call-requests/{requestId}/approve": { + "post": { + "operationId": "approveCallRequest", + "description": "Approves a pending call request and dials at once; returns once the voice gateway was asked to call, with the status calling. 409, with the request still pending, when the request is not pending, the number no longer passes the number rules, calls to other people are switched off for the line, no voice gateway that places calls is connected, or the line has a call.", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "requestId", + "in": "path" + } + ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "request": { - "$ref": "#/components/schemas/BotRequest" - }, - "agent": { - "$ref": "#/components/schemas/Agent" - } - }, - "required": [ - "request", - "agent" - ] + "$ref": "#/components/schemas/CallRequest" } } } @@ -15551,10 +18024,10 @@ } } }, - "/v1/agents/{id}/bot-requests/{requestId}/decline": { + "/v1/agents/{id}/call-requests/{requestId}/decline": { "post": { - "operationId": "declineBotRequest", - "description": "Declines a pending bot request.", + "operationId": "declineCallRequest", + "description": "Declines a pending call request; the bot gets a notice.", "security": [ { "BearerAuth": [] @@ -15587,7 +18060,151 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BotRequest" + "$ref": "#/components/schemas/CallRequest" + } + } + } + }, + "400": { + "description": "Invalid request or operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing, expired, or invalid account session", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "405": { + "description": "Resource is read-only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the size limit", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/agents/{id}/call-requests/{requestId}/transcript": { + "get": { + "operationId": "getCallRequestTranscript", + "description": "The messages of the call's conversation: the callee's words as user messages and the bot's replies. Empty before the call, and when the callee objected to the call.", + "security": [ + { + "BearerAuth": [] + }, + { + "SessionCookie": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "requestId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TranscriptPage" } } } diff --git a/scripts/generate-contract.py b/scripts/generate-contract.py index 629c2d2..b102400 100644 --- a/scripts/generate-contract.py +++ b/scripts/generate-contract.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Crumble Kotlin contract generator 1.1.0; Python standard library only.""" +"""Crumble Kotlin contract generator 1.2.0; Python standard library only.""" import argparse import copy import json import pathlib import re -VERSION = "1.1.0" +VERSION = "1.2.0" ROOT = pathlib.Path(__file__).resolve().parents[1] PACKAGE = "dev.crumble.android.generated" HEADER = "// Generated by scripts/generate-contract.py " + VERSION + ". Do not edit.\npackage " + PACKAGE + "\n\n" @@ -205,7 +205,44 @@ def model(self, name, schema): else: default = "" lines += [" val `" + key + "`: " + t + default + ","] - return "\n".join(lines + [")"]) + additional = schema.get("additionalProperties", False) + if additional is False: + return "\n".join(lines + [")"]) + value = self.type({} if additional is True else additional, name + "Value", request) + return self.open_model(name, lines[2:], list(properties), value) + + # An object that declares properties and allows others keeps the others in additionalProperties. + def open_model(self, name, fields, keys, value): + if "additionalProperties" in keys: + raise ValueError("open object declares a property named additionalProperties: " + name) + declared = ", ".join(quoted(key) for key in keys) + names = ", ".join("`" + key + "`" for key in keys) + return "\n".join( + ["@Serializable(with = " + name + "Codec::class)", "data class " + name + "("] + + fields + + [" val additionalProperties: Map = emptyMap(),", ")", "@Serializable", "private data class " + name + "Fields("] + + fields + + [ + ")", + "object " + name + "Codec : KSerializer<" + name + "> {", + " private val declared = setOf(" + declared + ")", + " override val descriptor: SerialDescriptor = buildClassSerialDescriptor(" + quoted(name) + ")", + " override fun deserialize(decoder: Decoder): " + name + " {", + " val input = decoder as JsonDecoder", + " val element = input.decodeJsonElement().jsonObject", + " val fields = input.json.decodeFromJsonElement<" + name + "Fields>(JsonObject(element.filterKeys { it in declared }))", + " val additional = element.filterKeys { it !in declared }.mapValues { input.json.decodeFromJsonElement<" + value + ">(it.value) }", + " return " + name + "(" + ", ".join("fields." + n for n in names.split(", ")) + ", additional)", + " }", + " override fun serialize(encoder: Encoder, value: " + name + ") {", + " val output = encoder as JsonEncoder", + " val fields = output.json.encodeToJsonElement(" + name + "Fields(" + ", ".join("value." + n for n in names.split(", ")) + ")).jsonObject", + " val additional = value.additionalProperties.mapValues { output.json.encodeToJsonElement<" + value + ">(it.value) }", + " output.encodeJsonElement(JsonObject(additional + fields))", + " }", + "}", + ] + ) def operations(self): lines = ["class ContractOperations(private val boundary: ContractBoundary) {"] diff --git a/scripts/test_contract.py b/scripts/test_contract.py index 080fb96..fad3bc2 100644 --- a/scripts/test_contract.py +++ b/scripts/test_contract.py @@ -55,6 +55,17 @@ def test_nullable_and_optional_are_distinct(self): self.assertIn("val `requiredNull`: String?,", code) self.assertIn("val `optionalNull`: OptionalField = OptionalField.Absent", code) + def test_open_object_keeps_additional_properties(self): + instance = generator.Generator({"components": {"schemas": {}}, "paths": {}}) + schema = {"type": "object", "properties": {"openapi": {"type": "string"}}, "required": ["openapi"], "additionalProperties": {}} + code = instance.model("Document", schema) + self.assertIn("val additionalProperties: Map = emptyMap(),", code) + self.assertIn("return Document(fields.`openapi`, additional)", code) + self.assertIn("output.encodeJsonElement(JsonObject(additional + fields))", code) + self.assertNotIn("additionalProperties", instance.model("Closed", dict(schema, additionalProperties=False))) + with self.assertRaises(ValueError): + instance.model("Clash", dict(schema, properties={"additionalProperties": {"type": "string"}})) + if __name__ == "__main__": unittest.main()