From 3b84a55d99432b06162773f0567a4898b83f13bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 04:07:15 -0700 Subject: [PATCH] refactor(protocol): pre-publish cheat-window cleanup and vintage tracking (#111041) * fix(codex): drain dynamic-tool handlers before side-thread cleanup * refactor(protocol): rename question ids to questionId and flatten answer maps * refactor(protocol): slim worker stack and unify session-catalog shapes * refactor(protocol): delete dead public surface and polish packaging * feat(protocol): track release-train vintage on gateway methods and schemas * fix(apps): align question surfaces merged from main with reshaped protocol * test(health): shape secret fixtures to scanner-safe token names * test(health): use scanner-safe token fixtures * fix(apps): align question surfaces merged from main with reshaped protocol * fix(ci): prove reshaped protocol in shallow checks * fix(ui): align sidebar question fixtures with protocol * fix(apps): read flat Swift question answers * fix(apps): align question surfaces merged from main with reshaped protocol * fix(apps): refresh native question inventory * fix(apps): align macOS snapshot fixtures with protocol * fix(ui): align narration question fixture with protocol --- .github/workflows/ci.yml | 8 + apps/.i18n/native-source.json | 56 +- .../ai/openclaw/app/chat/ChatController.kt | 8 +- .../java/ai/openclaw/app/chat/ChatQuestion.kt | 16 +- .../openclaw/app/gateway/GatewayProtocol.kt | 9 +- .../openclaw/app/ui/chat/ChatQuestionCard.kt | 6 +- .../ai/openclaw/app/chat/ChatQuestionTest.kt | 20 +- .../ui/chat/ChatReaderScrollControllerTest.kt | 3 +- .../GatewayConnectionControlTests.swift | 2 +- .../MacGatewayChatTransportMappingTests.swift | 2 +- .../OpenClawChatUI/ChatGatewayRequest.swift | 4 +- .../OpenClawChatUI/ChatQuestionCard.swift | 52 +- .../OpenClawProtocol/GatewayModels.swift | 38 +- .../ChatGatewayRequestTests.swift | 6 +- .../ChatQuestionCardTests.swift | 6 +- .../OpenClawKitTests/ChatViewModelTests.swift | 4 +- config/knip.config.ts | 2 + .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- .../acpx/src/pi-session-catalog-plugin.ts | 2 +- extensions/anthropic/session-catalog.test.ts | 2 +- extensions/anthropic/session-catalog.ts | 2 +- .../codex/src/app-server/side-question.ts | 23 +- .../src/app-server/user-input-bridge.test.ts | 6 +- .../codex/src/app-server/user-input-bridge.ts | 10 +- .../src/session-catalog-node-continue.ts | 4 +- .../codex/src/session-catalog-parsing.ts | 14 +- extensions/codex/src/session-catalog-types.ts | 2 +- extensions/codex/src/session-catalog.test.ts | 16 +- extensions/codex/src/session-catalog.ts | 4 +- extensions/copilot/src/attempt.ts | 6 +- .../copilot/src/user-input-bridge.test.ts | 7 +- extensions/copilot/src/user-input-bridge.ts | 2 +- extensions/opencode/session-catalog-plugin.ts | 2 +- package.json | 2 +- packages/gateway-protocol/package.json | 1 + .../src/approvals-validators.test.ts | 84 +-- .../src/clawhub-trust-error-details.ts | 3 +- .../src/connect-error-details.test.ts | 11 +- .../src/connect-error-details.ts | 10 +- .../src/gateway-suspend.test.ts | 93 +-- packages/gateway-protocol/src/index.test.ts | 307 +------- packages/gateway-protocol/src/index.ts | 56 +- .../src/plugins-validators.test.ts | 105 +-- .../src/protocol-validator.ts | 1 + .../src/public-session-catalog.ts | 1 + .../src/questions-validators.test.ts | 44 +- packages/gateway-protocol/src/schema/agent.ts | 6 +- .../src/schema/agents-models-skills.ts | 2 +- .../gateway-protocol/src/schema/approvals.ts | 50 +- .../src/schema/audit-activity.ts | 6 +- packages/gateway-protocol/src/schema/audit.ts | 11 +- .../gateway-protocol/src/schema/channels.ts | 6 +- .../gateway-protocol/src/schema/commands.ts | 10 +- .../gateway-protocol/src/schema/config.ts | 4 +- packages/gateway-protocol/src/schema/cron.ts | 18 +- .../src/schema/environments.ts | 3 +- .../src/schema/exec-approvals.ts | 8 +- .../gateway-protocol/src/schema/logs-chat.ts | 3 +- .../gateway-protocol/src/schema/migrations.ts | 15 +- .../src/schema/nodes-invoke-input.test.ts | 32 - packages/gateway-protocol/src/schema/nodes.ts | 2 +- .../gateway-protocol/src/schema/primitives.ts | 7 - .../gateway-protocol/src/schema/questions.ts | 34 +- .../src/schema/session-placement.test.ts | 65 +- .../src/schema/session-placement.ts | 22 +- .../src/schema/sessions-catalog.test.ts | 4 +- .../src/schema/sessions-catalog.ts | 37 +- .../gateway-protocol/src/schema/sessions.ts | 19 +- packages/gateway-protocol/src/schema/since.ts | 7 + .../gateway-protocol/src/schema/snapshot.ts | 145 +++- .../src/schema/system-event.ts | 4 +- packages/gateway-protocol/src/schema/tasks.ts | 2 +- .../terminal-constants.ts} | 0 .../src/schema/terminal.test.ts | 2 +- .../gateway-protocol/src/schema/terminal.ts | 75 +- .../gateway-protocol/src/schema/wizard.ts | 4 +- .../src/schema/worker-admission.ts | 160 +---- .../src/schema/worker-inference.ts | 278 ++----- .../src/schema/worker-protocol-primitives.ts | 122 ++++ .../src/startup-unavailable.ts | 2 +- .../src/terminal-validators.ts | 2 - scripts/check-protocol-since.mjs | 173 +++++ scripts/plugin-sdk-surface-report.mjs | 3 +- scripts/protocol-gen-swift.ts | 41 +- scripts/protocol-gen.ts | 5 + ...embedded-agent-subscribe.handlers.tools.ts | 5 +- src/agents/harness/gateway-question.test.ts | 31 +- src/agents/harness/gateway-question.ts | 11 +- src/agents/tools/ask-user-tool.test.ts | 8 +- src/agents/tools/ask-user-tool.ts | 14 +- src/commands/health.plugins.test.ts | 3 + src/commands/health.snapshot.test.ts | 4 +- .../methods/core-descriptors.since.test.ts | 66 ++ src/gateway/methods/core-descriptors.ts | 680 ++++++++++-------- src/gateway/methods/descriptor.ts | 1 + src/gateway/question-manager.test.ts | 46 +- src/gateway/question-manager.ts | 20 +- src/gateway/server-methods/question.test.ts | 12 +- src/gateway/server-methods/question.ts | 18 +- .../server-methods/session-catalog.test.ts | 15 + src/gateway/server-methods/session-catalog.ts | 10 +- src/gateway/server-methods/terminal-upload.ts | 2 +- src/gateway/server/health-state.ts | 2 +- .../inference-runtime.test.ts | 7 +- .../question-channel-runtime-internal.ts | 2 +- src/infra/question-channel-runtime.test.ts | 12 +- src/infra/question-gateway-resolver.test.ts | 8 +- src/infra/question-gateway-resolver.ts | 4 +- src/infra/question-reaction-runtime.test.ts | 4 +- src/infra/terminal-file-upload.test.ts | 4 +- src/infra/terminal-file-upload.ts | 2 +- src/plugin-sdk/session-catalog.ts | 1 + src/worker/worker-rpc-clients.test.ts | 5 +- ui/src/app/question-prompt.test.ts | 56 +- ui/src/app/question-prompt.ts | 49 +- .../app-sidebar-session-catalogs.ts | 16 +- ui/src/e2e/question-flow.e2e.test.ts | 22 +- ui/src/pages/chat/chat-composer.test.ts | 2 +- .../pages/chat/chat-thread.question.test.ts | 6 +- .../components/chat-question-card.test.ts | 8 +- .../chat/components/chat-question-card.ts | 44 +- .../app-sidebar-cases/attention.ts | 4 +- .../app-sidebar-cases/catalog-live.ts | 2 +- .../app-sidebar-cases/interactions.ts | 2 +- .../app-sidebar-cases/narration.ts | 2 +- 125 files changed, 1648 insertions(+), 2015 deletions(-) delete mode 100644 packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts create mode 100644 packages/gateway-protocol/src/schema/since.ts rename packages/gateway-protocol/src/{terminal-upload-constants.ts => schema/terminal-constants.ts} (100%) create mode 100644 packages/gateway-protocol/src/schema/worker-protocol-primitives.ts create mode 100644 scripts/check-protocol-since.mjs create mode 100644 src/gateway/methods/core-descriptors.since.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7737e88d2475..a22b392f3896 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1433,6 +1433,7 @@ jobs: env: GH_TOKEN: ${{ matrix.task == 'max-lines-ratchet' && github.token || '' }} OPENCLAW_TEST_PROJECTS_PARALLEL: 3 + PROTOCOL_SINCE_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }} RATCHET_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} RATCHET_EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }} RATCHET_MANUAL_TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.checkout_revision || '' }} @@ -1450,6 +1451,13 @@ jobs: case "$TASK" in bundled-protocol) pnpm test:bundled + if [[ -n "${PROTOCOL_SINCE_BASE_SHA:-}" ]]; then + git fetch --no-tags --no-recurse-submodules --depth=1 origin \ + "+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base" + else + git fetch --no-tags --no-recurse-submodules --depth=1 origin \ + "+refs/heads/main:refs/remotes/origin/main" + fi pnpm protocol:check ;; contracts-plugins-ci-routing) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 2e27d353afb2..3c37f3f39be7 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 1041, + "line": 1040, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Wait for the current response to finish before starting a new chat.", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 1174, + "line": 1173, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update model.", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 1239, + "line": 1238, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update thinking level.", "surface": "android", @@ -1779,7 +1779,7 @@ }, { "kind": "ui-call", - "line": 1732, + "line": 1731, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed before the run started; try again.", "surface": "android", @@ -1787,7 +1787,7 @@ }, { "kind": "ui-call", - "line": 3173, + "line": 3169, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not stage an attachment for sending.", "surface": "android", @@ -1795,7 +1795,7 @@ }, { "kind": "ui-call", - "line": 3206, + "line": 3202, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.", "surface": "android", @@ -1803,7 +1803,7 @@ }, { "kind": "ui-call", - "line": 3212, + "line": 3208, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Attachments are too large to queue for one message; remove some and try again.", "surface": "android", @@ -1811,7 +1811,7 @@ }, { "kind": "ui-call", - "line": 3218, + "line": 3214, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline attachment storage is full; delete queued items first.", "surface": "android", @@ -1819,7 +1819,7 @@ }, { "kind": "ui-call", - "line": 3223, + "line": 3219, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Gateway health not OK; cannot send", "surface": "android", @@ -1827,7 +1827,7 @@ }, { "kind": "ui-call", - "line": 3230, + "line": 3226, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not queue message for later delivery.", "surface": "android", @@ -1835,7 +1835,7 @@ }, { "kind": "ui-call", - "line": 3947, + "line": 3943, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed", "surface": "android", @@ -1843,7 +1843,7 @@ }, { "kind": "ui-call", - "line": 4085, + "line": 4081, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Event stream interrupted; try refreshing.", "surface": "android", @@ -1851,7 +1851,7 @@ }, { "kind": "ui-call", - "line": 4222, + "line": 4218, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1859,7 +1859,7 @@ }, { "kind": "ui-call", - "line": 4432, + "line": 4428, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", @@ -37643,7 +37643,7 @@ }, { "kind": "ui-localized-call", - "line": 258, + "line": 256, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Answered", "surface": "apple", @@ -37651,7 +37651,7 @@ }, { "kind": "ui-localized-call", - "line": 261, + "line": 259, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Answered elsewhere", "surface": "apple", @@ -37659,7 +37659,7 @@ }, { "kind": "ui-localized-call", - "line": 263, + "line": 261, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Skipped", "surface": "apple", @@ -37667,7 +37667,7 @@ }, { "kind": "ui-localized-call", - "line": 265, + "line": 263, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Expired", "surface": "apple", @@ -37675,7 +37675,7 @@ }, { "kind": "ui-localized-call", - "line": 267, + "line": 265, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Unavailable", "surface": "apple", @@ -37683,7 +37683,7 @@ }, { "kind": "ui-localized-call", - "line": 269, + "line": 267, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Pending", "surface": "apple", @@ -37691,7 +37691,7 @@ }, { "kind": "ui-modifier", - "line": 367, + "line": 361, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Question summary", "surface": "apple", @@ -37699,7 +37699,7 @@ }, { "kind": "ui-call", - "line": 381, + "line": 375, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Other answer", "surface": "apple", @@ -37707,7 +37707,7 @@ }, { "kind": "conditional-branch", - "line": 439, + "line": 433, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Not selected", "surface": "apple", @@ -37715,7 +37715,7 @@ }, { "kind": "conditional-branch", - "line": 439, + "line": 433, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Selected", "surface": "apple", @@ -37723,7 +37723,7 @@ }, { "kind": "ui-call", - "line": 456, + "line": 450, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Skipping…", "surface": "apple", @@ -37731,7 +37731,7 @@ }, { "kind": "ui-call", - "line": 459, + "line": 453, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Skip", "surface": "apple", @@ -37739,7 +37739,7 @@ }, { "kind": "ui-call", - "line": 470, + "line": 464, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Submitting…", "surface": "apple", @@ -37747,7 +37747,7 @@ }, { "kind": "ui-call", - "line": 473, + "line": 467, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift", "source": "Submit", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 4e8c0fa519c4..1313fe451ca5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -7,7 +7,6 @@ import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.QuestionAnswers -import ai.openclaw.app.gateway.QuestionAnswersAnswersValue import ai.openclaw.app.gateway.QuestionGetResult import ai.openclaw.app.gateway.QuestionListResult import ai.openclaw.app.gateway.QuestionRecord @@ -2144,7 +2143,7 @@ class ChatController internal constructor( "answers", buildJsonObject { answers.orEmpty().forEach { (questionId, values) -> - put(questionId, buildJsonObject { put("answers", JsonArray(values.map(::JsonPrimitive))) }) + put(questionId, JsonArray(values.map(::JsonPrimitive))) } }, ) @@ -2160,10 +2159,7 @@ class ChatController internal constructor( record = prompt.record.copy( status = if (cancel) "cancelled" else "answered", - answers = - answers?.let { values -> - QuestionAnswers(values.mapValues { QuestionAnswersAnswersValue(it.value) }) - }, + answers = answers?.let(::QuestionAnswers), ), submitting = false, skipping = false, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt index 44fa2dd092c3..3ce02baca7de 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt @@ -49,7 +49,7 @@ data class ChatQuestionDraft( label: String, ): ChatQuestionDraft { if (question.options.none { it.label == label }) return this - val selected = selectedOptions[question.id].orEmpty() + val selected = selectedOptions[question.questionId].orEmpty() val next = if (question.multiSelect == true) { if (label in selected) selected - label else selected + label @@ -59,8 +59,8 @@ data class ChatQuestionDraft( setOf(label) } return copy( - selectedOptions = selectedOptions + (question.id to next), - otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.id to "") else otherText, + selectedOptions = selectedOptions + (question.questionId to next), + otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.questionId to "") else otherText, ) } @@ -71,19 +71,19 @@ data class ChatQuestionDraft( if (question.options.isNotEmpty() && question.isOther != true) return this val clearOptions = question.multiSelect != true && value.isNotBlank() return copy( - selectedOptions = if (clearOptions) selectedOptions + (question.id to emptySet()) else selectedOptions, - otherText = otherText + (question.id to value), + selectedOptions = if (clearOptions) selectedOptions + (question.questionId to emptySet()) else selectedOptions, + otherText = otherText + (question.questionId to value), ) } fun answers(questions: List): Map>? { val result = linkedMapOf>() for (question in questions) { - val selected = selectedOptions[question.id].orEmpty() + val selected = selectedOptions[question.questionId].orEmpty() val values = question.options.mapNotNull { option -> option.label.takeIf { it in selected } }.toMutableList() - otherText[question.id]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add) + otherText[question.questionId]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add) if (values.isEmpty()) return null - result[question.id] = values + result[question.questionId] = values } return result } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 35da85779c11..48a35e964eee 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -81,7 +81,7 @@ data class QuestionOption( @Serializable data class Question( - val id: String, + val questionId: String, val header: String, val question: String, val options: List, @@ -92,7 +92,7 @@ data class Question( @Serializable data class QuestionAnswers( - val answers: Map, + val answers: Map>, ) @Serializable @@ -130,11 +130,6 @@ data class GatewayNodeInvokeResultParamsError( val message: String? = null, ) -@Serializable -data class QuestionAnswersAnswersValue( - val answers: List, -) - enum class GatewayMethod( val rawValue: String, ) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt index 16ccebe9eda8..213f4bfe9862 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt @@ -138,7 +138,7 @@ private fun QuestionSection( ) Text(text = question.question, style = ClawTheme.type.body, color = ClawTheme.colors.text) question.options.forEach { option -> - val selected = option.label in draft.selectedOptions[question.id].orEmpty() + val selected = option.label in draft.selectedOptions[question.questionId].orEmpty() Surface( onClick = { onDraftChanged(draft.toggle(question, option.label)) }, enabled = enabled, @@ -166,7 +166,7 @@ private fun QuestionSection( } if (question.options.isEmpty() || question.isOther == true) { OutlinedTextField( - value = draft.otherText[question.id].orEmpty(), + value = draft.otherText[question.questionId].orEmpty(), onValueChange = { onDraftChanged(draft.setOther(question, it)) }, modifier = Modifier.fillMaxWidth(), enabled = enabled, @@ -229,7 +229,7 @@ internal fun terminalQuestionAnswer( if (status == ChatQuestionStatus.Cancelled) return nativeString("Skipped") if (status == ChatQuestionStatus.Expired) return nativeString("Expired") if (status == ChatQuestionStatus.Unavailable) return nativeString("Unavailable") - prompt.record.answers?.answers?.get(question.id)?.answers?.takeIf { it.isNotEmpty() }?.let { + prompt.record.answers?.answers?.get(question.questionId)?.takeIf { it.isNotEmpty() }?.let { return it.joinToString(", ") } return if (status == ChatQuestionStatus.AnsweredElsewhere) nativeString("Answered elsewhere") else nativeString("Answered") diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt index f2e200b57018..9de429c60390 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt @@ -5,7 +5,6 @@ import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.Question import ai.openclaw.app.gateway.QuestionAnswers -import ai.openclaw.app.gateway.QuestionAnswersAnswersValue import ai.openclaw.app.gateway.QuestionGetResult import ai.openclaw.app.gateway.QuestionListResult import ai.openclaw.app.gateway.QuestionOption @@ -30,7 +29,7 @@ import org.junit.Test class ChatQuestionTest { private val question = Question( - id = "meal", + questionId = "meal", header = "Meal", question = "Choose dinner", options = listOf(QuestionOption("Pizza"), QuestionOption("Tacos")), @@ -87,7 +86,7 @@ class ChatQuestionTest { ChatQuestionPrompt( record = record(status = "answered").copy( - answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Pizza", "Salad")))), + answers = QuestionAnswers(mapOf("meal" to listOf("Pizza", "Salad"))), ), answeredLocally = true, ) @@ -430,7 +429,7 @@ class ChatQuestionTest { val answered = pending.copy( status = "answered", - answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))), + answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))), ) var getParams: String? = null val controller = @@ -466,8 +465,7 @@ class ChatQuestionTest { .single() .record.answers ?.answers - ?.get("meal") - ?.answers, + ?.get("meal"), ) assertEquals( ChatQuestionStatus.AnsweredElsewhere, @@ -489,7 +487,7 @@ class ChatQuestionTest { val listedAnswered = listedPending.copy( status = "answered", - answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))), + answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))), ) val recoveredAnswered = recoveredPending.copy(status = "answered") val failingAnswered = failingPending.copy(status = "answered") @@ -558,8 +556,7 @@ class ChatQuestionTest { .record .answers ?.answers - ?.get("meal") - ?.answers, + ?.get("meal"), ) assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_recovered").status()) assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_failing").status()) @@ -709,7 +706,7 @@ class ChatQuestionTest { val answered = pending.copy( status = "answered", - answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))), + answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))), ) var getCalls = 0 val controller = @@ -738,8 +735,7 @@ class ChatQuestionTest { .single() .record.answers ?.answers - ?.get("meal") - ?.answers, + ?.get("meal"), ) assertEquals( ChatQuestionStatus.AnsweredElsewhere, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt index a7f1516b0184..40b11fcd28ab 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt @@ -4,7 +4,6 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatMessageContent import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.gateway.QuestionAnswers -import ai.openclaw.app.gateway.QuestionAnswersAnswersValue import ai.openclaw.app.gateway.QuestionRecord import androidx.compose.runtime.saveable.SaverScope import org.junit.Assert.assertEquals @@ -326,7 +325,7 @@ class ChatReaderScrollControllerTest { answered.record.copy( answers = QuestionAnswers( - mapOf("choice" to QuestionAnswersAnswersValue(listOf("Yes"))), + mapOf("choice" to listOf("Yes")), ), ), ), diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index fe5a12b02a7f..b27b4b0bc57a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -14,7 +14,7 @@ private func makeGatewayGenerationSnapshot(version: String) -> HelloOk { features: [:], snapshot: Snapshot( presence: [], - health: OpenClawProtocol.AnyCodable([String: OpenClawProtocol.AnyCodable]()), + health: [String: OpenClawProtocol.AnyCodable](), stateversion: StateVersion(presence: 0, health: 0), uptimems: 0, configpath: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 814583add677..973bbed3bac7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -107,7 +107,7 @@ struct MacGatewayChatTransportMappingTests { @Test func `snapshot maps to health`() { let snapshot = Snapshot( presence: [], - health: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(false)]), + health: ["ok": OpenClawProtocol.AnyCodable(false)], stateversion: StateVersion(presence: 1, health: 1), uptimems: 123, configpath: nil, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift index ce3749b05d4e..c0e2f82b5a71 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift @@ -95,12 +95,12 @@ public enum OpenClawChatGatewayRequests { id: String, answers: [String: [String]]) -> OpenClawChatGatewayRequest { - let values = answers.mapValues { AnyCodable(["answers": AnyCodable($0)]) } + let values = answers.mapValues(AnyCodable.init) return OpenClawChatGatewayRequest( method: "question.resolve", params: [ "id": AnyCodable(id), - "answers": AnyCodable(["answers": AnyCodable(values)]), + "answers": AnyCodable(values), ], timeoutMs: self.mutationTimeoutMs) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift index dd9ecbccafe4..1a8f8d99c308 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift @@ -86,7 +86,7 @@ public final class OpenClawQuestionCardModel: Identifiable { } public func toggleOption(questionID: String, label: String) { - guard let question = self.record.questions.first(where: { $0.id == questionID }), + guard let question = self.record.questions.first(where: { $0.questionid == questionID }), question.options.contains(where: { $0.label == label }), self.status() == .pending else { return } @@ -109,7 +109,7 @@ public final class OpenClawQuestionCardModel: Identifiable { @discardableResult public func toggleOption(questionID: String, optionNumber: Int) -> Bool { - guard let question = self.record.questions.first(where: { $0.id == questionID }), + guard let question = self.record.questions.first(where: { $0.questionid == questionID }), self.status() == .pending, (1...4).contains(optionNumber), question.options.indices.contains(optionNumber - 1) @@ -119,7 +119,7 @@ public final class OpenClawQuestionCardModel: Identifiable { } public func setOtherText(questionID: String, value: String) { - guard let question = self.record.questions.first(where: { $0.id == questionID }), + guard let question = self.record.questions.first(where: { $0.questionid == questionID }), question.options.isEmpty || question.isother == true, self.status() == .pending else { return } @@ -164,9 +164,7 @@ public final class OpenClawQuestionCardModel: Identifiable { createdatms: self.record.createdatms, expiresatms: self.record.expiresatms, status: .answered, - answers: QuestionAnswers(answers: answers.mapValues { values in - AnyCodable(["answers": values]) - }), + answers: QuestionAnswers(answers: answers.mapValues(AnyCodable.init)), resolvedby: self.record.resolvedby) } @@ -255,9 +253,9 @@ public final class OpenClawQuestionCardModel: Identifiable { public func terminalSummaryText(for question: Question) -> String { switch self.status() { case .answered: - self.answerValues(questionID: question.id)?.joined(separator: ", ") ?? String(localized: "Answered") + self.answerValues(questionID: question.questionid)?.joined(separator: ", ") ?? String(localized: "Answered") case .answeredElsewhere: - self.answerValues(questionID: question.id)?.joined(separator: ", ") + self.answerValues(questionID: question.questionid)?.joined(separator: ", ") ?? String(localized: "Answered elsewhere") case .cancelled: String(localized: "Skipped") @@ -273,15 +271,15 @@ public final class OpenClawQuestionCardModel: Identifiable { private func answers() -> [String: [String]]? { var result: [String: [String]] = [:] for question in self.record.questions { - let selected = self.selectedOptions[question.id] ?? [] + let selected = self.selectedOptions[question.questionid] ?? [] var values = question.options.compactMap { selected.contains($0.label) ? $0.label : nil } - if let other = self.otherText[question.id]?.trimmingCharacters(in: .whitespacesAndNewlines), + if let other = self.otherText[question.questionid]?.trimmingCharacters(in: .whitespacesAndNewlines), !other.isEmpty { values.append(other) } guard !values.isEmpty else { return nil } - result[question.id] = values + result[question.questionid] = values } return result } @@ -289,14 +287,10 @@ public final class OpenClawQuestionCardModel: Identifiable { private func answerValues(questionID: String) -> [String]? { guard let answer = self.record.answers?.answers[questionID], let data = try? JSONEncoder().encode(answer), - let decoded = try? JSONDecoder().decode(ResolvedAnswer.self, from: data), - !decoded.answers.isEmpty + let decoded = try? JSONDecoder().decode([String].self, from: data), + !decoded.isEmpty else { return nil } - return decoded.answers - } - - private struct ResolvedAnswer: Codable { - let answers: [String] + return decoded } private static func recordsMatch(_ lhs: QuestionRecord, _ rhs: QuestionRecord) -> Bool { @@ -336,7 +330,7 @@ struct OpenClawQuestionCard: View { private var pendingCard: some View { TimelineView(.periodic(from: .now, by: 1)) { context in VStack(alignment: .leading, spacing: 14) { - ForEach(self.model.record.questions, id: \.id) { question in + ForEach(self.model.record.questions, id: \.questionid) { question in self.questionSection(question, now: context.date) } self.footer(now: context.date) @@ -349,7 +343,7 @@ struct OpenClawQuestionCard: View { private var terminalSummary: some View { VStack(alignment: .leading, spacing: 5) { - ForEach(self.model.record.questions, id: \.id) { question in + ForEach(self.model.record.questions, id: \.questionid) { question in HStack(alignment: .firstTextBaseline, spacing: 5) { Text(verbatim: "\(question.header):") .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout)) @@ -381,8 +375,8 @@ struct OpenClawQuestionCard: View { TextField( "Other answer", text: Binding( - get: { self.model.otherText[question.id] ?? "" }, - set: { self.model.setOtherText(questionID: question.id, value: $0) }), + get: { self.model.otherText[question.questionid] ?? "" }, + set: { self.model.setOtherText(questionID: question.questionid, value: $0) }), axis: .vertical) .font(OpenClawChatTypography.body) .textFieldStyle(.roundedBorder) @@ -392,13 +386,13 @@ struct OpenClawQuestionCard: View { } #if os(macOS) .focusable() - .focused(self.$focusedQuestionID, equals: question.id) + .focused(self.$focusedQuestionID, equals: question.questionid) .onKeyPress(characters: .decimalDigits) { keyPress in - guard self.focusedQuestionID == question.id else { return .ignored } + guard self.focusedQuestionID == question.questionid else { return .ignored } return self.handleNumberKey(keyPress, question: question, now: now) } .onKeyPress(.return) { - guard self.focusedQuestionID == question.id, + guard self.focusedQuestionID == question.questionid, self.model.status(at: now) == .pending, self.model.canSubmit else { return .ignored } @@ -409,12 +403,12 @@ struct OpenClawQuestionCard: View { } private func optionRow(question: Question, option: QuestionOption, now: Date) -> some View { - let selected = self.model.selectedOptions[question.id]?.contains(option.label) == true + let selected = self.model.selectedOptions[question.questionid]?.contains(option.label) == true return Button { #if os(macOS) - self.focusedQuestionID = question.id + self.focusedQuestionID = question.questionid #endif - self.model.toggleOption(questionID: question.id, label: option.label) + self.model.toggleOption(questionID: question.questionid, label: option.label) } label: { HStack(alignment: .top, spacing: 10) { Image(systemName: selected @@ -498,7 +492,7 @@ struct OpenClawQuestionCard: View { { guard self.model.status(at: now) == .pending, let digit = keyPress.characters.first?.wholeNumberValue, - self.model.toggleOption(questionID: question.id, optionNumber: digit) + self.model.toggleOption(questionID: question.questionid, optionNumber: digit) else { return .ignored } return .handled } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5b2c07c64b7f..b66650f84f5d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1048,7 +1048,7 @@ public struct StateVersion: Codable, Sendable { public struct Snapshot: Codable, Sendable { public let presence: [PresenceEntry] - public let health: AnyCodable + public let health: [String: AnyCodable] public let stateversion: StateVersion public let uptimems: Int public let appliedconfighash: AnyCodable? @@ -1060,7 +1060,7 @@ public struct Snapshot: Codable, Sendable { public init( presence: [PresenceEntry], - health: AnyCodable, + health: [String: AnyCodable], stateversion: StateVersion, uptimems: Int, appliedconfighash: AnyCodable? = nil, @@ -3779,7 +3779,7 @@ public struct SessionCatalogSession: Codable, Sendable { public let cliversion: String? public let gitbranch: String? public let archived: Bool - public let openclawsessionkey: String? + public let sessionkey: String? public let cancontinue: Bool public let canarchive: Bool public let canopenterminal: Bool? @@ -3797,7 +3797,7 @@ public struct SessionCatalogSession: Codable, Sendable { cliversion: String? = nil, gitbranch: String? = nil, archived: Bool, - openclawsessionkey: String? = nil, + sessionkey: String? = nil, cancontinue: Bool, canarchive: Bool, canopenterminal: Bool? = nil) @@ -3814,7 +3814,7 @@ public struct SessionCatalogSession: Codable, Sendable { self.cliversion = cliversion self.gitbranch = gitbranch self.archived = archived - self.openclawsessionkey = openclawsessionkey + self.sessionkey = sessionkey self.cancontinue = cancontinue self.canarchive = canarchive self.canopenterminal = canopenterminal @@ -3833,7 +3833,7 @@ public struct SessionCatalogSession: Codable, Sendable { case cliversion = "cliVersion" case gitbranch = "gitBranch" case archived - case openclawsessionkey = "openClawSessionKey" + case sessionkey = "sessionKey" case cancontinue = "canContinue" case canarchive = "canArchive" case canopenterminal = "canOpenTerminal" @@ -3952,39 +3952,39 @@ public struct SessionCatalogTranscriptItem: Codable, Sendable { public struct SessionsCatalogListParams: Codable, Sendable { public let catalogid: String? + public let cursors: [String: AnyCodable]? public let agentid: String? public let progressid: String? public let search: String? public let limitperhost: Int? public let hostids: [String]? - public let cursors: [String: AnyCodable]? public init( catalogid: String? = nil, + cursors: [String: AnyCodable]? = nil, agentid: String? = nil, progressid: String? = nil, search: String? = nil, limitperhost: Int? = nil, - hostids: [String]? = nil, - cursors: [String: AnyCodable]? = nil) + hostids: [String]? = nil) { self.catalogid = catalogid + self.cursors = cursors self.agentid = agentid self.progressid = progressid self.search = search self.limitperhost = limitperhost self.hostids = hostids - self.cursors = cursors } private enum CodingKeys: String, CodingKey { case catalogid = "catalogId" + case cursors case agentid = "agentId" case progressid = "progressId" case search case limitperhost = "limitPerHost" case hostids = "hostIds" - case cursors } } @@ -13525,7 +13525,7 @@ public struct QuestionOption: Codable, Sendable { } public struct Question: Codable, Sendable { - public let id: String + public let questionid: String public let header: String public let question: String public let options: [QuestionOption] @@ -13534,7 +13534,7 @@ public struct Question: Codable, Sendable { public let issecret: Bool? public init( - id: String, + questionid: String, header: String, question: String, options: [QuestionOption], @@ -13542,7 +13542,7 @@ public struct Question: Codable, Sendable { isother: Bool? = nil, issecret: Bool? = nil) { - self.id = id + self.questionid = questionid self.header = header self.question = question self.options = options @@ -13552,7 +13552,7 @@ public struct Question: Codable, Sendable { } private enum CodingKeys: String, CodingKey { - case id + case questionid = "questionId" case header case question case options @@ -13563,7 +13563,7 @@ public struct Question: Codable, Sendable { } public struct QuestionRequestQuestion: Codable, Sendable { - public let id: String + public let questionid: String public let header: String public let question: String public let options: [QuestionOption] @@ -13572,7 +13572,7 @@ public struct QuestionRequestQuestion: Codable, Sendable { public let issecret: Bool? public init( - id: String, + questionid: String, header: String, question: String, options: [QuestionOption], @@ -13580,7 +13580,7 @@ public struct QuestionRequestQuestion: Codable, Sendable { isother: Bool? = nil, issecret: Bool? = nil) { - self.id = id + self.questionid = questionid self.header = header self.question = question self.options = options @@ -13590,7 +13590,7 @@ public struct QuestionRequestQuestion: Codable, Sendable { } private enum CodingKeys: String, CodingKey { - case id + case questionid = "questionId" case header case question case options diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index 6f12c5d5ccd4..f4de5b4de8e1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -239,7 +239,7 @@ struct ChatGatewayRequestTests { #expect(String(decoding: encoded, as: UTF8.self).contains("a.png")) } - @Test func `question resolve request preserves nested answer contract`() throws { + @Test func `question resolve request preserves answer arrays`() throws { let request = OpenClawChatGatewayRequests.resolveQuestion( id: "ask_123", answers: ["meal": ["Pizza", "Salad"]]) @@ -248,9 +248,7 @@ struct ChatGatewayRequestTests { let data = try JSONEncoder().encode(request.params) let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) let answers = try #require(object["answers"] as? [String: Any]) - let values = try #require(answers["answers"] as? [String: Any]) - let meal = try #require(values["meal"] as? [String: Any]) - #expect(meal["answers"] as? [String] == ["Pizza", "Salad"]) + #expect(answers["meal"] as? [String] == ["Pizza", "Salad"]) } @Test func `question get request carries id`() { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift index 5afe25e5ccb6..1122b87f1ddb 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatQuestionCardTests.swift @@ -16,7 +16,7 @@ private func questionRecord( id: "ask_123", questions: [ Question( - id: "meal", + questionid: "meal", header: "Meal", question: "Choose dinner", options: [ @@ -145,7 +145,7 @@ private func questionRecord( let data = try JSONEncoder().encode(model.record.answers) let json = try #require(String(data: data, encoding: .utf8)) - #expect(json.contains("\"meal\":{\"answers\":[\"Pizza\"]}")) + #expect(json.contains("\"meal\":[\"Pizza\"]")) #expect(model.terminalSummaryText(for: model.record.questions[0]) == "Pizza") } @@ -173,7 +173,7 @@ private func questionRecord( @MainActor @Test func `question card terminal summaries prefer resolved answers`() { let answers = QuestionAnswers(answers: [ - "meal": AnyCodable(["answers": ["Pizza", "extra hot"]]), + "meal": AnyCodable(["Pizza", "extra hot"]), ]) let answered = OpenClawQuestionCardModel(record: questionRecord(status: .answered, answers: answers)) let question = answered.record.questions[0] diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index a9ee94bbd0b7..a54aa180aec1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -1325,7 +1325,7 @@ private func chatQuestionRecord( id: id, questions: [ Question( - id: "choice", + questionid: "choice", header: "Choice", question: "Choose", options: [QuestionOption(label: "One"), QuestionOption(label: "Two")]), @@ -1494,7 +1494,7 @@ struct ChatViewModelTests { @Test @MainActor func `missing pending question uses question get fallback`() async { let answers = QuestionAnswers(answers: [ - "choice": AnyCodable(["answers": ["Two"]]), + "choice": AnyCodable(["Two"]), ]) let transport = TestChatTransport( historyResponses: [], diff --git a/config/knip.config.ts b/config/knip.config.ts index 7dffa0d7caab..5ce800eacc01 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -68,6 +68,8 @@ const repositoryScriptEntries = [ // Oxlint loads this JS plugin by path from config/oxlint/boundary-guards.json. "scripts/oxlint-boundary-guards.mjs!", "scripts/plugin-prerelease-liveish-matrix.mjs!", + // Generates the checked-in native protocol models from core descriptor metadata. + "scripts/protocol-gen.ts!", "scripts/pr-gates-lock.mjs!", "scripts/pr-lib/ci-dispatch.mjs!", "scripts/pr-lib/review-artifacts.mjs!", diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 366f2b874bd6..caf37717bfe5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -272,7 +272,7 @@ e9c4398b04d04fead0e46ec618589d8f597a0b6087805ab132138e941b76e190 module/sandbox 596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime 50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup 250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime -e3cfdf7ff5181ecd517bcb965c11f78b8363c8adf9f7f53f18bdbb5e0ceca1a6 module/session-catalog +ed35a448c8f7650acb34739a591334267371f1ffe352505fc2808330d78580bd module/session-catalog f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime b1d0a76337122cb9dbb0c89fbb8bfacc1a88ce689270d3d684019c9a03ce669a module/session-transcript-hit diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index c00ec7e3b548..6b6b12768a6e 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -77,7 +77,7 @@ function isNodeSession(value: unknown): value is SessionCatalogSession { isOptionalString(value.modelProvider) && isOptionalString(value.cliVersion) && isOptionalString(value.gitBranch) && - isOptionalString(value.openClawSessionKey) && + isOptionalString(value.sessionKey) && isOptionalNumber(value.createdAt) && isOptionalNumber(value.updatedAt) && isOptionalNumber(value.recencyAt) diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index fcfd006a4d73..312ed5f371a1 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -511,7 +511,7 @@ describe("Claude session catalog", () => { registerClaudeSessionCatalog(api); const hosts = await provider?.list({}); - expect(hosts?.[0]?.sessions[0]?.openClawSessionKey).toBe("agent:main:claude-bound"); + expect(hosts?.[0]?.sessions[0]?.sessionKey).toBe("agent:main:claude-bound"); }); it("continues a local Desktop-app row and lists it as continuable", async () => { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index 63b0d892813e..c505116e4261 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -1449,7 +1449,7 @@ function toGenericClaudeHost( ...(session.cliVersion ? { cliVersion: session.cliVersion } : {}), ...(session.gitBranch ? { gitBranch: session.gitBranch } : {}), archived: session.archived, - ...(continuable && existingSessionKey ? { openClawSessionKey: existingSessionKey } : {}), + ...(continuable && existingSessionKey ? { sessionKey: existingSessionKey } : {}), canContinue: continuable, canArchive: false, canOpenTerminal: terminal.canOpenTerminal, diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 99c6d01fdd1c..b2f4f20680cf 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -365,6 +365,7 @@ export async function runCodexAppServerSideQuestion( let turnId: string | undefined; let removeRequestHandler: (() => void) | undefined; let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined; + const activeDynamicToolCalls = new Set>(); try { const modelScopedAppServer = resolveCodexAppServerForModelProvider({ @@ -484,14 +485,16 @@ export async function runCodexAppServerSideQuestion( sessionKey: params.sessionKey, }; emitDynamicToolStartedDiagnostic(diagnosticContext); + const toolCall = handleDynamicToolCallWithTimeout({ + call, + toolBridge, + signal: runAbortController.signal, + timeoutMs, + observeToolTerminal: sideRunParams.observeToolTerminal, + }); + activeDynamicToolCalls.add(toolCall); try { - const response = await handleDynamicToolCallWithTimeout({ - call, - toolBridge, - signal: runAbortController.signal, - timeoutMs, - observeToolTerminal: sideRunParams.observeToolTerminal, - }); + const response = await toolCall; emitDynamicToolTerminalDiagnostic({ ...diagnosticContext, response, @@ -510,6 +513,8 @@ export async function runCodexAppServerSideQuestion( : "failed", }); throw error; + } finally { + activeDynamicToolCalls.delete(toolCall); } }); removeRequestHandler = registerRequestHandler(client); @@ -730,6 +735,10 @@ export async function runCodexAppServerSideQuestion( if (!runAbortController.signal.aborted) { runAbortController.abort("codex_side_question_finished"); } + // Request handlers can still be finishing after the terminal turn event. + // Drain their abort races before unsubscribe so late diagnostics cannot leak + // into the next side run. + await Promise.allSettled(activeDynamicToolCalls); try { await cleanupCodexSideThread(client, { threadId: childThreadId, diff --git a/extensions/codex/src/app-server/user-input-bridge.test.ts b/extensions/codex/src/app-server/user-input-bridge.test.ts index 2aa1c03f4c9d..0ae78c5d6b20 100644 --- a/extensions/codex/src/app-server/user-input-bridge.test.ts +++ b/extensions/codex/src/app-server/user-input-bridge.test.ts @@ -37,7 +37,7 @@ function createGatewayStub() { } if (method === "question.resolve") { const resolveParams = params as { - answers?: { answers: Record }; + answers?: { answers: Record }; cancel?: boolean; }; const result = resolveParams.cancel @@ -95,7 +95,7 @@ describe("Codex app-server user input bridge", () => { sessionKey: "agent:main:session-1", agentId: "main", timeoutMs: 90_000, - questions: requestParams().questions, + questions: [expect.objectContaining({ questionId: "choice" })], }); const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0]; expect(payload.channelData).toEqual({ @@ -247,7 +247,7 @@ describe("Codex app-server user input bridge", () => { await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); expect(gateway.calls[0]?.params).toMatchObject({ timeoutMs: 60_000, - questions: [expect.objectContaining({ id: "notes", options: [] })], + questions: [expect.objectContaining({ questionId: "notes", options: [] })], }); await claimPendingAgentQuestionAnswer({ sessionKey: params.sessionKey, text: "Refactor it" }); await expect(response).resolves.toEqual({ diff --git a/extensions/codex/src/app-server/user-input-bridge.ts b/extensions/codex/src/app-server/user-input-bridge.ts index 820e92c1a3c9..536a597b98a3 100644 --- a/extensions/codex/src/app-server/user-input-bridge.ts +++ b/extensions/codex/src/app-server/user-input-bridge.ts @@ -153,7 +153,7 @@ export function createCodexUserInputBridge(params: { signal: abort.signal, }); return result.status === "answered" - ? (result.answers as unknown as JsonObject) + ? gatewayAnswersToCodexResponse(result.answers.answers) : emptyUserInputResponse(); } catch (error) { embeddedAgentLog.warn("failed to bridge codex user input through gateway", { error }); @@ -291,6 +291,14 @@ function buildUserInputResponse( return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject; } +function gatewayAnswersToCodexResponse(answers: Record): JsonObject { + return { + answers: Object.fromEntries( + Object.entries(answers).map(([questionId, values]) => [questionId, { answers: values }]), + ), + }; +} + function emptyUserInputResponse(): JsonObject { return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject; } diff --git a/extensions/codex/src/session-catalog-node-continue.ts b/extensions/codex/src/session-catalog-node-continue.ts index 5dfaeda33b6f..2ed69d988b1d 100644 --- a/extensions/codex/src/session-catalog-node-continue.ts +++ b/extensions/codex/src/session-catalog-node-continue.ts @@ -126,9 +126,7 @@ export async function listPairedNode(params: { ...page, sessions: page.sessions.map((session) => { const adopted = params.adoptedSessions.get(adoptedSourceKey(hostId, session.threadId)); - return adopted - ? Object.assign({}, session, { openClawSessionKey: adopted.key }) - : session; + return adopted ? Object.assign({}, session, { sessionKey: adopted.key }) : session; }), }; }) diff --git a/extensions/codex/src/session-catalog-parsing.ts b/extensions/codex/src/session-catalog-parsing.ts index ed2c1c21437d..54fa17d2eef1 100644 --- a/extensions/codex/src/session-catalog-parsing.ts +++ b/extensions/codex/src/session-catalog-parsing.ts @@ -313,7 +313,7 @@ function parseOptionalCatalogString( function parseCatalogSession( value: unknown, - options: { allowOpenClawSessionKey?: boolean } = {}, + options: { allowSessionKey?: boolean } = {}, ): CodexSessionCatalogSession { if ( !isRecord(value) || @@ -365,12 +365,8 @@ function parseCatalogSession( MAX_METADATA_LENGTH, ); const gitBranch = parseOptionalCatalogString(value.gitBranch, "Git branch", MAX_METADATA_LENGTH); - const openClawSessionKey = options.allowOpenClawSessionKey - ? parseOptionalCatalogString( - value.openClawSessionKey, - "OpenClaw session key", - MAX_SESSION_KEY_LENGTH, - ) + const sessionKey = options.allowSessionKey + ? parseOptionalCatalogString(value.sessionKey, "OpenClaw session key", MAX_SESSION_KEY_LENGTH) : undefined; const createdAt = readFiniteNumber(value.createdAt); const updatedAt = readFiniteNumber(value.updatedAt); @@ -390,13 +386,13 @@ function parseCatalogSession( ...(modelProvider !== undefined ? { modelProvider } : {}), ...(cliVersion !== undefined ? { cliVersion } : {}), ...(gitBranch !== undefined ? { gitBranch } : {}), - ...(openClawSessionKey !== undefined ? { openClawSessionKey } : {}), + ...(sessionKey !== undefined ? { sessionKey } : {}), }; } export function parseCatalogPage( value: unknown, - options: { allowOpenClawSessionKey?: boolean } = {}, + options: { allowSessionKey?: boolean } = {}, ): CodexSessionCatalogPage { if ( !isRecord(value) || diff --git a/extensions/codex/src/session-catalog-types.ts b/extensions/codex/src/session-catalog-types.ts index 2338faf9ccc8..4b4ab986df80 100644 --- a/extensions/codex/src/session-catalog-types.ts +++ b/extensions/codex/src/session-catalog-types.ts @@ -24,7 +24,7 @@ export type CodexSessionCatalogSession = { cliVersion?: string; gitBranch?: string; /** Existing locked OpenClaw chat already mapped to this native source thread. */ - openClawSessionKey?: string; + sessionKey?: string; archived: boolean; }; diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 9275f69eaf6b..cf22fd321ee2 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -1367,7 +1367,7 @@ describe("Codex supervision catalog", () => { expect(result.hosts[0]?.sessions[0]).toMatchObject({ threadId: "source-thread", - openClawSessionKey: sessionKey, + sessionKey, }); expect(result.hosts[1]?.sessions[0]).toEqual({ threadId: "source-thread", @@ -1403,7 +1403,7 @@ describe("Codex supervision catalog", () => { const result = await listCodexSessionCatalog({ bindingStore, config, runtime, control }); - expect(result.hosts[0]?.sessions[0]).not.toHaveProperty("openClawSessionKey"); + expect(result.hosts[0]?.sessions[0]).not.toHaveProperty("sessionKey"); }); it("ignores a public marker retarget and trusts the private source binding", async () => { @@ -1440,7 +1440,7 @@ describe("Codex supervision catalog", () => { threadId: "source-thread", status: "idle", archived: false, - openClawSessionKey: sessionKey, + sessionKey, }, { threadId: "forged-thread", status: "idle", archived: false }, ]); @@ -1497,7 +1497,7 @@ describe("Codex supervision catalog", () => { (candidate) => candidate.threadId === source.threadId, ); expect(session).toBeDefined(); - expect(session).not.toHaveProperty("openClawSessionKey"); + expect(session).not.toHaveProperty("sessionKey"); } for (const source of sources) { await expect( @@ -1735,7 +1735,7 @@ describe("Codex supervision actions", () => { expect(createSessionEntry).toHaveBeenCalledWith(expect.objectContaining({ agentId: "alpha" })); expect(catalog.hosts[0]?.sessions[0]).toMatchObject({ threadId: "thread-1", - openClawSessionKey: created.sessionKey, + sessionKey: created.sessionKey, }); }); @@ -1765,7 +1765,7 @@ describe("Codex supervision actions", () => { }); const duringImport = await listCodexSessionCatalog({ bindingStore, config, runtime, control }); - expect(duringImport.hosts[0]?.sessions[0]).not.toHaveProperty("openClawSessionKey"); + expect(duringImport.hosts[0]?.sessions[0]).not.toHaveProperty("sessionKey"); expect(entries[0]?.entry.initializationPending).toBe(true); let secondSettled = false; const secondContinue = continueLocalCodexSession({ @@ -3062,7 +3062,7 @@ describe("Codex supervision actions", () => { clientScopes: ["operator.admin"], }); const pendingList = await provider?.list({ hostIds: ["node:devbox"] }); - expect(pendingList?.[0]?.sessions[0]?.openClawSessionKey).toBeUndefined(); + expect(pendingList?.[0]?.sessions[0]?.sessionKey).toBeUndefined(); await first?.afterConversationBound?.(); runtimeConfig = { agents: { list: [{ id: "alpha" }, { id: "beta", default: true }] }, @@ -3129,7 +3129,7 @@ describe("Codex supervision actions", () => { const listed = await provider?.list({ hostIds: ["node:devbox"] }); expect(listed?.[0]?.sessions[0]).toMatchObject({ threadId: "thread-remote", - openClawSessionKey: first?.sessionKey, + sessionKey: first?.sessionKey, }); }); diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index 919513206310..5f499a357e2d 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -391,7 +391,7 @@ async function listGatewayHost(params: { ...page, sessions: page.sessions.map((session) => { const adopted = adoptedSessions.get(session.threadId); - return adopted ? Object.assign({}, session, { openClawSessionKey: adopted.key }) : session; + return adopted ? Object.assign({}, session, { sessionKey: adopted.key }) : session; }), }; } catch (error) { @@ -1230,7 +1230,7 @@ function toGenericCatalogHost( ...(session.cliVersion ? { cliVersion: session.cliVersion } : {}), ...(session.gitBranch ? { gitBranch: session.gitBranch } : {}), archived: session.archived, - ...(session.openClawSessionKey ? { openClawSessionKey: session.openClawSessionKey } : {}), + ...(session.sessionKey ? { sessionKey: session.sessionKey } : {}), canContinue, canArchive, canOpenTerminal, diff --git a/extensions/copilot/src/attempt.ts b/extensions/copilot/src/attempt.ts index e36a07b37169..0f8583bd1ef7 100644 --- a/extensions/copilot/src/attempt.ts +++ b/extensions/copilot/src/attempt.ts @@ -1157,12 +1157,12 @@ export async function runCopilotAttempt( // its own private files; OpenClaw-side audit state is addressed only by // session identity so missing identity cannot silently recreate JSONL state. const openClawSessionIdForMirror = readString(input.sessionId); - const openClawSessionKeyForMirror = readString((input as { sessionKey?: unknown }).sessionKey); + const sessionKeyForMirror = readString((input as { sessionKey?: unknown }).sessionKey); const openClawStorePathForMirror = readString(input.sessionTarget?.storePath); const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror; if ( openClawSessionIdForMirror && - openClawSessionKeyForMirror && + sessionKeyForMirror && openClawStorePathForMirror && messagesSnapshot.length > 0 ) { @@ -1191,7 +1191,7 @@ export async function runCopilotAttempt( }); await dualWriteCopilotTranscriptBestEffort({ sessionId: openClawSessionIdForMirror, - sessionKey: openClawSessionKeyForMirror, + sessionKey: sessionKeyForMirror, agentId: readString(input.agentId), storePath: openClawStorePathForMirror, messages: taggedMessages, diff --git a/extensions/copilot/src/user-input-bridge.test.ts b/extensions/copilot/src/user-input-bridge.test.ts index 92d55ed800d7..3819d67c75b9 100644 --- a/extensions/copilot/src/user-input-bridge.test.ts +++ b/extensions/copilot/src/user-input-bridge.test.ts @@ -35,7 +35,7 @@ function createGatewayStub() { } if (method === "question.resolve") { const resolved = params as { - answers?: { answers: Record }; + answers?: { answers: Record }; cancel?: boolean; }; const result = resolved.cancel @@ -72,7 +72,10 @@ describe("Copilot user input bridge", () => { agentId: "main", timeoutMs: 75_000, questions: [ - expect.objectContaining({ id: "answer", options: [{ label: "Fast" }, { label: "Deep" }] }), + expect.objectContaining({ + questionId: "answer", + options: [{ label: "Fast" }, { label: "Deep" }], + }), ], }); const payload = vi.mocked(params.onBlockReply!).mock.calls[0]![0]; diff --git a/extensions/copilot/src/user-input-bridge.ts b/extensions/copilot/src/user-input-bridge.ts index 2b8e1543c4f1..3266f5b09920 100644 --- a/extensions/copilot/src/user-input-bridge.ts +++ b/extensions/copilot/src/user-input-bridge.ts @@ -56,7 +56,7 @@ export function createCopilotUserInputBridge(params: { if (result.status !== "answered") { return emptyCopilotUserInputResponse(); } - const selected = result.answers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? ""; + const selected = result.answers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.[0] ?? ""; return { answer: selected, wasFreeform: !isChoiceAnswer(question, selected), diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index 714c460ad952..d73548d89bee 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -71,7 +71,7 @@ function isNodeSession(value: unknown): value is SessionCatalogSession { isOptionalString(value.modelProvider) && isOptionalString(value.cliVersion) && isOptionalString(value.gitBranch) && - isOptionalString(value.openClawSessionKey) && + isOptionalString(value.sessionKey) && isOptionalNumber(value.createdAt) && isOptionalNumber(value.updatedAt) && isOptionalNumber(value.recencyAt) diff --git a/package.json b/package.json index 569a23f7da10..252dc8122a91 100644 --- a/package.json +++ b/package.json @@ -1778,7 +1778,7 @@ "prompt:snapshots:check": "node --import tsx scripts/generate-prompt-snapshots.ts --check", "prompt:snapshots:gen": "node --import tsx scripts/generate-prompt-snapshots.ts --write", "prompt:snapshots:sync-codex-model": "node --import tsx scripts/sync-codex-model-prompt-fixture.ts", - "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin && git diff --exit-code -- dist/protocol.schema.json apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt", + "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin && node scripts/check-protocol-since.mjs && git diff --exit-code -- dist/protocol.schema.json apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt", "protocol:check:kotlin": "pnpm protocol:gen:kotlin && git diff --exit-code -- apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:kotlin": "node --import tsx scripts/protocol-gen-kotlin.ts", diff --git a/packages/gateway-protocol/package.json b/packages/gateway-protocol/package.json index 5697d0fd7499..902fbc11b102 100644 --- a/packages/gateway-protocol/package.json +++ b/packages/gateway-protocol/package.json @@ -5,6 +5,7 @@ "files": [ "dist" ], + "sideEffects": false, "type": "module", "main": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/gateway-protocol/src/approvals-validators.test.ts b/packages/gateway-protocol/src/approvals-validators.test.ts index 58476cddd832..caab061e2d38 100644 --- a/packages/gateway-protocol/src/approvals-validators.test.ts +++ b/packages/gateway-protocol/src/approvals-validators.test.ts @@ -1,21 +1,12 @@ import { describe, expect, it } from "vitest"; import { - validateApprovalAllowDecision, validateApprovalGetParams, validateApprovalGetResult, validateApprovalHistoryParams, validateApprovalHistoryResult, - validateApprovalDecision, - validateApprovalKind, validateApprovalPresentation, validateApprovalResolveParams, validateApprovalResolveResult, - validateApprovalSnapshot, - validateApprovalTerminalReason, - validateExecApprovalPresentation, - validatePluginApprovalPresentation, - validatePluginApprovalSeverity, - validateTerminalApprovalSnapshot, } from "./index.js"; const execPresentation = { @@ -66,41 +57,14 @@ const pluginRecord = { } as const; describe("unified approval protocol validators", () => { - it("keeps approval kinds and decisions closed", () => { - expect(validateApprovalKind("exec")).toBe(true); - expect(validateApprovalKind("plugin")).toBe(true); - expect(validateApprovalKind("system-agent")).toBe(true); - expect(validateApprovalKind("tool")).toBe(false); - expect(validateApprovalDecision("deny")).toBe(true); - expect(validateApprovalDecision("accept")).toBe(false); - expect(validateApprovalAllowDecision("allow-once")).toBe(true); - expect(validateApprovalAllowDecision("deny")).toBe(false); - for (const reason of [ - "user", - "timeout", - "malformed-verdict", - "no-route", - "run-aborted", - "gateway-restart", - "storage-corrupt", - ] as const) { - expect(validateApprovalTerminalReason(reason)).toBe(true); - } - expect(validateApprovalTerminalReason("reviewer-decision")).toBe(false); - expect(validatePluginApprovalSeverity("critical")).toBe(true); - expect(validatePluginApprovalSeverity("blocker")).toBe(false); - }); - it("accepts only reviewer-safe approval presentations", () => { - expect(validateExecApprovalPresentation(execPresentation)).toBe(true); - expect(validatePluginApprovalPresentation(pluginPresentation)).toBe(true); expect(validateApprovalPresentation(execPresentation)).toBe(true); expect(validateApprovalPresentation(pluginPresentation)).toBe(true); expect(validateApprovalPresentation(systemAgentPresentation)).toBe(true); for (const forbiddenField of ["cwd", "env", "systemRunBinding", "systemRunPlan"] as const) { expect( - validateExecApprovalPresentation({ + validateApprovalPresentation({ ...execPresentation, [forbiddenField]: forbiddenField === "env" ? { TOKEN: "secret" } : "private", }), @@ -110,13 +74,13 @@ describe("unified approval protocol validators", () => { it("keeps deny available on every presentation and resolve request", () => { expect( - validateExecApprovalPresentation({ + validateApprovalPresentation({ ...execPresentation, allowedDecisions: ["allow-once"], }), ).toBe(false); expect( - validatePluginApprovalPresentation({ + validateApprovalPresentation({ ...pluginPresentation, allowedDecisions: ["allow-always"], }), @@ -160,19 +124,22 @@ describe("unified approval protocol validators", () => { } as const; for (const snapshot of [pending, allowed, denied, expired, cancelled]) { - expect(validateApprovalSnapshot(snapshot)).toBe(true); expect(validateApprovalGetResult({ approval: snapshot })).toBe(true); } - expect(validateApprovalSnapshot({ ...allowed, decision: "deny" })).toBe(false); - expect(validateApprovalSnapshot({ ...allowed, resolvedBy: "device:phone" })).toBe(false); - expect(validateApprovalSnapshot({ ...denied, reason: "" })).toBe(false); - expect(validateApprovalSnapshot({ ...expired, decision: "deny" })).toBe(false); + expect(validateApprovalGetResult({ approval: { ...allowed, decision: "deny" } })).toBe(false); expect( - validateApprovalSnapshot({ - ...execRecord, - presentation: { ...execPresentation, kind: "plugin" }, - status: "pending", + validateApprovalGetResult({ approval: { ...allowed, resolvedBy: "device:phone" } }), + ).toBe(false); + expect(validateApprovalGetResult({ approval: { ...denied, reason: "" } })).toBe(false); + expect(validateApprovalGetResult({ approval: { ...expired, decision: "deny" } })).toBe(false); + expect( + validateApprovalGetResult({ + approval: { + ...execRecord, + presentation: { ...execPresentation, kind: "plugin" }, + status: "pending", + }, }), ).toBe(false); }); @@ -204,17 +171,21 @@ describe("unified approval protocol validators", () => { expect(validateApprovalGetParams({ id: "approval:🦞/percent%" })).toBe(true); expect( - validateApprovalSnapshot({ - ...execRecord, - status: "pending", - sourceSessionKey: "agent:worker:subagent:123", + validateApprovalGetResult({ + approval: { + ...execRecord, + status: "pending", + sourceSessionKey: "agent:worker:subagent:123", + }, }), ).toBe(false); expect( - validateApprovalSnapshot({ - ...execRecord, - status: "pending", - audienceSessionKeys: ["agent:worker:subagent:123", "agent:main"], + validateApprovalGetResult({ + approval: { + ...execRecord, + status: "pending", + audienceSessionKeys: ["agent:worker:subagent:123", "agent:main"], + }, }), ).toBe(false); }); @@ -256,7 +227,6 @@ describe("unified approval protocol validators", () => { expect(validateApprovalResolveResult({ applied: true, approval: recorded })).toBe(true); expect(validateApprovalResolveResult({ applied: false, approval: recorded })).toBe(true); - expect(validateTerminalApprovalSnapshot(recorded)).toBe(true); expect( validateApprovalResolveResult({ applied: false, diff --git a/packages/gateway-protocol/src/clawhub-trust-error-details.ts b/packages/gateway-protocol/src/clawhub-trust-error-details.ts index 353ac71210c4..25bae21f387e 100644 --- a/packages/gateway-protocol/src/clawhub-trust-error-details.ts +++ b/packages/gateway-protocol/src/clawhub-trust-error-details.ts @@ -5,8 +5,7 @@ export const ClawHubTrustErrorCodes = { DOWNLOAD_BLOCKED: "clawhub_download_blocked", } as const; -export type ClawHubTrustErrorCode = - (typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes]; +type ClawHubTrustErrorCode = (typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes]; export type ClawHubTrustErrorDetails = { clawhubTrustCode?: ClawHubTrustErrorCode; diff --git a/packages/gateway-protocol/src/connect-error-details.test.ts b/packages/gateway-protocol/src/connect-error-details.test.ts index bdfc921f1d6d..cabb53d85580 100644 --- a/packages/gateway-protocol/src/connect-error-details.test.ts +++ b/packages/gateway-protocol/src/connect-error-details.test.ts @@ -4,7 +4,6 @@ import { buildPairingConnectCloseReason, buildPairingConnectErrorDetails, buildPairingConnectErrorMessage, - ConnectPairingRequiredReasons, describePairingConnectRequirement, formatConnectErrorMessage, formatConnectPairingRequiredMessage, @@ -76,18 +75,16 @@ describe("resolveAuthConnectErrorDetailCode", () => { describe("pairing connect details", () => { it("builds reason-specific pairing messages", () => { - expect(buildPairingConnectErrorMessage(ConnectPairingRequiredReasons.SCOPE_UPGRADE)).toBe( + expect(buildPairingConnectErrorMessage("scope-upgrade")).toBe( "pairing required: device is asking for more scopes than currently approved", ); - expect(describePairingConnectRequirement(ConnectPairingRequiredReasons.NOT_PAIRED)).toBe( - "device is not approved yet", - ); + expect(describePairingConnectRequirement("not-paired")).toBe("device is not approved yet"); }); it("builds structured pairing details with remediation", () => { expect( buildPairingConnectErrorDetails({ - reason: ConnectPairingRequiredReasons.NOT_PAIRED, + reason: "not-paired", requestId: "req-123", recommendedNextStep: "wait_then_retry", retryable: true, @@ -122,7 +119,7 @@ describe("pairing connect details", () => { it("includes request ids in close reasons when available", () => { expect( buildPairingConnectCloseReason({ - reason: ConnectPairingRequiredReasons.ROLE_UPGRADE, + reason: "role-upgrade", requestId: "req-789", }), ).toBe( diff --git a/packages/gateway-protocol/src/connect-error-details.ts b/packages/gateway-protocol/src/connect-error-details.ts index d87b73b5524a..aa32d6b94da4 100644 --- a/packages/gateway-protocol/src/connect-error-details.ts +++ b/packages/gateway-protocol/src/connect-error-details.ts @@ -57,11 +57,11 @@ export const ConnectErrorDetailCodes = { CLIENT_VERSION_MISMATCH: "CLIENT_VERSION_MISMATCH", } as const; -export type ConnectErrorDetailCode = +type ConnectErrorDetailCode = (typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes]; /** Pairing-specific reasons clients can display and use for reconnect policy. */ -export const ConnectPairingRequiredReasons = { +const ConnectPairingRequiredReasons = { NOT_PAIRED: "not-paired", ROLE_UPGRADE: "role-upgrade", SCOPE_UPGRADE: "scope-upgrade", @@ -72,7 +72,7 @@ export type ConnectPairingRequiredReason = (typeof ConnectPairingRequiredReasons)[keyof typeof ConnectPairingRequiredReasons]; /** Suggested client-side recovery action for structured connect errors. */ -export type ConnectRecoveryNextStep = +type ConnectRecoveryNextStep = | "retry_with_device_token" | "update_auth_configuration" | "update_auth_credentials" @@ -80,13 +80,13 @@ export type ConnectRecoveryNextStep = | "review_auth_configuration"; /** Optional retry guidance extracted from gateway connect-error details. */ -export type ConnectErrorRecoveryAdvice = { +type ConnectErrorRecoveryAdvice = { canRetryWithDeviceToken?: boolean; recommendedNextStep?: ConnectRecoveryNextStep; }; /** Full structured details for pairing-required connect failures. */ -export type PairingConnectErrorDetails = { +type PairingConnectErrorDetails = { code: typeof ConnectErrorDetailCodes.PAIRING_REQUIRED; reason?: ConnectPairingRequiredReason; requestId?: string; diff --git a/packages/gateway-protocol/src/gateway-suspend.test.ts b/packages/gateway-protocol/src/gateway-suspend.test.ts index e0e33ca4da31..9f6ab3ebeff5 100644 --- a/packages/gateway-protocol/src/gateway-suspend.test.ts +++ b/packages/gateway-protocol/src/gateway-suspend.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - validateGatewaySuspendPrepareParams, - validateGatewaySuspendPrepareResult, - validateGatewaySuspendResumeResult, - validateGatewaySuspendStatusResult, -} from "./index.js"; +import { validateGatewaySuspendPrepareParams } from "./index.js"; describe("gateway suspension protocol", () => { it("keeps prepare params closed and bounded", () => { @@ -14,90 +9,4 @@ describe("gateway suspension protocol", () => { false, ); }); - - it("validates busy and ready prepare results", () => { - expect( - validateGatewaySuspendPrepareResult({ - status: "busy", - reason: "active-work", - retryAfterMs: 20_000, - activeCount: 2, - blockers: [ - { kind: "queue", count: 1, message: "one queued operation" }, - { - kind: "task", - count: 1, - message: "one active task", - task: { taskId: "task-1", status: "running", runtime: "subagent" }, - }, - ], - }), - ).toBe(true); - expect( - validateGatewaySuspendPrepareResult({ - status: "ready", - suspensionId: "suspension-id", - expiresAtMs: 123, - activeCount: 0, - blockers: [], - }), - ).toBe(true); - }); - - it("keeps background exec blockers count-only", () => { - const result = { - status: "busy", - reason: "active-work", - retryAfterMs: 20_000, - activeCount: 1, - blockers: [ - { - kind: "background-exec", - count: 1, - message: "1 active background exec session(s)", - }, - ], - }; - - expect(validateGatewaySuspendPrepareResult(result)).toBe(true); - expect( - validateGatewaySuspendPrepareResult({ - ...result, - blockers: [{ ...result.blockers[0], sessionIds: ["private-session-id"] }], - }), - ).toBe(false); - expect( - validateGatewaySuspendPrepareResult({ - ...result, - blockers: [{ ...result.blockers[0], command: "private command" }], - }), - ).toBe(false); - }); - - it("validates status and resume results", () => { - expect(validateGatewaySuspendStatusResult({ status: "running" })).toBe(true); - expect(validateGatewaySuspendStatusResult({ status: "ready", expiresAtMs: 123 })).toBe(true); - expect( - validateGatewaySuspendResumeResult({ ok: true, status: "running", resumed: false }), - ).toBe(true); - expect( - validateGatewaySuspendResumeResult({ - ok: true, - status: "running", - resumed: false, - warnings: [], - }), - ).toBe(false); - }); - - it("keeps scheduler recovery on the error frame instead of success results", () => { - const recovering = { - status: "recovering", - reason: "scheduler-resume-failed", - retryAfterMs: 1_000, - }; - - expect(validateGatewaySuspendPrepareResult(recovering)).toBe(false); - expect(validateGatewaySuspendStatusResult(recovering)).toBe(false); - }); }); diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 9415bc834e3b..4b03a3bb253d 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -8,37 +8,29 @@ import { validateChatHistoryParams, validateChatMetadataParams, validateChatSendParams, - validateChatEvent, validateCommandsListParams, validateConnectParams, validateModelsListParams, validateModelsProbeParams, - validateNodeEventResult, validateNodePluginToolsUpdateParams, validateNodeSkillsUpdateParams, validateNodePresenceActivityPayload, - validateNodePresenceAlivePayload, validateSessionsSearchParams, validateSessionsUsageParams, validateTasksCancelParams, validateTasksListParams, - validateTalkCatalogResult, validateTalkConfigResult, - validateTalkEvent, validateTalkClientCreateParams, validateTalkClientSteerParams, validateTalkClientToolCallParams, - validateTalkAgentControlResult, validateTalkSessionAppendAudioParams, validateTalkSessionCancelOutputParams, validateTalkSessionCancelTurnParams, validateTalkSessionCreateParams, validateTalkSessionJoinParams, - validateTalkSessionJoinResult, validateTalkSessionSubmitToolResultParams, validateTalkSessionSteerParams, validateTalkSessionTurnParams, - validateTalkSessionTurnResult, validateWakeParams, type ValidationError, } from "./index.js"; @@ -472,32 +464,6 @@ describe("validateTalkConfigResult", () => { }); }); -describe("validateTalkCatalogResult", () => { - it("accepts provider registry aliases", () => { - expect( - validateTalkCatalogResult({ - modes: ["realtime"], - transports: ["gateway-relay"], - brains: ["agent-consult"], - speech: { providers: [] }, - transcription: { providers: [] }, - realtime: { - ready: true, - activeProvider: "google", - providers: [ - { - id: "google", - aliases: ["gemini-live"], - label: "Google Live Voice", - configured: true, - }, - ], - }, - }), - ).toBe(true); - }); -}); - describe("validateTalkClientCreateParams", () => { it("accepts provider, model, voice, mode, transport, and brain overrides", () => { expect( @@ -536,80 +502,6 @@ describe("validateTalkClientCreateParams", () => { }); }); -describe("validateTalkEvent", () => { - it("pins the common Talk event envelope used by relay and surface adapters", () => { - expect( - validateTalkEvent({ - id: "talk-session:1", - type: "capture.started", - sessionId: "talk-session", - turnId: "turn-1", - captureId: "capture-1", - seq: 1, - timestamp: "2026-05-05T12:00:00.000Z", - mode: "stt-tts", - transport: "managed-room", - brain: "agent-consult", - provider: "openai", - final: false, - callId: "call-1", - itemId: "item-1", - parentId: "parent-1", - payload: { source: "ptt" }, - }), - ).toBe(true); - }); - - it("rejects stale or vendor-shaped event payloads without required correlation", () => { - expect( - validateTalkEvent({ - type: "output.audio.delta", - sessionId: "talk-session", - seq: 0, - timestamp: "2026-05-05T12:00:00.000Z", - mode: "realtime-duplex", - transport: "webrtc-sdp", - brain: "agent-consult", - payload: { byteLength: 12 }, - }), - ).toBe(false); - expect(formatValidationErrors(validateTalkEvent.errors)).toContain("must have required"); - }); - - it("requires turnId and captureId for scoped Talk events", () => { - expect( - validateTalkEvent({ - id: "talk-session:1", - type: "turn.started", - sessionId: "talk-session", - seq: 1, - timestamp: "2026-05-05T12:00:00.000Z", - mode: "stt-tts", - transport: "managed-room", - brain: "agent-consult", - payload: {}, - }), - ).toBe(false); - expect(formatValidationErrors(validateTalkEvent.errors)).toContain("must have required"); - - expect( - validateTalkEvent({ - id: "talk-session:2", - type: "capture.started", - sessionId: "talk-session", - turnId: "turn-1", - seq: 2, - timestamp: "2026-05-05T12:00:01.000Z", - mode: "stt-tts", - transport: "managed-room", - brain: "agent-consult", - payload: {}, - }), - ).toBe(false); - expect(formatValidationErrors(validateTalkEvent.errors)).toContain("must have required"); - }); -}); - describe("validateTalkSession", () => { it("accepts session-scoped provider, model, and voice selection", () => { expect( @@ -625,38 +517,6 @@ describe("validateTalkSession", () => { brain: "agent-consult", }), ).toBe(true); - expect( - validateTalkSessionJoinResult({ - id: "session-1", - roomId: "talk_room-1", - roomUrl: "/talk/rooms/talk_handoff-1", - sessionKey: "agent:main:main", - provider: "openai", - model: "gpt-realtime-2", - voice: "alloy", - mode: "realtime", - transport: "managed-room", - brain: "agent-consult", - createdAt: 1, - expiresAt: 2, - room: { - activeClientId: "conn-1", - recentTalkEvents: [ - { - id: "talk_handoff-1:1", - type: "session.ready", - sessionId: "talk_handoff-1", - seq: 1, - timestamp: "2026-05-05T12:00:00.000Z", - mode: "realtime", - transport: "managed-room", - brain: "agent-consult", - payload: {}, - }, - ], - }, - }), - ).toBe(true); }); it("rejects request-time instruction overrides for Talk session creation", () => { @@ -672,7 +532,7 @@ describe("validateTalkSession", () => { expect(validateTalkSessionCreateParams({ mode: "realtime", language: "de-DE" })).toBe(false); }); - it("accepts managed-room join, turn lifecycle params, and results", () => { + it("accepts managed-room join and turn lifecycle params", () => { expect( validateTalkSessionJoinParams({ sessionId: "session-1", @@ -692,26 +552,6 @@ describe("validateTalkSession", () => { reason: "barge-in", }), ).toBe(true); - expect( - validateTalkSessionTurnResult({ - ok: true, - turnId: "turn-1", - events: [ - { - id: "talk_handoff-1:2", - type: "turn.started", - sessionId: "talk_handoff-1", - turnId: "turn-1", - seq: 2, - timestamp: "2026-05-05T12:00:00.000Z", - mode: "realtime", - transport: "managed-room", - brain: "agent-consult", - payload: {}, - }, - ], - }), - ).toBe(true); }); }); @@ -730,7 +570,7 @@ describe("validateTalkClientToolCallParams", () => { }); describe("validateTalkAgentControlParams", () => { - it("accepts client and session steering params plus structured outcomes", () => { + it("accepts client and session steering params", () => { expect( validateTalkClientSteerParams({ sessionKey: "agent:main:main", @@ -746,24 +586,6 @@ describe("validateTalkAgentControlParams", () => { mode: "status", }), ).toBe(true); - expect( - validateTalkAgentControlResult({ - ok: true, - mode: "cancel", - sessionKey: "agent:main:main", - sessionId: "session-1", - active: true, - aborted: true, - message: "Cancelled the active OpenClaw run.", - speak: true, - show: true, - suppress: false, - providerResult: { - status: "cancelled", - message: "Cancelled the active OpenClaw run.", - }, - }), - ).toBe(true); }); }); @@ -860,91 +682,6 @@ describe("validateWakeParams", () => { }); }); -describe("validateChatEvent", () => { - it("accepts an explicitly yielded final turn", () => { - expect( - validateChatEvent({ - runId: "run-yielded", - sessionKey: "agent:main:main", - seq: 1, - state: "final", - stopReason: "end_turn", - yielded: true, - }), - ).toBe(true); - }); - - it("accepts v4 chat delta text and replacement markers", () => { - expect( - validateChatEvent({ - runId: "run-chat", - sessionKey: "agent:main:main", - seq: 1, - state: "delta", - deltaText: "hello", - message: { - role: "assistant", - content: [{ type: "text", text: "hello" }], - }, - }), - ).toBe(true); - expect( - validateChatEvent({ - runId: "run-chat", - sessionKey: "agent:main:main", - seq: 2, - state: "delta", - deltaText: "replacement", - replace: true, - message: { - role: "assistant", - content: [{ type: "text", text: "replacement" }], - }, - }), - ).toBe(true); - }); - - it("accepts selected-agent chat events", () => { - expect( - validateChatEvent({ - runId: "run-chat", - sessionKey: "global", - agentId: "work", - seq: 1, - state: "delta", - deltaText: "hello", - }), - ).toBe(true); - }); - - it("accepts an argument-free diagnostic on aborted chat events", () => { - expect( - validateChatEvent({ - runId: "run-chat", - sessionKey: "agent:main:main", - seq: 2, - state: "aborted", - errorMessage: "edit tool validation failed: path: must be string", - }), - ).toBe(true); - }); - - it("rejects v3-style chat deltas without deltaText", () => { - expect( - validateChatEvent({ - runId: "run-chat", - sessionKey: "agent:main:main", - seq: 1, - state: "delta", - message: { - role: "assistant", - content: [{ type: "text", text: "hello" }], - }, - }), - ).toBe(false); - }); -}); - describe("validateChatSendParams", () => { it("accepts one-turn fast:auto cutoff seconds", () => { const base = { @@ -1027,33 +764,6 @@ describe("validateTasksListParams", () => { }); }); -describe("validateNodePresenceAlivePayload", () => { - it("accepts a closed trigger and known metadata fields", () => { - expect( - validateNodePresenceAlivePayload({ - trigger: "silent_push", - sentAtMs: 123, - displayName: "Peter's iPhone", - version: "2026.4.28", - platform: "iOS 18.4.0", - deviceFamily: "iPhone", - modelIdentifier: "iPhone17,1", - pushTransport: "relay", - }), - ).toBe(true); - }); - - it("rejects unknown triggers and extra fields", () => { - expect(validateNodePresenceAlivePayload({ trigger: "push", sentAtMs: 123 })).toBe(false); - expect( - validateNodePresenceAlivePayload({ - trigger: "silent_push", - arbitrary: true, - }), - ).toBe(false); - }); -}); - describe("validateNodePresenceActivityPayload", () => { it("accepts bounded input idle time", () => { expect(validateNodePresenceActivityPayload({ idleSeconds: 12 })).toBe(true); @@ -1068,16 +778,3 @@ describe("validateNodePresenceActivityPayload", () => { expect(validateNodePresenceActivityPayload({ idleSeconds: 1, active: true })).toBe(false); }); }); - -describe("validateNodeEventResult", () => { - it("accepts structured handled results", () => { - expect( - validateNodeEventResult({ - ok: true, - event: "node.presence.alive", - handled: true, - reason: "persisted", - }), - ).toBe(true); - }); -}); diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index af4b5ee7eb30..614b841f4e86 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -131,7 +131,6 @@ import { ChatEventSchema, ChatHistoryParamsSchema, ChatMetadataParamsSchema, - ChatMessageGetResultSchema, ChatMessageGetParamsSchema, ChatInjectParamsSchema, ChatSendParamsSchema, @@ -442,6 +441,7 @@ import { SessionCatalogCapabilitiesSchema, SessionCatalogDescriptorSchema, SessionCatalogHostSchema, + SessionCatalogLocatorSchema, SessionCatalogSessionSchema, SessionCatalogTranscriptItemSchema, SessionsCatalogArchiveParamsSchema, @@ -628,23 +628,15 @@ export const validateWorkerLiveEventParams = lazyCompile( checkWorkerProtocolJson, ); export const validateGatewaySuspendPrepareParams = lazyCompile(GatewaySuspendPrepareParamsSchema); -export const validateGatewaySuspendPrepareResult = lazyCompile(GatewaySuspendPrepareResultSchema); export const validateGatewaySuspendStatusParams = lazyCompile(GatewaySuspendStatusParamsSchema); -export const validateGatewaySuspendStatusResult = lazyCompile(GatewaySuspendStatusResultSchema); export const validateGatewaySuspendResumeParams = lazyCompile(GatewaySuspendResumeParamsSchema); -export const validateGatewaySuspendResumeResult = lazyCompile(GatewaySuspendResumeResultSchema); export const validateRequestFrame = lazyCompile(RequestFrameSchema); -export const validateResponseFrame = lazyCompile(ResponseFrameSchema); -export const validateEventFrame = lazyCompile(EventFrameSchema); export const validateMessageActionParams = lazyCompile(MessageActionParamsSchema); export const validateSendParams = lazyCompile(SendParamsSchema); export const validateConversationListParams = lazyCompile(ConversationListParamsSchema); -export const validateConversationListResult = lazyCompile(ConversationListResultSchema); export const validateConversationSendParams = lazyCompile(ConversationSendParamsSchema); -export const validateConversationSendResult = lazyCompile(ConversationSendResultSchema); export const validateConversationTurnCancelParams = lazyCompile(ConversationTurnCancelParamsSchema); export const validateConversationTurnParams = lazyCompile(ConversationTurnParamsSchema); -export const validateConversationTurnResult = lazyCompile(ConversationTurnResultSchema); export const validatePollParams = lazyCompile(PollParamsSchema); export const validateAgentParams = lazyCompile(AgentParamsSchema); export const validateAuditActivityListParams = lazyCompile( @@ -705,12 +697,9 @@ export const validateSystemInfoResult = lazyCompile(SystemInfoResultSchema); export const validateNodePendingAckParams = lazyCompile(NodePendingAckParamsSchema); export const validateNodeDescribeParams = lazyCompile(NodeDescribeParamsSchema); export const validateNodeInvokeParams = lazyCompile(NodeInvokeParamsSchema); -export const validateNodeInvokeInputEvent = lazyCompile(NodeInvokeInputEventSchema); export const validateNodeInvokeResultParams = lazyCompile(NodeInvokeResultParamsSchema); export const validateNodeInvokeProgressParams = lazyCompile(NodeInvokeProgressParamsSchema); export const validateNodeEventParams = lazyCompile(NodeEventParamsSchema); -export const validateNodeEventResult = lazyCompile(NodeEventResultSchema); -export const validateNodePresenceAlivePayload = lazyCompile(NodePresenceAlivePayloadSchema); export const validateNodePresenceActivityPayload = lazyCompile(NodePresenceActivityPayloadSchema); export const validateNodePendingDrainParams = lazyCompile(NodePendingDrainParamsSchema); export const validateNodePendingEnqueueParams = lazyCompile(NodePendingEnqueueParamsSchema); @@ -729,14 +718,12 @@ export const validateSecretsResolveParams = lazyCompile(SecretsResolveParamsSche export const validateSecretsResolveResult = lazyCompile(SecretsResolveResultSchema); export const validateSessionsListParams = lazyCompile(SessionsListParamsSchema); export const validateSessionsCatalogListParams = lazyCompile(SessionsCatalogListParamsSchema); -export const validateSessionsCatalogHostEvent = lazyCompile(SessionsCatalogHostEventSchema); export const validateSessionsCatalogReadParams = lazyCompile(SessionsCatalogReadParamsSchema); export const validateSessionsCatalogContinueParams = lazyCompile( SessionsCatalogContinueParamsSchema, ); export const validateSessionsCatalogArchiveParams = lazyCompile(SessionsCatalogArchiveParamsSchema); export const validateSessionsSearchParams = lazyCompile(SessionsSearchParamsSchema); -export const validateSessionsSearchResult = lazyCompile(SessionsSearchResultSchema); export const validateSessionsCleanupParams = lazyCompile(SessionsCleanupParamsSchema); export const validateSessionsPreviewParams = lazyCompile(SessionsPreviewParamsSchema); export const validateSessionsDescribeParams = lazyCompile(SessionsDescribeParamsSchema); @@ -749,9 +736,7 @@ export const validateSessionsDiffParams = lazyCompile(SessionsDiffParamsSchema); export const validateSessionsCreateParams = lazyCompile(SessionsCreateParamsSchema); export const validateSessionsSendParams = lazyCompile(SessionsSendParamsSchema); export const validateSessionsDispatchParams = lazyCompile(SessionsDispatchParamsSchema); -export const validateSessionsDispatchResult = lazyCompile(SessionsDispatchResultSchema); export const validateSessionsReclaimParams = lazyCompile(SessionsReclaimParamsSchema); -export const validateSessionsReclaimResult = lazyCompile(SessionsReclaimResultSchema); export const validateSessionsMessagesSubscribeParams = lazyCompile( SessionsMessagesSubscribeParamsSchema, ); @@ -810,9 +795,7 @@ export const validateWizardNextParams = lazyCompile(WizardNextParamsSchema); export const validateWizardCancelParams = lazyCompile(WizardCancelParamsSchema); export const validateWizardStatusParams = lazyCompile(WizardStatusParamsSchema); export const validateTalkModeParams = lazyCompile(TalkModeParamsSchema); -export const validateTalkEvent = lazyCompile(TalkEventSchema); export const validateTalkCatalogParams = lazyCompile(TalkCatalogParamsSchema); -export const validateTalkCatalogResult = lazyCompile(TalkCatalogResultSchema); export const validateTalkConfigParams = lazyCompile(TalkConfigParamsSchema); export const validateTalkConfigResult = lazyCompile(TalkConfigResultSchema); export const validateTalkClientCreateParams = lazyCompile(TalkClientCreateParamsSchema); @@ -823,11 +806,8 @@ export const validateTalkClientToolCallParams = lazyCompile(TalkClientToolCallPa export const validateTalkClientToolCallResult = lazyCompile(TalkClientToolCallResultSchema); export const validateTalkClientTranscriptParams = lazyCompile(TalkClientTranscriptParamsSchema); export const validateTalkClientSteerParams = lazyCompile(TalkClientSteerParamsSchema); -export const validateTalkAgentControlResult = lazyCompile(TalkAgentControlResultSchema); export const validateTalkSessionCreateParams = lazyCompile(TalkSessionCreateParamsSchema); -export const validateTalkSessionCreateResult = lazyCompile(TalkSessionCreateResultSchema); export const validateTalkSessionJoinParams = lazyCompile(TalkSessionJoinParamsSchema); -export const validateTalkSessionJoinResult = lazyCompile(TalkSessionJoinResultSchema); export const validateTalkSessionAppendAudioParams = lazyCompile(TalkSessionAppendAudioParamsSchema); export const validateTalkSessionAcknowledgeMarkParams = lazyCompile( TalkSessionAcknowledgeMarkParamsSchema, @@ -837,17 +817,13 @@ export const validateTalkSessionCancelTurnParams = lazyCompile(TalkSessionCancel export const validateTalkSessionCancelOutputParams = lazyCompile( TalkSessionCancelOutputParamsSchema, ); -export const validateTalkSessionTurnResult = lazyCompile(TalkSessionTurnResultSchema); export const validateTalkSessionSteerParams = lazyCompile(TalkSessionSteerParamsSchema); export const validateTalkSessionSubmitToolResultParams = lazyCompile( TalkSessionSubmitToolResultParamsSchema, ); export const validateTalkSessionCloseParams = lazyCompile(TalkSessionCloseParamsSchema); -export const validateTalkSessionOkResult = lazyCompile(TalkSessionOkResultSchema); export const validateTalkSpeakParams = lazyCompile(TalkSpeakParamsSchema); -export const validateTalkSpeakResult = lazyCompile(TalkSpeakResultSchema); export const validateTtsSpeakParams = lazyCompile(TtsSpeakParamsSchema); -export const validateTtsSpeakResult = lazyCompile(TtsSpeakResultSchema); export const validateChannelsStatusParams = lazyCompile(ChannelsStatusParamsSchema); export const validateChannelsStartParams = lazyCompile(ChannelsStartParamsSchema); export const validateChannelsStopParams = lazyCompile(ChannelsStopParamsSchema); @@ -894,21 +870,7 @@ export const validateDevicePairSetupCodeParams = lazyCompile(DevicePairSetupCode export const validateDevicePairRenameParams = lazyCompile(DevicePairRenameParamsSchema); export const validateDeviceTokenRotateParams = lazyCompile(DeviceTokenRotateParamsSchema); export const validateDeviceTokenRevokeParams = lazyCompile(DeviceTokenRevokeParamsSchema); -export const validateApprovalKind = lazyCompile(ApprovalKindSchema); -export const validateApprovalDecision = lazyCompile(ApprovalDecisionSchema); -export const validateApprovalAllowDecision = lazyCompile(ApprovalAllowDecisionSchema); -export const validateApprovalTerminalReason = lazyCompile(ApprovalTerminalReasonSchema); -export const validatePluginApprovalSeverity = lazyCompile(PluginApprovalSeveritySchema); -export const validateExecApprovalPresentation = lazyCompile(ExecApprovalPresentationSchema); -export const validatePluginApprovalPresentation = lazyCompile(PluginApprovalPresentationSchema); export const validateApprovalPresentation = lazyCompile(ApprovalPresentationSchema); -export const validatePendingApprovalSnapshot = lazyCompile(PendingApprovalSnapshotSchema); -export const validateAllowedApprovalSnapshot = lazyCompile(AllowedApprovalSnapshotSchema); -export const validateDeniedApprovalSnapshot = lazyCompile(DeniedApprovalSnapshotSchema); -export const validateExpiredApprovalSnapshot = lazyCompile(ExpiredApprovalSnapshotSchema); -export const validateCancelledApprovalSnapshot = lazyCompile(CancelledApprovalSnapshotSchema); -export const validateApprovalSnapshot = lazyCompile(ApprovalSnapshotSchema); -export const validateTerminalApprovalSnapshot = lazyCompile(TerminalApprovalSnapshotSchema); export const validateApprovalGetParams = lazyCompile(ApprovalGetParamsSchema); export const validateApprovalHistoryParams = lazyCompile(ApprovalHistoryParamsSchema); export const validateApprovalResolveParams = lazyCompile(ApprovalResolveParamsSchema); @@ -918,31 +880,18 @@ export const validateExecApprovalGetParams = lazyCompile(ExecApprovalGetParamsSc export const validateExecApprovalRequestParams = lazyCompile(ExecApprovalRequestParamsSchema); export const validateExecApprovalResolveParams = lazyCompile(ExecApprovalResolveParamsSchema); export const validateQuestionRequestParams = lazyCompile(QuestionRequestParamsSchema); -export const validateQuestionRequestResult = lazyCompile(QuestionRequestResultSchema); export const validateQuestionWaitAnswerParams = lazyCompile(QuestionWaitAnswerParamsSchema); -export const validateQuestionWaitAnswerResult = lazyCompile(QuestionWaitAnswerResultSchema); export const validateQuestionResolveParams = lazyCompile(QuestionResolveParamsSchema); -export const validateQuestionResolveResult = lazyCompile(QuestionResolveResultSchema); export const validateQuestionGetParams = lazyCompile(QuestionGetParamsSchema); -export const validateQuestionGetResult = lazyCompile(QuestionGetResultSchema); export const validateQuestionListParams = lazyCompile(QuestionListParamsSchema); -export const validateQuestionListResult = lazyCompile(QuestionListResultSchema); -export const validateQuestionRequestedEvent = lazyCompile(QuestionRequestedEventSchema); -export const validateQuestionResolvedEvent = lazyCompile(QuestionResolvedEventSchema); export const validatePluginApprovalRequestParams = lazyCompile(PluginApprovalRequestParamsSchema); export const validatePluginApprovalResolveParams = lazyCompile(PluginApprovalResolveParamsSchema); export const validatePluginsListParams = lazyCompile(PluginsListParamsSchema); -export const validatePluginsListResult = lazyCompile(PluginsListResultSchema); export const validatePluginsRefreshParams = lazyCompile(PluginsRefreshParamsSchema); -export const validatePluginsRefreshResult = lazyCompile(PluginsRefreshResultSchema); export const validatePluginsSearchParams = lazyCompile(PluginsSearchParamsSchema); -export const validatePluginsSearchResult = lazyCompile(PluginsSearchResultSchema); export const validatePluginsInstallParams = lazyCompile(PluginsInstallParamsSchema); -export const validatePluginsInstallResult = lazyCompile(PluginsInstallResultSchema); export const validatePluginsSetEnabledParams = lazyCompile(PluginsSetEnabledParamsSchema); -export const validatePluginsSetEnabledResult = lazyCompile(PluginsSetEnabledResultSchema); export const validatePluginsUninstallParams = lazyCompile(PluginsUninstallParamsSchema); -export const validatePluginsUninstallResult = lazyCompile(PluginsUninstallResultSchema); export const validatePluginsUiDescriptorsParams = lazyCompile(PluginsUiDescriptorsParamsSchema); export const validatePluginsUiDescriptorsResult = lazyCompile(PluginsUiDescriptorsResultSchema); export const validatePluginsSessionActionParams = lazyCompile(PluginsSessionActionParamsSchema); @@ -959,8 +908,6 @@ export const validateChatToolTitlesParams = lazyCompile(ChatToolTitlesParamsSche export const validateChatSendParams = lazyCompile(ChatSendParamsSchema); export const validateChatAbortParams = lazyCompile(ChatAbortParamsSchema); export const validateChatInjectParams = lazyCompile(ChatInjectParamsSchema); -export const validateChatEvent = lazyCompile(ChatEventSchema); -export const validateChatMessageGetResult = lazyCompile(ChatMessageGetResultSchema); export const validateUpdateStatusParams = lazyCompile(UpdateStatusParamsSchema); export const validateUpdateRunParams = lazyCompile(UpdateRunParamsSchema); export const validateUiCommandParams = lazyCompile(UiCommandParamsSchema); @@ -1098,6 +1045,7 @@ export { SessionCatalogDescriptorSchema, SessionCatalogSessionSchema, SessionCatalogHostSchema, + SessionCatalogLocatorSchema, SessionCatalogSchema, SessionCatalogTranscriptItemSchema, SessionsCatalogListParamsSchema, diff --git a/packages/gateway-protocol/src/plugins-validators.test.ts b/packages/gateway-protocol/src/plugins-validators.test.ts index 8eaebcc2f487..eaeedbfb9001 100644 --- a/packages/gateway-protocol/src/plugins-validators.test.ts +++ b/packages/gateway-protocol/src/plugins-validators.test.ts @@ -1,89 +1,27 @@ import { describe, expect, it } from "vitest"; import { validatePluginsInstallParams, - validatePluginsInstallResult, validatePluginsListParams, - validatePluginsListResult, validatePluginsRefreshParams, - validatePluginsRefreshResult, validatePluginsSearchParams, - validatePluginsSearchResult, validatePluginsSetEnabledParams, - validatePluginsSetEnabledResult, validatePluginsUninstallParams, - validatePluginsUninstallResult, } from "./index.js"; -const installedPlugin = { - id: "workboard", - name: "Workboard", - packageName: "@openclaw/workboard", - description: "Coordinate work across agents", - version: "1.0.0", - kind: ["tool"], - origin: "bundled", - installed: true, - enabled: false, - state: "disabled", - featured: true, - featuredAt: 1_784_280_000_000, - order: 10, - install: { source: "official", pluginId: "workboard" }, - category: "tool", - removable: false, -} as const; - describe("plugin lifecycle protocol validators", () => { - it("validates plugin metadata refresh payloads", () => { + it("validates plugin metadata refresh params", () => { expect(validatePluginsRefreshParams({})).toBe(true); expect(validatePluginsRefreshParams({ unexpected: true })).toBe(false); - expect(validatePluginsRefreshResult({ ok: true })).toBe(true); - expect(validatePluginsRefreshResult({ ok: false })).toBe(false); }); - it("accepts cold catalog payloads and rejects runtime-only states", () => { + it("keeps list params closed", () => { expect(validatePluginsListParams({})).toBe(true); expect(validatePluginsListParams({ unexpected: true })).toBe(false); - expect( - validatePluginsListResult({ - plugins: [installedPlugin], - diagnostics: [], - mutationAllowed: true, - }), - ).toBe(true); - expect( - validatePluginsListResult({ - plugins: [{ ...installedPlugin, state: "loaded" }], - diagnostics: [], - mutationAllowed: true, - }), - ).toBe(false); }); - it("validates bounded plugin search requests and projected results", () => { + it("validates bounded plugin search requests", () => { expect(validatePluginsSearchParams({ query: "memory", limit: 20 })).toBe(true); expect(validatePluginsSearchParams({ query: "memory", limit: 101 })).toBe(false); - expect( - validatePluginsSearchResult({ - results: [ - { - score: 0.95, - package: { - name: "memory-plus", - displayName: "Memory Plus", - family: "code-plugin", - channel: "community", - isOfficial: false, - summary: "Long-term memory tools", - latestVersion: "2.1.0", - runtimeId: "memory-plus", - downloads: 1420, - verificationTier: "source-linked", - }, - }, - ], - }), - ).toBe(true); }); it("keeps official and ClawHub install requests distinct", () => { @@ -103,49 +41,16 @@ describe("plugin lifecycle protocol validators", () => { packageName: "memory-plus", }), ).toBe(false); - expect( - validatePluginsInstallResult({ - ok: true, - plugin: { ...installedPlugin, enabled: true, state: "enabled" }, - restartRequired: true, - warnings: ["Restart the gateway to load this plugin."], - }), - ).toBe(true); }); - it("validates uninstall requests and removal summaries", () => { + it("validates uninstall requests", () => { expect(validatePluginsUninstallParams({ pluginId: "memory-plus" })).toBe(true); expect(validatePluginsUninstallParams({ pluginId: "" })).toBe(false); expect(validatePluginsUninstallParams({})).toBe(false); - expect( - validatePluginsUninstallResult({ - ok: true, - pluginId: "memory-plus", - restartRequired: true, - removed: ["config entry", "install record", "directory"], - warnings: ["npm prune skipped"], - }), - ).toBe(true); - expect( - validatePluginsUninstallResult({ - ok: true, - pluginId: "memory-plus", - restartRequired: false, - removed: [], - }), - ).toBe(false); }); - it("validates enablement mutations and dynamic restart metadata", () => { + it("validates enablement mutations", () => { expect(validatePluginsSetEnabledParams({ pluginId: "workboard", enabled: true })).toBe(true); expect(validatePluginsSetEnabledParams({ pluginId: "workboard", enabled: "yes" })).toBe(false); - expect( - validatePluginsSetEnabledResult({ - ok: true, - plugin: { ...installedPlugin, enabled: true, state: "enabled" }, - restartRequired: false, - warnings: ['Exclusive slot "memory" switched to "memory-plus".'], - }), - ).toBe(true); }); }); diff --git a/packages/gateway-protocol/src/protocol-validator.ts b/packages/gateway-protocol/src/protocol-validator.ts index 4be7a003a329..81db80ae8c16 100644 --- a/packages/gateway-protocol/src/protocol-validator.ts +++ b/packages/gateway-protocol/src/protocol-validator.ts @@ -19,6 +19,7 @@ export function lazyCompile( schema: TSchema, precheck?: (data: unknown) => ValidationError | undefined, ): ProtocolValidator; +/* @__NO_SIDE_EFFECTS__ */ export function lazyCompile( schema: TSchema, precheck?: (data: unknown) => ValidationError | undefined, diff --git a/packages/gateway-protocol/src/public-session-catalog.ts b/packages/gateway-protocol/src/public-session-catalog.ts index cacea9191388..785c4752c085 100644 --- a/packages/gateway-protocol/src/public-session-catalog.ts +++ b/packages/gateway-protocol/src/public-session-catalog.ts @@ -4,6 +4,7 @@ export type { SessionCatalogCapabilities, SessionCatalogDescriptor, SessionCatalogHost, + SessionCatalogLocator, SessionCatalogSession, SessionCatalogTranscriptItem, SessionsCatalogArchiveParams, diff --git a/packages/gateway-protocol/src/questions-validators.test.ts b/packages/gateway-protocol/src/questions-validators.test.ts index 573ffddc18d9..b94ba2d3a502 100644 --- a/packages/gateway-protocol/src/questions-validators.test.ts +++ b/packages/gateway-protocol/src/questions-validators.test.ts @@ -1,19 +1,12 @@ import { describe, expect, it } from "vitest"; import { - validateQuestionGetResult, - validateQuestionListResult, - validateQuestionRequestedEvent, validateQuestionRequestParams, - validateQuestionRequestResult, - validateQuestionResolvedEvent, validateQuestionResolveParams, - validateQuestionResolveResult, validateQuestionWaitAnswerParams, - validateQuestionWaitAnswerResult, } from "./index.js"; const question = { - id: "choice", + questionId: "choice", header: "Choice", question: "Which option?", options: [{ label: "One", description: "First" }, { label: "Two" }], @@ -21,19 +14,10 @@ const question = { isOther: true, isSecret: false, }; -const answers = { answers: { choice: { answers: ["Two"] } } }; -const pendingRecord = { - id: "question-uuid", - questions: [question], - agentId: "main", - sessionKey: "agent:main:main", - createdAtMs: 1, - expiresAtMs: 2, - status: "pending", -}; +const answers = { answers: { choice: ["Two"] } }; describe("question protocol validators", () => { - it("round-trips method params and results", () => { + it("validates method params", () => { expect( validateQuestionRequestParams({ id: "client-question-id", @@ -41,36 +25,16 @@ describe("question protocol validators", () => { timeoutMs: 100, }), ).toBe(true); - expect(validateQuestionRequestResult({ id: "question-uuid", expiresAtMs: 2 })).toBe(true); expect(validateQuestionWaitAnswerParams({ id: "question-uuid", timeoutMs: 50 })).toBe(true); - expect(validateQuestionWaitAnswerResult({ status: "pending" })).toBe(true); - expect(validateQuestionWaitAnswerResult({ status: "answered", answers })).toBe(true); expect(validateQuestionResolveParams({ id: "question-uuid", answers })).toBe(true); expect(validateQuestionResolveParams({ id: "question-uuid", cancel: true })).toBe(true); - expect(validateQuestionResolveResult({ status: "cancelled" })).toBe(true); - expect(validateQuestionGetResult({ question: pendingRecord })).toBe(true); - expect(validateQuestionListResult({ questions: [pendingRecord] })).toBe(true); }); - it("round-trips requested and resolved events", () => { - expect(validateQuestionRequestedEvent(pendingRecord)).toBe(true); - expect( - validateQuestionResolvedEvent({ id: "question-uuid", status: "answered", answers }), - ).toBe(true); - expect(validateQuestionResolvedEvent({ id: "question-uuid", status: "expired" })).toBe(true); - }); - - it("keeps records normalized while allowing request-boundary header truncation", () => { + it("enforces the shared question header cap", () => { expect( validateQuestionRequestParams({ questions: [{ ...question, header: "longer than twelve" }], }), - ).toBe(true); - expect( - validateQuestionRequestedEvent({ - ...pendingRecord, - questions: [{ ...question, header: "longer than twelve" }], - }), ).toBe(false); expect(validateQuestionRequestParams({ questions: [] })).toBe(false); expect( diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index 316c4a033fd6..0409d401eef0 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -23,7 +23,7 @@ const AGENT_INTERNAL_EVENT_STATUSES = ["ok", "timeout", "error", "unknown"] as c const CONVERSATION_REF_PATTERN = "^conv_[a-f0-9]{32}$"; /** Generated media/file attachment metadata carried by internal agent events. */ -export const AgentGeneratedAttachmentSchema = closedObject({ +const AgentGeneratedAttachmentSchema = closedObject({ type: Type.Optional(Type.String({ enum: ["image", "audio", "video", "file"] })), path: Type.Optional(Type.String()), url: Type.Optional(Type.String()), @@ -34,7 +34,7 @@ export const AgentGeneratedAttachmentSchema = closedObject({ }); /** Internal completion event surfaced when child automation reports back to a parent run. */ -export const AgentInternalEventSchema = closedObject({ +const AgentInternalEventSchema = closedObject({ type: Type.Literal(AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION), source: Type.String({ enum: [...AGENT_INTERNAL_EVENT_SOURCES] }), childSessionKey: Type.String(), @@ -62,7 +62,7 @@ export const AgentEventSchema = closedObject({ }); /** Caller-supplied routing hints. Authorization must use trusted runtime context. */ -export const MessageActionToolContextSchema = closedObject({ +const MessageActionToolContextSchema = closedObject({ currentChannelId: Type.Optional(Type.String()), currentMessagingTarget: Type.Optional(Type.String()), currentGraphChannelId: Type.Optional(Type.String()), diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index eac8e2de2491..e60a0b26d5c7 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -14,7 +14,7 @@ import { NonEmptyString } from "./primitives.js"; */ /** Model option shown in selectors and model catalog results. */ -export const GatewayAgentRuntimeSchema = closedObject({ +const GatewayAgentRuntimeSchema = closedObject({ id: NonEmptyString, fallback: Type.Optional(Type.Union([Type.Literal("openclaw"), Type.Literal("none")])), source: Type.Union([ diff --git a/packages/gateway-protocol/src/schema/approvals.ts b/packages/gateway-protocol/src/schema/approvals.ts index 619a56219c83..08aeb8ae5d94 100644 --- a/packages/gateway-protocol/src/schema/approvals.ts +++ b/packages/gateway-protocol/src/schema/approvals.ts @@ -4,6 +4,7 @@ import { Type } from "typebox"; import { APPROVAL_ID_WELL_FORMED_UNICODE_PATTERN } from "./approval-id.js"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; +import { withSince } from "./since.js"; export { isWellFormedApprovalId } from "./approval-id.js"; @@ -260,32 +261,41 @@ const SessionApprovalEventCommonFields = { }; /** Sanitized pending transition delivered only to an opted-in session audience. */ -export const PendingSessionApprovalEventSchema = closedObject({ - ...SessionApprovalEventCommonFields, - phase: Type.Literal("pending"), - approval: PendingApprovalSnapshotSchema, -}); +export const PendingSessionApprovalEventSchema = withSince( + "2026.7", + closedObject({ + ...SessionApprovalEventCommonFields, + phase: Type.Literal("pending"), + approval: PendingApprovalSnapshotSchema, + }), +); /** Sanitized terminal transition delivered only to an opted-in session audience. */ -export const TerminalSessionApprovalEventSchema = closedObject({ - ...SessionApprovalEventCommonFields, - phase: Type.Literal("terminal"), - approval: TerminalApprovalSnapshotSchema, -}); +export const TerminalSessionApprovalEventSchema = withSince( + "2026.7", + closedObject({ + ...SessionApprovalEventCommonFields, + phase: Type.Literal("terminal"), + approval: TerminalApprovalSnapshotSchema, + }), +); /** Sanitized approval transition delivered only to an opted-in session audience. */ -export const SessionApprovalEventSchema = Type.Union([ - PendingSessionApprovalEventSchema, - TerminalSessionApprovalEventSchema, -]); +export const SessionApprovalEventSchema = withSince( + "2026.7", + Type.Union([PendingSessionApprovalEventSchema, TerminalSessionApprovalEventSchema]), +); /** Authoritative pending approval set returned when a session stream subscribes. */ -export const SessionApprovalReplaySchema = closedObject({ - sessionKey: NonEmptyString, - updatedAtMs: Type.Integer({ minimum: 0 }), - approvals: Type.Array(PendingApprovalSnapshotSchema), - truncated: Type.Boolean(), -}); +export const SessionApprovalReplaySchema = withSince( + "2026.7", + closedObject({ + sessionKey: NonEmptyString, + updatedAtMs: Type.Integer({ minimum: 0 }), + approvals: Type.Array(PendingApprovalSnapshotSchema), + truncated: Type.Boolean(), + }), +); // Owner-local wire types derived directly from local schema consts so the // public plugin-sdk declaration graph never pulls in the ProtocolSchemas registry. diff --git a/packages/gateway-protocol/src/schema/audit-activity.ts b/packages/gateway-protocol/src/schema/audit-activity.ts index 93f52bd87779..8064bd12110f 100644 --- a/packages/gateway-protocol/src/schema/audit-activity.ts +++ b/packages/gateway-protocol/src/schema/audit-activity.ts @@ -5,7 +5,7 @@ import { NonEmptyString } from "./primitives.js"; const AuditActivitySchemaVersionV1Schema = Type.Integer({ minimum: 1, maximum: 1 }); -export const AuditActivityStatusV1Schema: TSchema = Type.Union([ +const AuditActivityStatusV1Schema: TSchema = Type.Union([ Type.Literal("started"), Type.Literal("succeeded"), Type.Literal("failed"), @@ -15,13 +15,13 @@ export const AuditActivityStatusV1Schema: TSchema = Type.Union([ Type.Literal("unknown"), ]); -export const AuditActivityKindV1Schema: TSchema = Type.Union([ +const AuditActivityKindV1Schema: TSchema = Type.Union([ Type.Literal("agent_run"), Type.Literal("tool_action"), Type.Literal("message"), ]); -export const AuditActivityDirectionV1Schema: TSchema = Type.Union([ +const AuditActivityDirectionV1Schema: TSchema = Type.Union([ Type.Literal("inbound"), Type.Literal("outbound"), ]); diff --git a/packages/gateway-protocol/src/schema/audit.ts b/packages/gateway-protocol/src/schema/audit.ts index 19d92094236d..7951904c9de2 100644 --- a/packages/gateway-protocol/src/schema/audit.ts +++ b/packages/gateway-protocol/src/schema/audit.ts @@ -4,19 +4,16 @@ import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; -export const AuditEventKindSchema = Type.Union([ - Type.Literal("agent_run"), - Type.Literal("tool_action"), -]); +const AuditEventKindSchema = Type.Union([Type.Literal("agent_run"), Type.Literal("tool_action")]); -export const AuditEventActionSchema = Type.Union([ +const AuditEventActionSchema = Type.Union([ Type.Literal("agent.run.started"), Type.Literal("agent.run.finished"), Type.Literal("tool.action.started"), Type.Literal("tool.action.finished"), ]); -export const AuditEventStatusSchema = Type.Union([ +const AuditEventStatusSchema = Type.Union([ Type.Literal("started"), Type.Literal("succeeded"), Type.Literal("failed"), @@ -26,7 +23,7 @@ export const AuditEventStatusSchema = Type.Union([ Type.Literal("unknown"), ]); -export const AuditEventErrorCodeSchema = Type.Union([ +const AuditEventErrorCodeSchema = Type.Union([ Type.Literal("run_failed"), Type.Literal("run_cancelled"), Type.Literal("run_timed_out"), diff --git a/packages/gateway-protocol/src/schema/channels.ts b/packages/gateway-protocol/src/schema/channels.ts index 229e7dad6a1e..108c7a12e1b1 100644 --- a/packages/gateway-protocol/src/schema/channels.ts +++ b/packages/gateway-protocol/src/schema/channels.ts @@ -644,7 +644,7 @@ export const ChannelsStatusParamsSchema = closedObject({ * This is intentionally schema-light so new channel-specific metadata can ship * without a gateway protocol update; known fields stay documented for UI use. */ -export const ChannelAccountSnapshotSchema = Type.Object( +const ChannelAccountSnapshotSchema = Type.Object( { accountId: NonEmptyString, name: Type.Optional(Type.String()), @@ -685,7 +685,7 @@ export const ChannelAccountSnapshotSchema = Type.Object( ); /** UI label and icon metadata for one channel. */ -export const ChannelUiMetaSchema = closedObject({ +const ChannelUiMetaSchema = closedObject({ id: NonEmptyString, label: NonEmptyString, detailLabel: NonEmptyString, @@ -693,7 +693,7 @@ export const ChannelUiMetaSchema = closedObject({ }); /** Event-loop health snapshot included with channel status responses. */ -export const ChannelEventLoopHealthSchema = closedObject({ +const ChannelEventLoopHealthSchema = closedObject({ degraded: Type.Boolean(), reasons: Type.Array( Type.Union([ diff --git a/packages/gateway-protocol/src/schema/commands.ts b/packages/gateway-protocol/src/schema/commands.ts index 49a1d3f44fd0..f1b01534b7c0 100644 --- a/packages/gateway-protocol/src/schema/commands.ts +++ b/packages/gateway-protocol/src/schema/commands.ts @@ -34,21 +34,21 @@ export const COMMAND_LIST_MAX_ITEMS = 500; const BoundedNonEmptyString = (maxLength: number) => Type.String({ minLength: 1, maxLength }); /** Source system that contributed a command. */ -export const CommandSourceSchema = Type.Union([ +const CommandSourceSchema = Type.Union([ Type.Literal("native"), Type.Literal("skill"), Type.Literal("plugin"), ]); /** Surfaces where a command may be invoked. */ -export const CommandScopeSchema = Type.Union([ +const CommandScopeSchema = Type.Union([ Type.Literal("text"), Type.Literal("native"), Type.Literal("both"), ]); /** Coarse UI grouping for command catalog display. */ -export const CommandCategorySchema = Type.Union([ +const CommandCategorySchema = Type.Union([ Type.Literal("session"), Type.Literal("options"), Type.Literal("status"), @@ -59,13 +59,13 @@ export const CommandCategorySchema = Type.Union([ ]); /** Static argument choice shown to clients. */ -export const CommandArgChoiceSchema = closedObject({ +const CommandArgChoiceSchema = closedObject({ value: Type.String({ maxLength: COMMAND_CHOICE_VALUE_MAX_LENGTH }), label: Type.String({ maxLength: COMMAND_CHOICE_LABEL_MAX_LENGTH }), }); /** One typed argument advertised for a command. */ -export const CommandArgSchema = closedObject({ +const CommandArgSchema = closedObject({ name: BoundedNonEmptyString(COMMAND_ARG_NAME_MAX_LENGTH), description: Type.String({ maxLength: COMMAND_ARG_DESCRIPTION_MAX_LENGTH }), type: Type.Union([Type.Literal("string"), Type.Literal("number"), Type.Literal("boolean")]), diff --git a/packages/gateway-protocol/src/schema/config.ts b/packages/gateway-protocol/src/schema/config.ts index 88d0cb2d85de..f1d796ccc253 100644 --- a/packages/gateway-protocol/src/schema/config.ts +++ b/packages/gateway-protocol/src/schema/config.ts @@ -74,7 +74,7 @@ export const UpdateRunParamsSchema = closedObject({ }); /** UI metadata attached to config schema paths. */ -export const ConfigUiHintSchema = closedObject({ +const ConfigUiHintSchema = closedObject({ label: Type.Optional(Type.String()), help: Type.Optional(Type.String()), tags: Type.Optional(Type.Array(Type.String())), @@ -95,7 +95,7 @@ export const ConfigSchemaResponseSchema = closedObject({ }); /** Child entry returned when looking up a config schema path. */ -export const ConfigSchemaLookupChildSchema = closedObject({ +const ConfigSchemaLookupChildSchema = closedObject({ key: NonEmptyString, path: NonEmptyString, type: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])), diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 1b053c6a71c6..c4d095c11862 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -203,7 +203,7 @@ const CronRunLogJobIdSchema = Type.String({ }); /** Schedule expression for one-time, interval, or cron-expression jobs. */ -export const CronScheduleSchema = Type.Union([ +const CronScheduleSchema = Type.Union([ closedObject({ kind: Type.Literal("at"), at: NonEmptyString, @@ -230,7 +230,7 @@ export const CronScheduleSchema = Type.Union([ ]); /** Headless condition script evaluated before a recurring cron payload runs. */ -export const CronTriggerSchema = closedObject({ +const CronTriggerSchema = closedObject({ script: Type.String({ minLength: 1, maxLength: 65_536 }), once: Type.Optional(Type.Boolean()), }); @@ -248,7 +248,7 @@ export const CronPacingSchema = Type.Object( ); /** Full cron payload for new jobs. */ -export const CronPayloadSchema = Type.Union([ +const CronPayloadSchema = Type.Union([ closedObject({ kind: Type.Literal("systemEvent"), text: NonEmptyString, @@ -273,7 +273,7 @@ export const CronPayloadSchema = Type.Union([ ]); /** Partial cron payload for job updates. */ -export const CronPayloadPatchSchema = Type.Union([ +const CronPayloadPatchSchema = Type.Union([ closedObject({ kind: Type.Literal("systemEvent"), text: Type.Optional(NonEmptyString), @@ -298,7 +298,7 @@ export const CronPayloadPatchSchema = Type.Union([ ]); /** Failure alert policy for repeated cron run failures. */ -export const CronFailureAlertSchema = closedObject({ +const CronFailureAlertSchema = closedObject({ after: Type.Optional(Type.Integer({ minimum: 1 })), channel: Type.Optional(CronAnnounceChannelSchema), to: Type.Optional(NonBlankString), @@ -319,7 +319,7 @@ const CronFailureAlertPatchSchema = closedObject({ }); /** Delivery destination used when failure alerts need a separate target. */ -export const CronFailureDestinationSchema = closedObject({ +const CronFailureDestinationSchema = closedObject({ channel: Type.Optional(CronAnnounceChannelSchema), to: Type.Optional(NonBlankString), accountId: Type.Optional(NonEmptyString), @@ -333,7 +333,7 @@ const CronFailureDestinationPatchSchema = closedObject({ mode: Type.Optional(Type.Union([Type.Literal("announce"), Type.Literal("webhook"), Type.Null()])), }); -export const CronCompletionDestinationSchema = closedObject({ +const CronCompletionDestinationSchema = closedObject({ mode: Type.Literal("webhook"), to: NonBlankString, }); @@ -381,7 +381,7 @@ export const CronDeliverySchema = Type.Union([ ]); /** Patch shape for cron delivery policy updates. */ -export const CronDeliveryPatchSchema = closedObject({ +const CronDeliveryPatchSchema = closedObject({ mode: Type.Optional( Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("webhook")]), ), @@ -533,7 +533,7 @@ export const CronDeclarativeAddResultSchema = closedObject({ export const CronAddResultSchema = Type.Union([CronJobSchema, CronDeclarativeAddResultSchema]); /** Mutable cron job fields accepted by update APIs. */ -export const CronJobPatchSchema = closedObject({ +const CronJobPatchSchema = closedObject({ name: Type.Optional(NonEmptyString), displayName: Type.Optional(Type.Union([CronDisplayNameSchema, Type.Null()])), ...CronCommonOptionalFields, diff --git a/packages/gateway-protocol/src/schema/environments.ts b/packages/gateway-protocol/src/schema/environments.ts index d6a30edcb0dc..7ba661df4973 100644 --- a/packages/gateway-protocol/src/schema/environments.ts +++ b/packages/gateway-protocol/src/schema/environments.ts @@ -66,7 +66,7 @@ export const EnvironmentSummarySchema = createEnvironmentSummarySchema(); export const EnvironmentsListParamsSchema = closedObject({}); /** Configured worker target exposed without provider settings or credentials. */ -export const WorkerEnvironmentProfileSummarySchema = closedObject({ +const WorkerEnvironmentProfileSummarySchema = closedObject({ id: NonEmptyString, providerId: NonEmptyString, }); @@ -105,7 +105,6 @@ export type EnvironmentStatus = Static; export type WorkerEnvironmentState = Static; export type WorkerTunnelStatus = Static; export type WorkerEnvironmentMetadata = Static; -export type WorkerEnvironmentProfileSummary = Static; export type EnvironmentSummary = Static; export type EnvironmentsCreateParams = Static; export type EnvironmentsCreateResult = Static; diff --git a/packages/gateway-protocol/src/schema/exec-approvals.ts b/packages/gateway-protocol/src/schema/exec-approvals.ts index f435416890b6..8faf8e7ddfa0 100644 --- a/packages/gateway-protocol/src/schema/exec-approvals.ts +++ b/packages/gateway-protocol/src/schema/exec-approvals.ts @@ -11,7 +11,7 @@ import { NonEmptyString } from "./primitives.js"; * persisted policy, request snapshots, and resolve decisions stay explicit. */ /** One persisted allowlist entry for a command pattern or resolved executable. */ -export const ExecApprovalsAllowlistEntrySchema = closedObject({ +const ExecApprovalsAllowlistEntrySchema = closedObject({ id: Type.Optional(NonEmptyString), pattern: Type.String(), source: Type.Optional(Type.Literal("allow-always")), @@ -49,16 +49,16 @@ const ExecApprovalsResolvedDefaultsSchema = closedObject({ }); /** Default exec approval policy shared by all agents unless overridden. */ -export const ExecApprovalsDefaultsSchema = closedObject(ExecApprovalsPolicyFields); +const ExecApprovalsDefaultsSchema = closedObject(ExecApprovalsPolicyFields); /** Agent-specific exec approval policy and allowlist. */ -export const ExecApprovalsAgentSchema = closedObject({ +const ExecApprovalsAgentSchema = closedObject({ ...ExecApprovalsPolicyFields, allowlist: Type.Optional(Type.Array(ExecApprovalsAllowlistEntrySchema)), }); /** Versioned exec approvals config file edited through gateway APIs. */ -export const ExecApprovalsFileSchema = closedObject({ +const ExecApprovalsFileSchema = closedObject({ version: Type.Literal(1), socket: Type.Optional( closedObject({ diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index fb597152b55f..8e438102e539 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -86,12 +86,11 @@ export type ChatMessageGetResult = Static; export const ChatAttachmentsSchema = Type.Array(Type.Unknown()); /** Opaque, out-of-band plugin bindings carried separately from model input. */ -export const RunToolBindingsSchema = Type.Record( +const RunToolBindingsSchema = Type.Record( Type.String({ minLength: 1, maxLength: 128 }), Type.Unknown(), { maxProperties: 16 }, ); -export type RunToolBindings = Static; /** User-to-agent send request; idempotency key lets clients safely retry transport failures. */ export const ChatSendParamsSchema = closedObject({ diff --git a/packages/gateway-protocol/src/schema/migrations.ts b/packages/gateway-protocol/src/schema/migrations.ts index daf4915eddea..7eca67f89732 100644 --- a/packages/gateway-protocol/src/schema/migrations.ts +++ b/packages/gateway-protocol/src/schema/migrations.ts @@ -10,7 +10,7 @@ const MemoryMigrationPlanFingerprintSchema = Type.String({ pattern: "^[a-f0-9]{64}$", }); -export const MemoryMigrationItemStatusSchema = Type.Union([ +const MemoryMigrationItemStatusSchema = Type.Union([ Type.Literal("planned"), Type.Literal("migrated"), Type.Literal("skipped"), @@ -19,7 +19,7 @@ export const MemoryMigrationItemStatusSchema = Type.Union([ Type.Literal("error"), ]); -export const MemoryMigrationItemSchema = Type.Object( +const MemoryMigrationItemSchema = Type.Object( { id: NonEmptyString, status: MemoryMigrationItemStatusSchema, @@ -32,7 +32,7 @@ export const MemoryMigrationItemSchema = Type.Object( { additionalProperties: false }, ); -export const MemoryMigrationSummarySchema = Type.Object( +const MemoryMigrationSummarySchema = Type.Object( { total: Type.Integer({ minimum: 0 }), planned: Type.Integer({ minimum: 0 }), @@ -45,7 +45,7 @@ export const MemoryMigrationSummarySchema = Type.Object( { additionalProperties: false }, ); -export const MemoryMigrationProviderPlanSchema = Type.Object( +const MemoryMigrationProviderPlanSchema = Type.Object( { providerId: NonEmptyString, label: NonEmptyString, @@ -74,7 +74,7 @@ export const MigrationsMemoryPlanParamsSchema = Type.Object( { additionalProperties: false }, ); -export const MigrationsMemoryPlanResultSchema = Type.Object( +const MigrationsMemoryPlanResultSchema = Type.Object( { agentId: NonEmptyString, workspace: NonEmptyString, @@ -99,7 +99,7 @@ export const MigrationsMemoryApplyParamsSchema = Type.Object( { additionalProperties: false }, ); -export const MigrationsMemoryApplyResultSchema = Type.Object( +const MigrationsMemoryApplyResultSchema = Type.Object( { providerId: NonEmptyString, source: NonEmptyString, @@ -124,10 +124,7 @@ export const MigrationProtocolSchemas = { MigrationsMemoryApplyResult: MigrationsMemoryApplyResultSchema, } as const; -export type MemoryMigrationItemStatus = Static; export type MemoryMigrationItem = Static; export type MemoryMigrationProviderPlan = Static; -export type MigrationsMemoryPlanParams = Static; export type MigrationsMemoryPlanResult = Static; -export type MigrationsMemoryApplyParams = Static; export type MigrationsMemoryApplyResult = Static; diff --git a/packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts b/packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts deleted file mode 100644 index ac4cfe94c049..000000000000 --- a/packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateNodeInvokeInputEvent } from "../index.js"; - -describe("node.invoke.input protocol", () => { - it("accepts ordered bounded frames and rejects oversized or open-ended payloads", () => { - expect( - validateNodeInvokeInputEvent({ - id: "invoke-1", - nodeId: "node-1", - seq: 0, - payloadJSON: '{"kind":"data","data":"x"}', - }), - ).toBe(true); - expect( - validateNodeInvokeInputEvent({ - id: "invoke-1", - nodeId: "node-1", - seq: 0, - payloadJSON: "x".repeat(16 * 1024 + 1), - }), - ).toBe(false); - expect( - validateNodeInvokeInputEvent({ - id: "invoke-1", - nodeId: "node-1", - seq: 0, - payloadJSON: "{}", - argv: ["sh"], - }), - ).toBe(false); - }); -}); diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index 47fd1a300ec4..a3659a7d8195 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -185,7 +185,7 @@ export const NodePendingDrainParamsSchema = closedObject({ }); /** One queued node-work item returned by pending-work drain calls. */ -export const NodePendingDrainItemSchema = closedObject({ +const NodePendingDrainItemSchema = closedObject({ id: NonEmptyString, type: NodePendingWorkTypeSchema, priority: Type.String({ enum: ["default", "normal", "high"] }), diff --git a/packages/gateway-protocol/src/schema/primitives.ts b/packages/gateway-protocol/src/schema/primitives.ts index 693a85319e04..298a53fd640c 100644 --- a/packages/gateway-protocol/src/schema/primitives.ts +++ b/packages/gateway-protocol/src/schema/primitives.ts @@ -49,13 +49,6 @@ export const GatewayClientIdSchema = Type.Enum(GATEWAY_CLIENT_IDS); /** Closed gateway client mode schema aligned with `GATEWAY_CLIENT_MODES`. */ export const GatewayClientModeSchema = Type.Enum(GATEWAY_CLIENT_MODES); -/** Supported secret reference backing stores for protocol SecretRef payloads. */ -export const SecretRefSourceSchema = Type.Union([ - Type.Literal("env"), - Type.Literal("file"), - Type.Literal("exec"), -]); - const SecretProviderAliasString = Type.String({ pattern: SECRET_PROVIDER_ALIAS_PATTERN.source, }); diff --git a/packages/gateway-protocol/src/schema/questions.ts b/packages/gateway-protocol/src/schema/questions.ts index 07108103130b..047472fa3e9d 100644 --- a/packages/gateway-protocol/src/schema/questions.ts +++ b/packages/gateway-protocol/src/schema/questions.ts @@ -3,8 +3,10 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; +import { withSince } from "./since.js"; const QuestionIdSchema = Type.String({ pattern: "^[a-z][a-z0-9_]*$" }); +// UI chip/tag display cap shared by every question input and output shape. const QuestionHeaderSchema = Type.String({ maxLength: 12 }); export const QuestionOptionSchema = closedObject({ @@ -13,8 +15,8 @@ export const QuestionOptionSchema = closedObject({ }); const QuestionInputFields = { - id: QuestionIdSchema, - header: Type.String(), + questionId: QuestionIdSchema, + header: QuestionHeaderSchema, question: NonEmptyString, options: Type.Array(QuestionOptionSchema, { maxItems: 4 }), multiSelect: Type.Optional(Type.Boolean()), @@ -27,14 +29,13 @@ export const QuestionRequestQuestionSchema = closedObject(QuestionInputFields); const QuestionFields = { ...QuestionInputFields, - header: QuestionHeaderSchema, }; /** Canonical normalized question shown to an operator. */ export const QuestionSchema = closedObject(QuestionFields); export const QuestionAnswersSchema = closedObject({ - answers: Type.Record(QuestionIdSchema, closedObject({ answers: Type.Array(Type.String()) })), + answers: Type.Record(QuestionIdSchema, Type.Array(Type.String())), }); export const QuestionStatusSchema = Type.Union([ @@ -112,16 +113,21 @@ export const QuestionListResultSchema = closedObject({ questions: Type.Array(QuestionRecordSchema), }); -export const QuestionRequestedEventSchema = QuestionRecordSchema; -export const QuestionResolvedEventSchema = Type.Union([ - closedObject({ - id: NonEmptyString, - status: Type.Literal("answered"), - answers: QuestionAnswersSchema, - }), - closedObject({ id: NonEmptyString, status: Type.Literal("cancelled") }), - closedObject({ id: NonEmptyString, status: Type.Literal("expired") }), -]); +// Native codegen intentionally reuses QuestionRecord for this event instead of +// registering a duplicate alias, so stamp the shared schema at the event site. +export const QuestionRequestedEventSchema = withSince("2026.7", QuestionRecordSchema); +export const QuestionResolvedEventSchema = withSince( + "2026.7", + Type.Union([ + closedObject({ + id: NonEmptyString, + status: Type.Literal("answered"), + answers: QuestionAnswersSchema, + }), + closedObject({ id: NonEmptyString, status: Type.Literal("cancelled") }), + closedObject({ id: NonEmptyString, status: Type.Literal("expired") }), + ]), +); export type QuestionOption = Static; export type Question = Static; diff --git a/packages/gateway-protocol/src/schema/session-placement.test.ts b/packages/gateway-protocol/src/schema/session-placement.test.ts index 44a7660cc2af..41b483617aea 100644 --- a/packages/gateway-protocol/src/schema/session-placement.test.ts +++ b/packages/gateway-protocol/src/schema/session-placement.test.ts @@ -4,9 +4,7 @@ import { SessionPlacementSchema, SessionPlacementStateSchema, validateSessionsDispatchParams, - validateSessionsDispatchResult, validateSessionsReclaimParams, - validateSessionsReclaimResult, } from "../index.js"; const placementStates = [ @@ -239,53 +237,6 @@ describe("session dispatch protocol schemas", () => { ).toBe(false); }); - it("accepts only active worker ownership in successful dispatch results", () => { - const active = { - state: "active" as const, - ...basePlacement, - ...workerOwnedFields, - }; - expect( - validateSessionsDispatchResult({ - ok: true, - key: "agent:main:dispatch", - sessionId: "session-1", - placement: active, - }), - ).toBe(true); - expect( - validateSessionsDispatchResult({ - ok: true, - key: "agent:main:dispatch", - sessionId: "session-1", - placement: { - state: "failed", - ...basePlacement, - recoveryError: "worker admission failed", - }, - }), - ).toBe(false); - }); - - it("accepts only reclaimed ownership in successful reclaim results", () => { - expect( - validateSessionsReclaimResult({ - ok: true, - key: "agent:main:dispatch", - sessionId: "session-1", - placement: { state: "reclaimed", ...basePlacement, ...workerOwnedFields }, - }), - ).toBe(true); - expect( - validateSessionsReclaimResult({ - ok: true, - key: "agent:main:dispatch", - sessionId: "session-1", - placement: { state: "active", ...basePlacement, ...workerOwnedFields }, - }), - ).toBe(false); - }); - it("rejects unknown placement fields", () => { expect( Value.Check(SessionPlacementSchema, { @@ -297,21 +248,7 @@ describe("session dispatch protocol schemas", () => { ).toBe(false); }); - it("rejects extra fields in dispatch params and results", () => { - const active = { - state: "active" as const, - ...basePlacement, - ...workerOwnedFields, - }; - expect( - validateSessionsDispatchResult({ - ok: true, - key: "agent:main:dispatch", - sessionId: "session-1", - placement: active, - extra: true, - }), - ).toBe(false); + it("rejects extra fields in dispatch params", () => { expect( validateSessionsDispatchParams({ key: "agent:main:dispatch", diff --git a/packages/gateway-protocol/src/schema/session-placement.ts b/packages/gateway-protocol/src/schema/session-placement.ts index 3c5e1bbe0b17..74cbb3d276d3 100644 --- a/packages/gateway-protocol/src/schema/session-placement.ts +++ b/packages/gateway-protocol/src/schema/session-placement.ts @@ -6,7 +6,6 @@ import { SESSION_PLACEMENT_STATES } from "./session-placement-state.js"; export { isCloudWorkerPlacementState, - SESSION_PLACEMENT_STATES, type SessionPlacementState, } from "./session-placement-state.js"; @@ -107,23 +106,23 @@ function createWorkerOwnedSessionPlacementSchema< }); } -export const LocalSessionPlacementSchema = createUnownedSessionPlacementSchema("local"); -export const RequestedSessionPlacementSchema = createUnownedSessionPlacementSchema("requested"); +const LocalSessionPlacementSchema = createUnownedSessionPlacementSchema("local"); +const RequestedSessionPlacementSchema = createUnownedSessionPlacementSchema("requested"); -export const ProvisioningSessionPlacementSchema = closedObject({ +const ProvisioningSessionPlacementSchema = closedObject({ state: Type.Literal("provisioning"), ...SessionPlacementTimingProperties, environmentId: Type.Optional(NonEmptyString), }); -export const SyncingSessionPlacementSchema = closedObject({ +const SyncingSessionPlacementSchema = closedObject({ state: Type.Literal("syncing"), ...SessionPlacementTimingProperties, environmentId: NonEmptyString, workerBundleHash: WorkerBundleHashSchema, }); -export const StartingSessionPlacementSchema = closedObject({ +const StartingSessionPlacementSchema = closedObject({ state: Type.Literal("starting"), ...SessionPlacementTimingProperties, environmentId: NonEmptyString, @@ -131,18 +130,17 @@ export const StartingSessionPlacementSchema = closedObject({ ...SessionPlacementWorkspaceProperties, }); -export const ActiveWorkerSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("active"); -export const DrainingSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("draining"); -export const ReconcilingSessionPlacementSchema = - createWorkerOwnedSessionPlacementSchema("reconciling"); +const ActiveWorkerSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("active"); +const DrainingSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("draining"); +const ReconcilingSessionPlacementSchema = createWorkerOwnedSessionPlacementSchema("reconciling"); -export const ReclaimedSessionPlacementSchema = closedObject({ +const ReclaimedSessionPlacementSchema = closedObject({ state: Type.Literal("reclaimed"), ...SessionPlacementTimingProperties, ...TerminalSessionPlacementProperties, }); -export const FailedSessionPlacementSchema = closedObject({ +const FailedSessionPlacementSchema = closedObject({ state: Type.Literal("failed"), ...SessionPlacementTimingProperties, ...TerminalSessionPlacementProperties, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts index 20767ebef195..e5e96a9e8410 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts @@ -64,10 +64,10 @@ describe("SessionsCatalogListParamsSchema", () => { ).toBe(true); }); - it("requires a catalog selector for host cursors", () => { + it("accepts flat optional catalog cursor fields", () => { expect( Value.Check(SessionsCatalogListParamsSchema, { cursors: { "gateway:local": "1" } }), - ).toBe(false); + ).toBe(true); expect( Value.Check(SessionsCatalogListParamsSchema, { catalogId: "claude", diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts index 33ab9bf266cf..98e6206211c9 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts @@ -6,6 +6,12 @@ import { NonEmptyString } from "./primitives.js"; const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString }); +export const SessionCatalogLocatorSchema = closedObject({ + catalogId: NonEmptyString, + hostId: NonEmptyString, + threadId: NonEmptyString, +}); + export const SessionCatalogCapabilitiesSchema = closedObject({ continueSession: Type.Boolean(), archive: Type.Boolean(), @@ -32,7 +38,7 @@ export const SessionCatalogSessionSchema = closedObject({ cliVersion: Type.Optional(Type.String()), gitBranch: Type.Optional(Type.String()), archived: Type.Boolean(), - openClawSessionKey: Type.Optional(NonEmptyString), + sessionKey: Type.Optional(NonEmptyString), canContinue: Type.Boolean(), canArchive: Type.Boolean(), canOpenTerminal: Type.Optional(Type.Boolean()), @@ -65,17 +71,11 @@ const SessionsCatalogListCommonProperties = { hostIds: Type.Optional(Type.Array(NonEmptyString)), }; -export const SessionsCatalogListParamsSchema = Type.Union([ - closedObject({ - catalogId: Type.Optional(NonEmptyString), - ...SessionsCatalogListCommonProperties, - }), - closedObject({ - catalogId: NonEmptyString, - cursors: Type.Record(NonEmptyString, Type.String()), - ...SessionsCatalogListCommonProperties, - }), -]); +export const SessionsCatalogListParamsSchema = closedObject({ + catalogId: Type.Optional(NonEmptyString), + cursors: Type.Optional(Type.Record(NonEmptyString, Type.String())), + ...SessionsCatalogListCommonProperties, +}); export const SessionsCatalogListResultSchema = closedObject({ catalogs: Type.Array(SessionCatalogSchema), @@ -110,9 +110,7 @@ export const SessionCatalogTranscriptItemSchema = closedObject({ }); export const SessionsCatalogReadParamsSchema = closedObject({ - catalogId: NonEmptyString, - hostId: NonEmptyString, - threadId: NonEmptyString, + ...SessionCatalogLocatorSchema.properties, limit: Type.Optional(Type.Integer({ minimum: 1 })), cursor: Type.Optional(Type.String()), }); @@ -126,23 +124,20 @@ export const SessionsCatalogReadResultSchema = closedObject({ }); export const SessionsCatalogContinueParamsSchema = closedObject({ - catalogId: NonEmptyString, - hostId: NonEmptyString, - threadId: NonEmptyString, + ...SessionCatalogLocatorSchema.properties, }); export const SessionsCatalogContinueResultSchema = closedObject({ sessionKey: NonEmptyString }); export const SessionsCatalogArchiveParamsSchema = closedObject({ - catalogId: NonEmptyString, - hostId: NonEmptyString, - threadId: NonEmptyString, + ...SessionCatalogLocatorSchema.properties, confirmNoOtherRunner: Type.Literal(true), }); export const SessionsCatalogArchiveResultSchema = closedObject({ ok: Type.Literal(true) }); export type SessionCatalogCapabilities = Static; +export type SessionCatalogLocator = Static; export type SessionCatalogDescriptor = Static; export type SessionCatalogSession = Static; export type SessionCatalogHost = Static; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 42dffa29bcd4..8d984103bcf2 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -18,7 +18,7 @@ export { SessionsCreateParamsSchema }; */ /** Reason a compaction checkpoint was created. */ -export const SessionCompactionCheckpointReasonSchema = Type.Union([ +const SessionCompactionCheckpointReasonSchema = Type.Union([ Type.Literal("manual"), Type.Literal("auto-threshold"), Type.Literal("overflow-retry"), @@ -38,7 +38,7 @@ export const SessionOperationEventSchema = closedObject({ }); /** Reference to the transcript location before or after compaction. */ -export const SessionCompactionTranscriptReferenceSchema = closedObject({ +const SessionCompactionTranscriptReferenceSchema = closedObject({ sessionId: NonEmptyString, sessionFile: Type.Optional(NonEmptyString), leafId: Type.Optional(NonEmptyString), @@ -649,9 +649,20 @@ export const SessionsUsageParamsSchema = closedObject({ /** Usage row grouping. `family` rolls up known rotated session ids for a logical key. */ groupBy: Type.Optional(Type.Union([Type.Literal("instance"), Type.Literal("family")])), /** Backward-compatible alias for requesting family grouping. */ - includeHistorical: Type.Optional(Type.Boolean()), + includeHistorical: Type.Optional( + Type.Boolean({ + deprecated: true, + description: "Deprecated alias for groupBy: family.", + }), + ), /** UTC offset to use when mode is `specific` (for example, UTC-4 or UTC+5:30). */ - utcOffset: Type.Optional(Type.String({ pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$" })), + utcOffset: Type.Optional( + Type.String({ + pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$", + deprecated: true, + description: "Deprecated compatibility fallback; use timeZone.", + }), + ), /** IANA time zone for `specific`; preferred over `utcOffset`, which remains a compatibility fallback. */ timeZone: Type.Optional(NonEmptyString), /** Maximum sessions to return (default 50). */ diff --git a/packages/gateway-protocol/src/schema/since.ts b/packages/gateway-protocol/src/schema/since.ts new file mode 100644 index 000000000000..5d73a1cfe324 --- /dev/null +++ b/packages/gateway-protocol/src/schema/since.ts @@ -0,0 +1,7 @@ +import type { TSchema } from "typebox"; + +/** Adds protocol-vintage metadata without changing the schema's validated value shape. */ +export function withSince(train: string, schema: T): T { + Object.assign(schema, { "x-openclaw-since": train }); + return schema; +} diff --git a/packages/gateway-protocol/src/schema/snapshot.ts b/packages/gateway-protocol/src/schema/snapshot.ts index 058b5a9abd83..19757373f7bd 100644 --- a/packages/gateway-protocol/src/schema/snapshot.ts +++ b/packages/gateway-protocol/src/schema/snapshot.ts @@ -41,11 +41,150 @@ export const PresenceEntrySchema = closedObject({ watchedSessions: Type.Optional(Type.Array(NonEmptyString)), }); -/** Health snapshot is intentionally opaque because providers contribute nested shapes. */ -export const HealthSnapshotSchema = Type.Any(); +const HealthSessionSummarySchema = closedObject({ + path: Type.String(), + count: Type.Integer({ minimum: 0 }), + recent: Type.Array( + closedObject({ + key: Type.String(), + updatedAt: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + age: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + }), + ), +}); + +const HealthSnapshotSchema = closedObject({ + // Every field is optional because hello snapshots use an empty object until + // the asynchronous health producer has populated the cache. + ok: Type.Optional(Type.Literal(true)), + ts: Type.Optional(Type.Integer({ minimum: 0 })), + durationMs: Type.Optional(Type.Integer({ minimum: 0 })), + eventLoop: Type.Optional( + closedObject({ + degraded: Type.Boolean(), + reasons: Type.Array( + Type.Union([ + Type.Literal("event_loop_delay"), + Type.Literal("event_loop_utilization"), + Type.Literal("cpu"), + ]), + ), + intervalMs: Type.Number({ minimum: 0 }), + delayP99Ms: Type.Number({ minimum: 0 }), + delayMaxMs: Type.Number({ minimum: 0 }), + utilization: Type.Number({ minimum: 0 }), + cpuCoreRatio: Type.Number({ minimum: 0 }), + }), + ), + plugins: Type.Optional( + closedObject({ + loaded: Type.Array(Type.String()), + errors: Type.Array( + closedObject({ + id: Type.String(), + origin: Type.String(), + activated: Type.Boolean(), + activationSource: Type.Optional(Type.String()), + activationReason: Type.Optional(Type.String()), + failurePhase: Type.Optional(Type.String()), + error: Type.String(), + }), + ), + unavailable: Type.Optional( + Type.Array( + closedObject({ + id: Type.String(), + state: Type.Literal("configured-unavailable"), + diagnostic: closedObject({ + kind: Type.Literal("plugin-verification"), + reason: Type.String(), + detail: Type.String(), + }), + }), + ), + ), + }), + ), + contextEngines: Type.Optional( + closedObject({ + quarantined: Type.Array( + closedObject({ + engineId: Type.String(), + owner: Type.Optional(Type.String()), + operation: Type.String(), + reason: Type.String(), + failedAt: Type.Integer({ minimum: 0 }), + }), + ), + }), + ), + deliveryQueues: Type.Optional( + closedObject({ + failed: Type.Array( + closedObject({ + queueName: Type.String(), + count: Type.Integer({ minimum: 0 }), + oldestFailedAt: Type.Optional(Type.Integer({ minimum: 0 })), + }), + ), + }), + ), + modelPricing: Type.Optional( + closedObject({ + state: Type.Union([Type.Literal("ok"), Type.Literal("degraded"), Type.Literal("disabled")]), + sources: Type.Array( + closedObject({ + source: Type.Union([ + Type.Literal("openrouter"), + Type.Literal("litellm"), + Type.Literal("bootstrap"), + Type.Literal("refresh"), + ]), + state: Type.Union([Type.Literal("ok"), Type.Literal("degraded")]), + lastFailureAt: Type.Optional(Type.Integer({ minimum: 0 })), + detail: Type.Optional(Type.String()), + }), + ), + lastFailureAt: Type.Optional(Type.Integer({ minimum: 0 })), + detail: Type.Optional(Type.String()), + }), + ), + configReload: Type.Optional( + closedObject({ + hotReloadStatus: Type.Union([Type.Literal("active"), Type.Literal("disabled")]), + }), + ), + // Channel plugins own their nested account/probe summaries, so this is the + // one provider-contributed bag that deliberately remains unknown. + channels: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + channelOrder: Type.Optional(Type.Array(Type.String())), + channelLabels: Type.Optional(Type.Record(Type.String(), Type.String())), + heartbeatSeconds: Type.Optional(Type.Integer({ minimum: 0 })), + defaultAgentId: Type.Optional(Type.String()), + agents: Type.Optional( + Type.Array( + closedObject({ + agentId: Type.String(), + name: Type.Optional(Type.String()), + isDefault: Type.Boolean(), + heartbeat: closedObject({ + enabled: Type.Boolean(), + every: Type.String(), + everyMs: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + prompt: Type.String(), + target: Type.String(), + model: Type.Optional(Type.String()), + ackMaxChars: Type.Integer({ minimum: 0 }), + }), + sessions: HealthSessionSummarySchema, + }), + ), + ), + sessions: Type.Optional(HealthSessionSummarySchema), +}); /** Default session routing keys included in initial gateway snapshots. */ -export const SessionDefaultsSchema = closedObject({ +const SessionDefaultsSchema = closedObject({ defaultAgentId: NonEmptyString, mainKey: NonEmptyString, mainSessionKey: NonEmptyString, diff --git a/packages/gateway-protocol/src/schema/system-event.ts b/packages/gateway-protocol/src/schema/system-event.ts index 143330031c07..3ad33d289ccc 100644 --- a/packages/gateway-protocol/src/schema/system-event.ts +++ b/packages/gateway-protocol/src/schema/system-event.ts @@ -1,11 +1,10 @@ // Gateway Protocol schema module defines operator-originated system events. -import type { Static } from "typebox"; import { Type } from "typebox"; import { lazyCompile } from "../protocol-validator.js"; import { closedObject } from "./closed-object.js"; /** Operator event plus optional presence metadata and exact-session wake routing. */ -export const SystemEventParamsSchema = closedObject({ +const SystemEventParamsSchema = closedObject({ text: Type.String(), idempotencyKey: Type.Optional(Type.String({ minLength: 1 })), sessionKey: Type.Optional(Type.String()), @@ -26,5 +25,4 @@ export const SystemEventParamsSchema = closedObject({ tags: Type.Optional(Type.Array(Type.String())), }); -export type SystemEventParams = Static; export const validateSystemEventParams = lazyCompile(SystemEventParamsSchema); diff --git a/packages/gateway-protocol/src/schema/tasks.ts b/packages/gateway-protocol/src/schema/tasks.ts index 2b3141e5308f..b119e7d49520 100644 --- a/packages/gateway-protocol/src/schema/tasks.ts +++ b/packages/gateway-protocol/src/schema/tasks.ts @@ -11,7 +11,7 @@ import { NonEmptyString } from "./primitives.js"; * these schemas keep list/get/cancel payloads bounded and status values closed. */ /** Closed task lifecycle statuses visible in the gateway task ledger. */ -export const TaskLedgerStatusSchema = Type.Union([ +const TaskLedgerStatusSchema = Type.Union([ Type.Literal("queued"), Type.Literal("running"), Type.Literal("completed"), diff --git a/packages/gateway-protocol/src/terminal-upload-constants.ts b/packages/gateway-protocol/src/schema/terminal-constants.ts similarity index 100% rename from packages/gateway-protocol/src/terminal-upload-constants.ts rename to packages/gateway-protocol/src/schema/terminal-constants.ts diff --git a/packages/gateway-protocol/src/schema/terminal.test.ts b/packages/gateway-protocol/src/schema/terminal.test.ts index a07a76952381..8475e476fe15 100644 --- a/packages/gateway-protocol/src/schema/terminal.test.ts +++ b/packages/gateway-protocol/src/schema/terminal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { validateTerminalOpenParams, validateTerminalUploadParams } from "../index.js"; -import { MAX_TERMINAL_UPLOAD_BASE64_LENGTH } from "../terminal-upload-constants.js"; +import { MAX_TERMINAL_UPLOAD_BASE64_LENGTH } from "./terminal-constants.js"; describe("terminal protocol", () => { it("accepts a typed catalog reference and rejects client command fields", () => { diff --git a/packages/gateway-protocol/src/schema/terminal.ts b/packages/gateway-protocol/src/schema/terminal.ts index 210647d4a1ed..5397a5e7c5ca 100644 --- a/packages/gateway-protocol/src/schema/terminal.ts +++ b/packages/gateway-protocol/src/schema/terminal.ts @@ -3,13 +3,15 @@ // operator connection and stream its bytes back over the existing WebSocket. import type { Static } from "typebox"; import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; +import { SessionCatalogLocatorSchema } from "./sessions-catalog.js"; +import { withSince } from "./since.js"; import { MAX_TERMINAL_UPLOAD_BASE64_LENGTH, MAX_TERMINAL_UPLOAD_BYTES, MAX_TERMINAL_UPLOAD_NAME_LENGTH, -} from "../terminal-upload-constants.js"; -import { closedObject } from "./closed-object.js"; -import { NonEmptyString } from "./primitives.js"; +} from "./terminal-constants.js"; // PTY grids are bounded so a hostile client cannot request an allocation that // overflows the terminal backend's row/column math. @@ -20,13 +22,7 @@ export const TerminalOpenParamsSchema = closedObject({ // Optional agent selector; defaults to the gateway's default agent. The // session starts in that agent's workspace and inherits its isolation. agentId: Type.Optional(NonEmptyString), - catalog: Type.Optional( - closedObject({ - catalogId: NonEmptyString, - hostId: NonEmptyString, - threadId: NonEmptyString, - }), - ), + catalog: Type.Optional(SessionCatalogLocatorSchema), cols: TerminalDimension, rows: TerminalDimension, }); @@ -143,35 +139,44 @@ export const TerminalAckResultSchema = closedObject({ ok: Type.Boolean() }); export type TerminalAckResult = Static; /** Streamed output chunk; seq is its cumulative UTF-16 end offset within the session. */ -export const TerminalDataEventSchema = closedObject({ - sessionId: NonEmptyString, - seq: Type.Integer({ minimum: 0 }), - data: Type.String(), -}); +export const TerminalDataEventSchema = withSince( + "2026.7", + closedObject({ + sessionId: NonEmptyString, + seq: Type.Integer({ minimum: 0 }), + data: Type.String(), + }), +); export type TerminalDataEvent = Static; /** Terminal end-of-life notice; the session id is invalid after this event. */ -export const TerminalExitEventSchema = closedObject({ - sessionId: NonEmptyString, - exitCode: Type.Optional(Type.Union([Type.Integer(), Type.Null()])), - signal: Type.Optional(Type.Union([Type.Integer(), Type.Null()])), - // Stable reason code so clients can distinguish process exit from a - // server-side teardown (disconnect, idle sweep, config disable). - reason: Type.Optional( - Type.Union([ - Type.Literal("process_exit"), - Type.Literal("closed"), - Type.Literal("disconnected"), - // Another admin connection attached the session away; the session is - // still alive server-side, but no longer streams to this connection. - Type.Literal("detached"), - Type.Literal("error"), - ]), - ), - error: Type.Optional(Type.String()), -}); +export const TerminalExitEventSchema = withSince( + "2026.7", + closedObject({ + sessionId: NonEmptyString, + exitCode: Type.Optional(Type.Union([Type.Integer(), Type.Null()])), + signal: Type.Optional(Type.Union([Type.Integer(), Type.Null()])), + // Stable reason code so clients can distinguish process exit from a + // server-side teardown (disconnect, idle sweep, config disable). + reason: Type.Optional( + Type.Union([ + Type.Literal("process_exit"), + Type.Literal("closed"), + Type.Literal("disconnected"), + // Another admin connection attached the session away; the session is + // still alive server-side, but no longer streams to this connection. + Type.Literal("detached"), + Type.Literal("error"), + ]), + ), + error: Type.Optional(Type.String()), + }), +); export type TerminalExitEvent = Static; /** Union of every event a terminal session can emit. */ -export const TerminalEventSchema = Type.Union([TerminalDataEventSchema, TerminalExitEventSchema]); +export const TerminalEventSchema = withSince( + "2026.7", + Type.Union([TerminalDataEventSchema, TerminalExitEventSchema]), +); export type TerminalEvent = Static; diff --git a/packages/gateway-protocol/src/schema/wizard.ts b/packages/gateway-protocol/src/schema/wizard.ts index 01cc14d543f8..1412757e0ca5 100644 --- a/packages/gateway-protocol/src/schema/wizard.ts +++ b/packages/gateway-protocol/src/schema/wizard.ts @@ -24,7 +24,7 @@ export const WizardStartParamsSchema = closedObject({ }); /** Client answer payload for the current wizard step. */ -export const WizardAnswerSchema = closedObject({ +const WizardAnswerSchema = closedObject({ stepId: NonEmptyString, value: Type.Optional(Type.Unknown()), }); @@ -47,7 +47,7 @@ export const WizardCancelParamsSchema = WizardSessionIdParamsSchema; export const WizardStatusParamsSchema = WizardSessionIdParamsSchema; /** Selectable value shown in a choice-based wizard step. */ -export const WizardStepOptionSchema = closedObject({ +const WizardStepOptionSchema = closedObject({ value: Type.Unknown(), label: NonEmptyString, hint: Type.Optional(Type.String()), diff --git a/packages/gateway-protocol/src/schema/worker-admission.ts b/packages/gateway-protocol/src/schema/worker-admission.ts index 22c1a5317dde..d6d827506f4a 100644 --- a/packages/gateway-protocol/src/schema/worker-admission.ts +++ b/packages/gateway-protocol/src/schema/worker-admission.ts @@ -1,6 +1,29 @@ import { Type, type Static, type TProperties } from "typebox"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js"; import { closedObject } from "./closed-object.js"; +import { withSince } from "./since.js"; +import { + LiveIntegerSchema, + LiveSequenceSchema, + LiveTextSchema, + WORKER_PROTOCOL_MAX_PAYLOAD_BYTES, + WorkerAdmissionFailureReasonSchema, + WorkerErrorResponseFrameSchema, + WorkerErrorShapeSchema, + WorkerFrameIdSchema, + WorkerIdentifierSchema, + WorkerProtocolCloseReasonSchema, + WorkerTranscriptAssistantDiagnosticSchema, + WorkerTranscriptUsageSchema, +} from "./worker-protocol-primitives.js"; + +export { + WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH, + WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH, + WORKER_PROTOCOL_MAX_PAYLOAD_BYTES, + WorkerAdmissionFailureReasonSchema, + WorkerProtocolCloseReasonSchema, +} from "./worker-protocol-primitives.js"; // Additive RPCs require exact build-bound features; bump only for an incompatible base set. export const WORKER_RPC_SET_VERSION = 1; @@ -18,26 +41,14 @@ export const WORKER_PROTOCOL_FEATURES = [ WORKER_LIVE_EVENT_PROTOCOL_FEATURE, "worker-inference-v1", ] as const; -export const WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH = 256; -export const WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH = 128; export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64; -export const WORKER_PROTOCOL_MAX_PAYLOAD_BYTES = 64 * 1024; export const WORKER_PROTOCOL_MAX_FEATURES = 64; export const WORKER_PROTOCOL_MAX_FEATURE_LENGTH = 128; export const WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES = 64; export const WORKER_TRANSCRIPT_MAX_CONTENT_PARTS = 128; export const WORKER_TRANSCRIPT_MAX_JSON_DEPTH = 32; -const WorkerIdentifierSchema = Type.String({ - minLength: 1, - maxLength: WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH, - pattern: "^\\S(?:.*\\S)?$", -}); const WorkerCredentialSchema = Type.String({ minLength: 16, maxLength: 256 }); -const WorkerFrameIdSchema = Type.String({ - minLength: 1, - maxLength: WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH, -}); const WorkerProtocolFeatureSchema = Type.String({ minLength: 1, maxLength: WORKER_PROTOCOL_MAX_FEATURE_LENGTH, @@ -49,14 +60,17 @@ const WorkerBundleHashSchema = Type.String({ }); /** Build identity presented by a worker before the gateway admits it. */ -export const WorkerAdmissionHandshakeSchema = closedObject({ - bundleHash: WorkerBundleHashSchema, - openclawVersion: Type.String({ minLength: 1, maxLength: 128 }), - protocolFeatures: Type.Array(WorkerProtocolFeatureSchema, { - maxItems: WORKER_PROTOCOL_MAX_FEATURES, - uniqueItems: true, +export const WorkerAdmissionHandshakeSchema = withSince( + "2026.7", + closedObject({ + bundleHash: WorkerBundleHashSchema, + openclawVersion: Type.String({ minLength: 1, maxLength: 128 }), + protocolFeatures: Type.Array(WorkerProtocolFeatureSchema, { + maxItems: WORKER_PROTOCOL_MAX_FEATURES, + uniqueItems: true, + }), }), -}); +); const WorkerConnectAdmissionCommonProperties = { environmentId: WorkerIdentifierSchema, @@ -80,7 +94,7 @@ const WorkerConnectAdmissionSchema = Type.Union([ ]); /** Dedicated first-frame payload accepted only on the worker ingress. */ -export const WorkerConnectParamsSchema = closedObject({ +const WorkerConnectParamsSchema = closedObject({ minProtocol: Type.Integer({ minimum: 1 }), maxProtocol: Type.Integer({ minimum: 1 }), client: closedObject({ @@ -100,50 +114,8 @@ export const WorkerConnectRequestFrameSchema = closedObject({ params: WorkerConnectParamsSchema, }); -export const WorkerAdmissionFailureReasonSchema = Type.Union([ - Type.Literal("invalid-credential"), - Type.Literal("credential-expired"), - Type.Literal("environment-mismatch"), - Type.Literal("environment-unavailable"), - Type.Literal("bundle-mismatch"), - Type.Literal("version-mismatch"), - Type.Literal("session-mismatch"), - Type.Literal("placement-mismatch"), - Type.Literal("owner-epoch-mismatch"), - Type.Literal("rpc-set-mismatch"), - Type.Literal("protocol-features-mismatch"), -]); - -export const WorkerProtocolCloseReasonSchema = Type.Union([ - WorkerAdmissionFailureReasonSchema, - Type.Literal("invalid-handshake"), - Type.Literal("protocol-mismatch"), - Type.Literal("gateway-unavailable"), - Type.Literal("invalid-frame"), - Type.Literal("slow-consumer"), - Type.Literal("method-not-allowed"), - Type.Literal("invalid-heartbeat"), - Type.Literal("credential-replaced"), - Type.Literal("gateway-shutdown"), -]); - -const WorkerErrorCodeSchema = Type.Union([ - Type.Literal("INVALID_REQUEST"), - Type.Literal("UNAVAILABLE"), -]); - -const WorkerErrorDetailsSchema = closedObject({ reason: WorkerProtocolCloseReasonSchema }); - -export const WorkerErrorShapeSchema = closedObject({ - code: WorkerErrorCodeSchema, - message: Type.String({ minLength: 1, maxLength: 256 }), - details: WorkerErrorDetailsSchema, - retryable: Type.Optional(Type.Boolean()), - retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })), -}); - /** Minimal admission response; workers never receive the general gateway snapshot. */ -export const WorkerHelloOkSchema = closedObject({ +const WorkerHelloOkSchema = closedObject({ type: Type.Literal("worker-hello-ok"), environmentId: WorkerIdentifierSchema, sessionId: Type.Union([WorkerIdentifierSchema, Type.Null()]), @@ -160,13 +132,6 @@ export const WorkerHelloOkSchema = closedObject({ }), }); -const WorkerErrorResponseFrameSchema = closedObject({ - type: Type.Literal("res"), - id: WorkerFrameIdSchema, - ok: Type.Literal(false), - error: WorkerErrorShapeSchema, -}); - const WorkerAdmissionSuccessResponseFrameSchema = closedObject({ type: Type.Literal("res"), id: WorkerFrameIdSchema, @@ -190,7 +155,7 @@ export const WorkerHeartbeatParamsSchema = closedObject({ status: WorkerStatusSchema, }); -export const WorkerHeartbeatResultSchema = closedObject({ +const WorkerHeartbeatResultSchema = closedObject({ receivedAtMs: Type.Integer({ minimum: 0 }), status: Type.Literal("ok"), ownerEpoch: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), @@ -249,48 +214,6 @@ const WorkerTranscriptToolCallSchema = closedObject({ executionMode: Type.Optional(Type.Union([Type.Literal("sequential"), Type.Literal("parallel")])), }); -const WorkerTranscriptUsageSchema = closedObject({ - input: Type.Number({ minimum: 0 }), - output: Type.Number({ minimum: 0 }), - cacheRead: Type.Number({ minimum: 0 }), - cacheWrite: Type.Number({ minimum: 0 }), - contextUsage: Type.Optional( - Type.Union([ - closedObject({ - state: Type.Literal("available"), - promptTokens: Type.Number({ minimum: 0 }), - totalTokens: Type.Number({ minimum: 0 }), - }), - closedObject({ state: Type.Literal("unavailable") }), - ]), - ), - totalTokens: Type.Number({ minimum: 0 }), - cost: closedObject({ - input: Type.Number({ minimum: 0 }), - output: Type.Number({ minimum: 0 }), - cacheRead: Type.Number({ minimum: 0 }), - cacheWrite: Type.Number({ minimum: 0 }), - total: Type.Number({ minimum: 0 }), - totalOrigin: Type.Optional(Type.Literal("provider-billed")), - }), -}); - -const WorkerTranscriptAssistantDiagnosticSchema = closedObject({ - type: WorkerIdentifierSchema, - timestamp: Type.Integer({ minimum: 0 }), - error: Type.Optional( - closedObject({ - name: Type.Optional(Type.String({ maxLength: 256 })), - message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }), - stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })), - code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])), - }), - ), - details: Type.Optional( - Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()), - ), -}); - const WorkerTranscriptUserMessageSchema = closedObject({ role: Type.Literal("user"), content: Type.Array( @@ -416,19 +339,8 @@ function workerLiveObject(properties: Prop return closedObject(properties); } -const LiveTextSchema = Type.String({ - maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES, -}); const OptionalLiveTextSchema = Type.Optional(LiveTextSchema); -const LiveIntegerSchema = Type.Integer({ - minimum: 0, - maximum: Number.MAX_SAFE_INTEGER, -}); const OptionalLiveIntegerSchema = Type.Optional(LiveIntegerSchema); -const LiveSequenceSchema = Type.Integer({ - minimum: 1, - maximum: Number.MAX_SAFE_INTEGER, -}); const LiveIdentifierSchema = Type.String({ minLength: 1, diff --git a/packages/gateway-protocol/src/schema/worker-inference.ts b/packages/gateway-protocol/src/schema/worker-inference.ts index 1fea891efb09..e4b0aabf5d15 100644 --- a/packages/gateway-protocol/src/schema/worker-inference.ts +++ b/packages/gateway-protocol/src/schema/worker-inference.ts @@ -1,16 +1,20 @@ -import { Type, type TProperties, type TSchema } from "typebox"; +import { Type, type Static, type TProperties } from "typebox"; import { Value } from "typebox/value"; import { closedObject } from "./closed-object.js"; import { - WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH, - WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH, - WORKER_PROTOCOL_MAX_PAYLOAD_BYTES, WORKER_TRANSCRIPT_MAX_CONTENT_PARTS, WORKER_TRANSCRIPT_MAX_JSON_DEPTH, - type WorkerErrorShape, - WorkerErrorShapeSchema, - type WorkerTranscriptMessage, } from "./worker-admission.js"; +import { + LiveIntegerSchema, + LiveSequenceSchema, + LiveTextSchema, + WorkerErrorResponseFrameSchema, + WorkerFrameIdSchema, + WorkerIdentifierSchema, + WorkerTranscriptAssistantDiagnosticSchema, + WorkerTranscriptUsageSchema, +} from "./worker-protocol-primitives.js"; export const WORKER_INFERENCE_PROTOCOL_FEATURE = "worker-inference-v1"; export const WORKER_INFERENCE_METHODS = [ @@ -19,87 +23,17 @@ export const WORKER_INFERENCE_METHODS = [ ] as const; export const WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES = 25 * 1024 * 1024; export const WORKER_INFERENCE_MAX_CONTEXT_MESSAGES = 1_024; -export const WORKER_INFERENCE_MAX_TOOLS = 256; +const WORKER_INFERENCE_MAX_TOOLS = 256; export const WORKER_INFERENCE_MAX_OUTPUT_TOKENS = 1_000_000; -const WorkerIdentifierSchema = Type.String({ - minLength: 1, - maxLength: WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH, - pattern: "^\\S(?:.*\\S)?$", -}); -const WorkerFrameIdSchema = Type.String({ - minLength: 1, - maxLength: WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH, -}); -const WorkerErrorResponseFrameSchema = closedObject({ - type: Type.Literal("res"), - id: WorkerFrameIdSchema, - ok: Type.Literal(false), - error: WorkerErrorShapeSchema, -}); - function workerInferenceObject(properties: Properties) { return closedObject(properties); } -const LiveTextSchema = Type.String({ - maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES, -}); const InferenceTextSchema = Type.String({ maxLength: WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES, }); const OptionalInferenceTextSchema = Type.Optional(InferenceTextSchema); -const LiveIntegerSchema = Type.Integer({ - minimum: 0, - maximum: Number.MAX_SAFE_INTEGER, -}); -const LiveSequenceSchema = Type.Integer({ - minimum: 1, - maximum: Number.MAX_SAFE_INTEGER, -}); - -const WorkerTranscriptUsageSchema = closedObject({ - input: Type.Number({ minimum: 0 }), - output: Type.Number({ minimum: 0 }), - cacheRead: Type.Number({ minimum: 0 }), - cacheWrite: Type.Number({ minimum: 0 }), - contextUsage: Type.Optional( - Type.Union([ - closedObject({ - state: Type.Literal("available"), - promptTokens: Type.Number({ minimum: 0 }), - totalTokens: Type.Number({ minimum: 0 }), - }), - closedObject({ state: Type.Literal("unavailable") }), - ]), - ), - totalTokens: Type.Number({ minimum: 0 }), - cost: closedObject({ - input: Type.Number({ minimum: 0 }), - output: Type.Number({ minimum: 0 }), - cacheRead: Type.Number({ minimum: 0 }), - cacheWrite: Type.Number({ minimum: 0 }), - total: Type.Number({ minimum: 0 }), - totalOrigin: Type.Optional(Type.Literal("provider-billed")), - }), -}); - -const WorkerTranscriptAssistantDiagnosticSchema = closedObject({ - type: WorkerIdentifierSchema, - timestamp: Type.Integer({ minimum: 0 }), - error: Type.Optional( - closedObject({ - name: Type.Optional(Type.String({ maxLength: 256 })), - message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }), - stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })), - code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])), - }), - ), - details: Type.Optional( - Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()), - ), -}); - const WorkerInferenceTextContentSchema = workerInferenceObject({ type: Type.Literal("text"), text: InferenceTextSchema, @@ -211,12 +145,12 @@ const WorkerInferenceToolSchema = workerInferenceObject({ parameters: Type.Unknown(), }); -export const WorkerInferenceModelRefSchema: TSchema = workerInferenceObject({ +export const WorkerInferenceModelRefSchema = workerInferenceObject({ provider: WorkerIdentifierSchema, model: WorkerIdentifierSchema, }); -export const WorkerInferenceContextSchema: TSchema = workerInferenceObject({ +const WorkerInferenceContextSchema = workerInferenceObject({ systemPrompt: Type.Optional(InferenceTextSchema), messages: Type.Array(WorkerInferenceMessageSchema, { maxItems: WORKER_INFERENCE_MAX_CONTEXT_MESSAGES, @@ -250,7 +184,7 @@ const WorkerInferenceThinkingBudgetsSchema = workerInferenceObject({ max: Type.Optional(WorkerInferenceThinkingBudgetSchema), }); -export const WorkerInferenceOptionsSchema: TSchema = workerInferenceObject({ +export const WorkerInferenceOptionsSchema = workerInferenceObject({ temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })), maxTokens: Type.Optional( Type.Integer({ minimum: 1, maximum: WORKER_INFERENCE_MAX_OUTPUT_TOKENS }), @@ -266,18 +200,18 @@ const WorkerInferenceIdentityProperties = { turnId: WorkerIdentifierSchema, }; -export const WorkerInferenceStartParamsSchema: TSchema = workerInferenceObject({ +const WorkerInferenceStartParamsSchema = workerInferenceObject({ ...WorkerInferenceIdentityProperties, modelRef: WorkerInferenceModelRefSchema, context: WorkerInferenceContextSchema, options: WorkerInferenceOptionsSchema, }); -export const WorkerInferenceStartResultSchema: TSchema = workerInferenceObject({ +const WorkerInferenceStartResultSchema = workerInferenceObject({ status: Type.Union([Type.Literal("accepted"), Type.Literal("replayed")]), }); -export const WorkerInferenceErrorReasonSchema: TSchema = Type.Union([ +const WorkerInferenceErrorReasonSchema = Type.Union([ Type.Literal("model-not-approved"), Type.Literal("invalid-context"), Type.Literal("epoch-mismatch"), @@ -286,13 +220,13 @@ export const WorkerInferenceErrorReasonSchema: TSchema = Type.Union([ Type.Literal("cancelled"), ]); -export const WorkerInferenceErrorShapeSchema: TSchema = workerInferenceObject({ +const WorkerInferenceErrorShapeSchema = workerInferenceObject({ code: Type.Union([Type.Literal("INVALID_REQUEST"), Type.Literal("UNAVAILABLE")]), message: Type.String({ minLength: 1, maxLength: 256 }), details: workerInferenceObject({ reason: WorkerInferenceErrorReasonSchema }), }); -export const WorkerInferenceStartRequestFrameSchema: TSchema = workerInferenceObject({ +export const WorkerInferenceStartRequestFrameSchema = workerInferenceObject({ type: Type.Literal("req"), id: WorkerFrameIdSchema, method: Type.Literal(WORKER_INFERENCE_METHODS[0]), @@ -313,21 +247,21 @@ const WorkerInferenceErrorResponseFrameSchema = workerInferenceObject({ error: WorkerInferenceErrorShapeSchema, }); -export const WorkerInferenceStartResponseFrameSchema: TSchema = Type.Union([ +export const WorkerInferenceStartResponseFrameSchema = Type.Union([ WorkerInferenceStartSuccessResponseFrameSchema, WorkerInferenceErrorResponseFrameSchema, WorkerErrorResponseFrameSchema, ]); -export const WorkerInferenceCancelParamsSchema: TSchema = workerInferenceObject({ +const WorkerInferenceCancelParamsSchema = workerInferenceObject({ ...WorkerInferenceIdentityProperties, }); -export const WorkerInferenceCancelResultSchema: TSchema = workerInferenceObject({ +const WorkerInferenceCancelResultSchema = workerInferenceObject({ status: Type.Literal("cancelled"), }); -export const WorkerInferenceCancelRequestFrameSchema: TSchema = workerInferenceObject({ +export const WorkerInferenceCancelRequestFrameSchema = workerInferenceObject({ type: Type.Literal("req"), id: WorkerFrameIdSchema, method: Type.Literal(WORKER_INFERENCE_METHODS[1]), @@ -341,13 +275,13 @@ const WorkerInferenceCancelSuccessResponseFrameSchema = workerInferenceObject({ payload: WorkerInferenceCancelResultSchema, }); -export const WorkerInferenceCancelResponseFrameSchema: TSchema = Type.Union([ +export const WorkerInferenceCancelResponseFrameSchema = Type.Union([ WorkerInferenceCancelSuccessResponseFrameSchema, WorkerInferenceErrorResponseFrameSchema, WorkerErrorResponseFrameSchema, ]); -export const WorkerInferenceResolvedModelSchema: TSchema = workerInferenceObject({ +const WorkerInferenceResolvedModelSchema = workerInferenceObject({ api: WorkerIdentifierSchema, provider: WorkerIdentifierSchema, model: WorkerIdentifierSchema, @@ -399,13 +333,13 @@ const WorkerInferenceStreamEventSchema = Type.Union([ workerInferenceObject({ type: Type.Literal("toolcall_end"), contentIndex: LiveIntegerSchema }), ]); -export const WorkerInferenceEventParamsSchema: TSchema = workerInferenceObject({ +const WorkerInferenceEventParamsSchema = workerInferenceObject({ ...WorkerInferenceIdentityProperties, seq: LiveSequenceSchema, event: WorkerInferenceStreamEventSchema, }); -export const WorkerInferenceEventFrameSchema: TSchema = workerInferenceObject({ +const WorkerInferenceEventFrameSchema = workerInferenceObject({ type: Type.Literal("event"), event: Type.Literal("worker.inference.event"), payload: WorkerInferenceEventParamsSchema, @@ -423,147 +357,49 @@ const WorkerInferenceTerminalErrorSchema = workerInferenceObject({ usage: Type.Optional(WorkerTranscriptUsageSchema), }); -export const WorkerInferenceTerminalOutcomeSchema: TSchema = Type.Union([ +const WorkerInferenceTerminalOutcomeSchema = Type.Union([ WorkerInferenceTerminalDoneSchema, WorkerInferenceTerminalErrorSchema, ]); -export const WorkerInferenceTerminalParamsSchema: TSchema = workerInferenceObject({ +const WorkerInferenceTerminalParamsSchema = workerInferenceObject({ ...WorkerInferenceIdentityProperties, seq: LiveSequenceSchema, outcome: WorkerInferenceTerminalOutcomeSchema, }); -export const WorkerInferenceTerminalFrameSchema: TSchema = workerInferenceObject({ +const WorkerInferenceTerminalFrameSchema = workerInferenceObject({ type: Type.Literal("event"), event: Type.Literal("worker.inference.terminal"), payload: WorkerInferenceTerminalParamsSchema, }); -type WorkerInferenceUserMessage = Omit< - Extract, - "content" -> & { - content: string | Extract["content"]; - runtimeContextCarrier?: boolean; -}; -type WorkerInferenceContextMessage = - | WorkerInferenceUserMessage - | Extract; -type WorkerInferenceTool = { name: string; description: string; parameters: unknown }; -type WorkerInferenceIdentity = { - runEpoch: number; - sessionId: string; - runId: string; - turnId: string; -}; -type WorkerInferenceThinkingBudgets = { - minimal?: number; - low?: number; - medium?: number; - high?: number; - max?: number; -}; -type WorkerInferenceUsage = Extract["usage"]; -type WorkerInferenceAssistantMessage = Omit< - Extract, - "diagnostics" | "stopReason" | "errorMessage" | "errorCode" | "errorType" | "errorBody" -> & { stopReason: "stop" | "length" | "toolUse" }; - -export type WorkerInferenceModelRef = { provider: string; model: string }; -export type WorkerInferenceContext = { - systemPrompt?: string; - messages: WorkerInferenceContextMessage[]; - tools?: WorkerInferenceTool[]; -}; -export type WorkerInferenceOptions = { - temperature?: number; - maxTokens?: number; - reasoning?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max"; - thinkingBudgets?: WorkerInferenceThinkingBudgets; -}; -export type WorkerInferenceStartParams = WorkerInferenceIdentity & { - modelRef: WorkerInferenceModelRef; - context: WorkerInferenceContext; - options: WorkerInferenceOptions; -}; -export type WorkerInferenceStartResult = { status: "accepted" | "replayed" }; -export type WorkerInferenceErrorReason = - | "model-not-approved" - | "invalid-context" - | "epoch-mismatch" - | "session-not-attached" - | "provider-error" - | "cancelled"; -export type WorkerInferenceErrorShape = { - code: "INVALID_REQUEST" | "UNAVAILABLE"; - message: string; - details: { reason: WorkerInferenceErrorReason }; -}; -export type WorkerInferenceStartRequestFrame = { - type: "req"; - id: string; - method: "worker.inference.start"; - params: WorkerInferenceStartParams; -}; -type WorkerInferenceResponseErrorFrame = { - type: "res"; - id: string; - ok: false; - error: WorkerInferenceErrorShape | WorkerErrorShape; -}; -export type WorkerInferenceStartResponseFrame = - | { type: "res"; id: string; ok: true; payload: WorkerInferenceStartResult } - | WorkerInferenceResponseErrorFrame; -export type WorkerInferenceCancelParams = WorkerInferenceIdentity; -export type WorkerInferenceCancelResult = { status: "cancelled" }; -export type WorkerInferenceCancelRequestFrame = { - type: "req"; - id: string; - method: "worker.inference.cancel"; - params: WorkerInferenceCancelParams; -}; -export type WorkerInferenceCancelResponseFrame = - | { type: "res"; id: string; ok: true; payload: WorkerInferenceCancelResult } - | WorkerInferenceResponseErrorFrame; -export type WorkerInferenceResolvedModel = { api: string; provider: string; model: string }; -type WorkerInferenceStreamEvent = - | { type: "start"; resolvedModel: WorkerInferenceResolvedModel; timestamp: number } - | { type: "text_start"; contentIndex: number; contentSignature?: string } - | { type: "text_delta"; contentIndex: number; delta: string } - | { type: "text_end"; contentIndex: number; contentSignature?: string } - | { type: "thinking_start"; contentIndex: number } - | { type: "thinking_delta"; contentIndex: number; delta: string } - | { type: "thinking_end"; contentIndex: number; contentSignature?: string } - | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } - | { type: "toolcall_delta"; contentIndex: number; delta: string } - | { type: "toolcall_end"; contentIndex: number }; -export type WorkerInferenceEventParams = WorkerInferenceIdentity & { - seq: number; - event: WorkerInferenceStreamEvent; -}; -export type WorkerInferenceEventFrame = { - type: "event"; - event: "worker.inference.event"; - payload: WorkerInferenceEventParams; -}; -export type WorkerInferenceTerminalOutcome = - | { type: "done"; message: WorkerInferenceAssistantMessage } - | { - type: "error"; - reason: WorkerInferenceErrorReason; - message: string; - usage?: WorkerInferenceUsage; - }; -export type WorkerInferenceTerminalParams = WorkerInferenceIdentity & { - seq: number; - outcome: WorkerInferenceTerminalOutcome; -}; -export type WorkerInferenceTerminalFrame = { - type: "event"; - event: "worker.inference.terminal"; - payload: WorkerInferenceTerminalParams; -}; +export type WorkerInferenceModelRef = Static; +export type WorkerInferenceContext = Static; +export type WorkerInferenceOptions = Static; +export type WorkerInferenceStartParams = Static; +export type WorkerInferenceStartResult = Static; +export type WorkerInferenceErrorReason = Static; +export type WorkerInferenceErrorShape = Static; +export type WorkerInferenceStartRequestFrame = Static< + typeof WorkerInferenceStartRequestFrameSchema +>; +export type WorkerInferenceStartResponseFrame = Static< + typeof WorkerInferenceStartResponseFrameSchema +>; +export type WorkerInferenceCancelParams = Static; +export type WorkerInferenceCancelResult = Static; +export type WorkerInferenceCancelRequestFrame = Static< + typeof WorkerInferenceCancelRequestFrameSchema +>; +export type WorkerInferenceCancelResponseFrame = Static< + typeof WorkerInferenceCancelResponseFrameSchema +>; +export type WorkerInferenceEventParams = Static; +export type WorkerInferenceEventFrame = Static; +export type WorkerInferenceTerminalOutcome = Static; +export type WorkerInferenceTerminalParams = Static; +export type WorkerInferenceTerminalFrame = Static; function isSafeWorkerInferenceJson(data: unknown): boolean { const stack: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: data }]; diff --git a/packages/gateway-protocol/src/schema/worker-protocol-primitives.ts b/packages/gateway-protocol/src/schema/worker-protocol-primitives.ts new file mode 100644 index 000000000000..0eee34e73671 --- /dev/null +++ b/packages/gateway-protocol/src/schema/worker-protocol-primitives.ts @@ -0,0 +1,122 @@ +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; + +export const WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH = 256; +export const WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH = 128; +export const WORKER_PROTOCOL_MAX_PAYLOAD_BYTES = 64 * 1024; + +export const WorkerIdentifierSchema = Type.String({ + minLength: 1, + maxLength: WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH, + pattern: "^\\S(?:.*\\S)?$", +}); + +export const WorkerFrameIdSchema = Type.String({ + minLength: 1, + maxLength: WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH, +}); + +export const WorkerAdmissionFailureReasonSchema = Type.Union([ + Type.Literal("invalid-credential"), + Type.Literal("credential-expired"), + Type.Literal("environment-mismatch"), + Type.Literal("environment-unavailable"), + Type.Literal("bundle-mismatch"), + Type.Literal("version-mismatch"), + Type.Literal("session-mismatch"), + Type.Literal("placement-mismatch"), + Type.Literal("owner-epoch-mismatch"), + Type.Literal("rpc-set-mismatch"), + Type.Literal("protocol-features-mismatch"), +]); + +export const WorkerProtocolCloseReasonSchema = Type.Union([ + WorkerAdmissionFailureReasonSchema, + Type.Literal("invalid-handshake"), + Type.Literal("protocol-mismatch"), + Type.Literal("gateway-unavailable"), + Type.Literal("invalid-frame"), + Type.Literal("slow-consumer"), + Type.Literal("method-not-allowed"), + Type.Literal("invalid-heartbeat"), + Type.Literal("credential-replaced"), + Type.Literal("gateway-shutdown"), +]); + +const WorkerErrorCodeSchema = Type.Union([ + Type.Literal("INVALID_REQUEST"), + Type.Literal("UNAVAILABLE"), +]); + +const WorkerErrorDetailsSchema = closedObject({ reason: WorkerProtocolCloseReasonSchema }); + +export const WorkerErrorShapeSchema = closedObject({ + code: WorkerErrorCodeSchema, + message: Type.String({ minLength: 1, maxLength: 256 }), + details: WorkerErrorDetailsSchema, + retryable: Type.Optional(Type.Boolean()), + retryAfterMs: Type.Optional(Type.Integer({ minimum: 0 })), +}); + +export const WorkerErrorResponseFrameSchema = closedObject({ + type: Type.Literal("res"), + id: WorkerFrameIdSchema, + ok: Type.Literal(false), + error: WorkerErrorShapeSchema, +}); + +export const WorkerTranscriptUsageSchema = closedObject({ + input: Type.Number({ minimum: 0 }), + output: Type.Number({ minimum: 0 }), + cacheRead: Type.Number({ minimum: 0 }), + cacheWrite: Type.Number({ minimum: 0 }), + contextUsage: Type.Optional( + Type.Union([ + closedObject({ + state: Type.Literal("available"), + promptTokens: Type.Number({ minimum: 0 }), + totalTokens: Type.Number({ minimum: 0 }), + }), + closedObject({ state: Type.Literal("unavailable") }), + ]), + ), + totalTokens: Type.Number({ minimum: 0 }), + cost: closedObject({ + input: Type.Number({ minimum: 0 }), + output: Type.Number({ minimum: 0 }), + cacheRead: Type.Number({ minimum: 0 }), + cacheWrite: Type.Number({ minimum: 0 }), + total: Type.Number({ minimum: 0 }), + totalOrigin: Type.Optional(Type.Literal("provider-billed")), + }), +}); + +export const WorkerTranscriptAssistantDiagnosticSchema = closedObject({ + type: WorkerIdentifierSchema, + timestamp: Type.Integer({ minimum: 0 }), + error: Type.Optional( + closedObject({ + name: Type.Optional(Type.String({ maxLength: 256 })), + message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }), + stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })), + code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])), + }), + ), + details: Type.Optional( + Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()), + ), +}); + +export const LiveTextSchema = Type.String({ + maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES, +}); + +export const LiveIntegerSchema = Type.Integer({ + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, +}); + +export const LiveSequenceSchema = Type.Integer({ + minimum: 1, + maximum: Number.MAX_SAFE_INTEGER, +}); diff --git a/packages/gateway-protocol/src/startup-unavailable.ts b/packages/gateway-protocol/src/startup-unavailable.ts index 2c2705ff9bc0..1d8b4fa55ff3 100644 --- a/packages/gateway-protocol/src/startup-unavailable.ts +++ b/packages/gateway-protocol/src/startup-unavailable.ts @@ -12,7 +12,7 @@ const GATEWAY_STARTUP_RETRY_MIN_MS = 100; const GATEWAY_STARTUP_RETRY_MAX_MS = 2_000; /** Details payload attached to retryable startup-unavailable gateway errors. */ -export type GatewayStartupUnavailableDetails = { +type GatewayStartupUnavailableDetails = { reason: typeof GATEWAY_STARTUP_UNAVAILABLE_REASON; }; diff --git a/packages/gateway-protocol/src/terminal-validators.ts b/packages/gateway-protocol/src/terminal-validators.ts index 55a8172d6baa..e630219706d6 100644 --- a/packages/gateway-protocol/src/terminal-validators.ts +++ b/packages/gateway-protocol/src/terminal-validators.ts @@ -2,7 +2,6 @@ import { lazyCompile } from "./protocol-validator.js"; import { TerminalAttachParamsSchema, TerminalCloseParamsSchema, - TerminalEventSchema, TerminalInputParamsSchema, TerminalOpenParamsSchema, TerminalResizeParamsSchema, @@ -19,4 +18,3 @@ export const validateTerminalAttachParams = lazyCompile(TerminalAttachParamsSche export const validateTerminalTextParams = lazyCompile(TerminalTextParamsSchema); export const validateTerminalUploadParams = lazyCompile(TerminalUploadParamsSchema); export const validateTerminalUploadResult = lazyCompile(TerminalUploadResultSchema); -export const validateTerminalEvent = lazyCompile(TerminalEventSchema); diff --git a/scripts/check-protocol-since.mjs b/scripts/check-protocol-since.mjs new file mode 100644 index 000000000000..a74d8a1775ad --- /dev/null +++ b/scripts/check-protocol-since.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +// Enforces release-train metadata on core gateway methods. +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const descriptorPath = "src/gateway/methods/core-descriptors.ts"; + +function runGit(args) { + const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" }); + if (result.status !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || `git exited ${result.status}`; + throw new Error( + `${detail}\nRun git fetch origin main so origin/main and its merge-base are available.`, + ); + } + return result.stdout.trim(); +} + +function tryRunGit(args) { + const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +function resolveBaseCommit() { + const explicitBase = process.env.PROTOCOL_SINCE_BASE_SHA?.trim(); + if (explicitBase) { + if (!/^[0-9a-f]{40}$/u.test(explicitBase)) { + throw new Error("PROTOCOL_SINCE_BASE_SHA must be a full commit SHA."); + } + if (tryRunGit(["cat-file", "-e", `${explicitBase}:${descriptorPath}`]) === undefined) { + throw new Error(`PROTOCOL_SINCE_BASE_SHA ${explicitBase} is unavailable in this checkout.`); + } + return explicitBase; + } + const mainMergeBase = tryRunGit(["merge-base", "HEAD", "origin/main"]); + if (mainMergeBase) { + return mainMergeBase; + } + // Pull-request CI checks out a synthetic merge commit without creating origin/main. + // Its first parent is the exact base used to build the merge result. + const parents = (tryRunGit(["show", "-s", "--format=%P", "HEAD"]) ?? "").split(/\s+/u); + if (parents.length === 2 && parents[0]) { + return parents[0]; + } + throw new Error( + "Could not resolve PROTOCOL_SINCE_BASE_SHA, origin/main, or a two-parent pull-request merge checkout.", + ); +} + +function unwrapExpression(expression) { + let current = expression; + while ( + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isParenthesizedExpression(current) + ) { + current = current.expression; + } + return current; +} + +function stringProperty(object, key) { + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const propertyName = property.name; + const name = + ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName) + ? propertyName.text + : undefined; + if (name === key && ts.isStringLiteralLike(property.initializer)) { + return property.initializer.text; + } + } + return undefined; +} + +function collectMethodSpecs(sourceText, fileName) { + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true); + let specs; + + function visit(node) { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "CORE_GATEWAY_METHOD_SPECS" && + node.initializer + ) { + const initializer = unwrapExpression(node.initializer); + if (ts.isArrayLiteralExpression(initializer)) { + specs = initializer.elements.map((element) => { + const line = + sourceFile.getLineAndCharacterOfPosition(element.getStart(sourceFile)).line + 1; + if (!ts.isObjectLiteralExpression(element)) { + throw new Error( + `${fileName}:${line} core method specs must be inline object literals so vintage metadata can be enforced.`, + ); + } + const name = stringProperty(element, "name"); + if (!name) { + throw new Error( + `${fileName}:${line} core method spec names must be string literals so additions can be compared with origin/main.`, + ); + } + return { + name, + since: stringProperty(element, "since"), + line, + }; + }); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + if (!specs) { + throw new Error(`Could not find CORE_GATEWAY_METHOD_SPECS in ${fileName}.`); + } + return specs; +} + +function currentTrain() { + const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); + const match = /^(\d{4})\.(\d{1,2})(?:\.|$)/.exec(packageJson.version); + if (!match) { + throw new Error(`Root package version ${JSON.stringify(packageJson.version)} is not calver.`); + } + return `${match[1]}.${match[2]}`; +} + +try { + const train = currentTrain(); + const mergeBase = resolveBaseCommit(); + const currentSource = fs.readFileSync(path.join(repoRoot, descriptorPath), "utf8"); + const currentSpecs = collectMethodSpecs(currentSource, descriptorPath); + const baseSource = runGit(["show", `${mergeBase}:${descriptorPath}`]); + const baseNames = new Set( + collectMethodSpecs(baseSource, `${descriptorPath}@${mergeBase}`).map((s) => s.name), + ); + const added = currentSpecs.filter((spec) => !baseNames.has(spec.name)); + const violations = added.filter((spec) => spec.since !== train); + + if (violations.length > 0) { + console.error(`Protocol since guard failed for current train ${train}:`); + for (const spec of violations) { + const problem = spec.since + ? `has since ${JSON.stringify(spec.since)}` + : "is missing since metadata"; + console.error( + `- ${descriptorPath}:${spec.line} ${spec.name} ${problem}; add since: ${JSON.stringify(train)}.`, + ); + } + process.exitCode = 1; + } else { + console.log( + `protocol since guard passed: ${added.length} new core method${added.length === 1 ? "" : "s"} use train ${train}`, + ); + } +} catch (error) { + console.error( + `Protocol since guard failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +} diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 0efdb95f0de9..240b8b8eb78c 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -288,7 +288,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: config-backed main-session resolver for Gateway-hosted plugin services. // +9: outbound echo identity type and record/query helpers across // channel-outbound and its two compatibility barrels. - 8189, + // Net +1: public session catalog locator types after the protocol cleanup harvest. + 8190, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index c18ae204eaa5..9e3df8cdb2c5 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -42,10 +42,6 @@ const STRICT_LITERAL_STRUCTS = new Set([ "PluginsSessionActionFailureResult", ]); -// Swift exposes these branch properties as optionals; the gateway schema still -// enforces cross-field requirements such as cursors requiring catalogId. -const MERGED_OBJECT_UNION_STRUCTS = new Set(["SessionsCatalogListParams"]); - const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\npublic let GATEWAY_MIN_NODE_PROTOCOL_VERSION = ${MIN_NODE_PROTOCOL_VERSION}\n\nprivate struct GatewayAnyCodingKey: CodingKey, Hashable {\n let stringValue: String\n let intValue: Int?\n\n init?(stringValue: String) {\n self.stringValue = stringValue\n self.intValue = nil\n }\n\n init?(intValue: Int) {\n self.stringValue = String(intValue)\n self.intValue = intValue\n }\n}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values( ErrorCodes, ) @@ -163,37 +159,6 @@ function registerNamedSchema(name: string, schema: JsonSchema): void { schemaNameBySignature.set(signature, name); } -function mergeObjectUnionForStruct(name: string, schema: JsonSchema): JsonSchema | undefined { - if (!MERGED_OBJECT_UNION_STRUCTS.has(name)) { - return undefined; - } - const branches = schema.oneOf ?? schema.anyOf; - if (!branches?.length || branches.some((branch) => branch.type !== "object")) { - throw new Error(`${name} must remain an object union for Swift generation`); - } - const properties: Record = {}; - for (const branch of branches) { - for (const [key, property] of Object.entries(branch.properties ?? {})) { - const existing = properties[key]; - if (existing && schemaSignature(existing) !== schemaSignature(property)) { - throw new Error(`${name}.${key} has incompatible object-union schemas`); - } - properties[key] = property; - } - } - const required = Object.keys(properties).filter((key) => - branches.every((branch) => branch.required?.includes(key)), - ); - return { - type: "object", - properties, - required, - additionalProperties: branches.every((branch) => branch.additionalProperties === false) - ? false - : undefined, - }; -} - function namedSchema(schema: JsonSchema, allowStructuralFallback = false): string | undefined { return ( schemaNameByObject.get(schema as object) ?? @@ -776,10 +741,8 @@ async function generate() { if (name === "GatewayFrame") { continue; } - const structSchema = - schema.type === "object" ? schema : mergeObjectUnionForStruct(name, schema); - if (structSchema) { - parts.push(emitStruct(name, structSchema)); + if (schema.type === "object") { + parts.push(emitStruct(name, schema)); } } diff --git a/scripts/protocol-gen.ts b/scripts/protocol-gen.ts index aa105d617aa9..8e5454eb049b 100644 --- a/scripts/protocol-gen.ts +++ b/scripts/protocol-gen.ts @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { ProtocolSchemas } from "../packages/gateway-protocol/src/schema.js"; +import { listCoreGatewayMethodMetadata } from "../src/gateway/methods/core-descriptors.js"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); @@ -12,6 +13,9 @@ async function writeJsonSchema() { for (const [name, schema] of Object.entries(ProtocolSchemas)) { definitions[name] = schema; } + const methods = Object.fromEntries( + listCoreGatewayMethodMetadata().map(({ name, scope, since }) => [name, { since, scope }]), + ); const rootSchema = { $schema: "http://json-schema.org/draft-07/schema#", @@ -31,6 +35,7 @@ async function writeJsonSchema() { event: "#/definitions/EventFrame", }, }, + methods, definitions, }; diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.ts b/src/agents/embedded-agent-subscribe.handlers.tools.ts index 77b5f80ad08c..593040809278 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.ts @@ -1232,7 +1232,10 @@ export function handleToolExecutionStart( return ctx.params.onToolResult?.( buildAgentHarnessQuestionPromptPayload({ questionId, - questions, + questions: questions.map(({ questionId: id, ...question }) => ({ + ...question, + id, + })), options: { intro: "Question for you:" }, }), ); diff --git a/src/agents/harness/gateway-question.test.ts b/src/agents/harness/gateway-question.test.ts index 07d33f2e5f86..3e9a5842eb50 100644 --- a/src/agents/harness/gateway-question.test.ts +++ b/src/agents/harness/gateway-question.test.ts @@ -141,7 +141,7 @@ describe("gateway harness questions", () => { it("returns an answer that wins a registration cancellation race", async () => { const registration = deferred<{ id: string }>(); - const answers = { answers: { answer: { answers: ["Continue"] } } }; + const answers = { answers: { answer: ["Continue"] } }; let resolveCount = 0; const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => { if (method === "question.request") { @@ -184,7 +184,7 @@ describe("gateway harness questions", () => { }); it("returns an answer recovered after the request response is lost", async () => { - const answers = { answers: { answer: { answers: ["Continue"] } } }; + const answers = { answers: { answer: ["Continue"] } }; const requestError = new Error("request response lost"); const gatewayCall: AgentHarnessQuestionGatewayCall = async (method) => { if (method === "question.request") { @@ -218,7 +218,7 @@ describe("gateway harness questions", () => { const registration = deferred<{ id: string }>(); const answer = deferred<{ status: "answered"; - answers: { answers: Record }; + answers: { answers: Record }; }>(); const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { if (method === "question.request") { @@ -228,9 +228,8 @@ describe("gateway harness questions", () => { return await answer.promise; } if (method === "question.resolve") { - const resolvedAnswers = ( - params as { answers?: { answers: Record } } - ).answers; + const resolvedAnswers = (params as { answers?: { answers: Record } }) + .answers; if (!resolvedAnswers) { return { status: "cancelled" }; } @@ -261,7 +260,7 @@ describe("gateway harness questions", () => { await expect(run).resolves.toEqual({ status: "answered", - answers: { answers: { answer: { answers: ["Continue"] } } }, + answers: { answers: { answer: ["Continue"] } }, }); expect(onBlockReply).not.toHaveBeenCalled(); }); @@ -298,7 +297,7 @@ describe("gateway harness questions", () => { it("accepts a later text answer after cancellation fails", async () => { const answer = deferred<{ status: "answered"; - answers: { answers: Record }; + answers: { answers: Record }; }>(); let cancelAttempts = 0; const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { @@ -309,9 +308,8 @@ describe("gateway harness questions", () => { return await answer.promise; } if (method === "question.resolve") { - const resolvedAnswers = ( - params as { answers?: { answers: Record } } - ).answers; + const resolvedAnswers = (params as { answers?: { answers: Record } }) + .answers; if (!resolvedAnswers) { cancelAttempts += 1; throw new Error("temporary gateway failure"); @@ -348,7 +346,7 @@ describe("gateway harness questions", () => { await expect(run).resolves.toEqual({ status: "answered", - answers: { answers: { answer: { answers: ["Continue"] } } }, + answers: { answers: { answer: ["Continue"] } }, }); expect(cancelAttempts).toBe(1); }); @@ -356,7 +354,7 @@ describe("gateway harness questions", () => { it("returns a gateway answer without waiting for stalled prompt delivery", async () => { const answer = deferred<{ status: "answered"; - answers: { answers: Record }; + answers: { answers: Record }; }>(); const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { if (method === "question.request") { @@ -366,8 +364,7 @@ describe("gateway harness questions", () => { return await answer.promise; } if (method === "question.resolve") { - const answers = (params as { answers?: { answers: Record } }) - .answers; + const answers = (params as { answers?: { answers: Record } }).answers; if (answers) { answer.resolve({ status: "answered", answers }); return { status: "answered", answers }; @@ -398,14 +395,14 @@ describe("gateway harness questions", () => { text: "Continue", }), ).resolves.toBe(true); - const answers = { answers: { answer: { answers: ["Continue"] } } }; + const answers = { answers: { answer: ["Continue"] } }; await expect(run).resolves.toEqual({ status: "answered", answers }); expect(deliverySignal?.aborted).toBe(true); }); it("does not deliver a prompt for an already-terminal gateway question", async () => { - const answers = { answers: { answer: { answers: ["Continue"] } } }; + const answers = { answers: { answer: ["Continue"] } }; const gatewayCall: AgentHarnessQuestionGatewayCall = async (method, _opts, params) => { if (method === "question.request") { return { id: (params as { id: string }).id }; diff --git a/src/agents/harness/gateway-question.ts b/src/agents/harness/gateway-question.ts index 121a9e96791a..84315258a0a5 100644 --- a/src/agents/harness/gateway-question.ts +++ b/src/agents/harness/gateway-question.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import type { + QuestionAnswers, QuestionRequestQuestion, QuestionWaitAnswerResult, } from "../../../packages/gateway-protocol/src/schema/questions.js"; @@ -91,11 +92,16 @@ async function resolvePendingAgentQuestionAnswers( state: PendingAgentQuestion, answers: AgentHarnessUserInputAnswers, ): Promise { + const gatewayAnswers: QuestionAnswers = { + answers: Object.fromEntries( + Object.entries(answers.answers).map(([questionId, answer]) => [questionId, answer.answers]), + ), + }; try { await state.gatewayCall( "question.resolve", {}, - { id: state.questionId, answers, resolvedBy: "plain-text" }, + { id: state.questionId, answers: gatewayAnswers, resolvedBy: "plain-text" }, ); return true; } catch (error) { @@ -277,8 +283,9 @@ export async function runAgentHarnessGatewayQuestion( params: RunAgentHarnessGatewayQuestionParams, ): Promise { const questionId = params.questionId ?? `ask_${randomBytes(16).toString("hex")}`; - const questions: QuestionRequestQuestion[] = params.questions.map((question) => ({ + const questions: QuestionRequestQuestion[] = params.questions.map(({ id, ...question }) => ({ ...question, + questionId: id, options: [...(question.options ?? [])], })); let aborted = false; diff --git a/src/agents/tools/ask-user-tool.test.ts b/src/agents/tools/ask-user-tool.test.ts index d7e385049490..3e2b4f2c6b9d 100644 --- a/src/agents/tools/ask-user-tool.test.ts +++ b/src/agents/tools/ask-user-tool.test.ts @@ -60,7 +60,7 @@ describe("ask_user normalization", () => { expect(normalized.timeoutSeconds).toBe(30); expect(normalized.questions[0]).toMatchObject({ - id: "deploy_target", + questionId: "deploy_target", header: "Deployment t", isOther: true, }); @@ -260,7 +260,7 @@ describe("ask_user prompt delivery", () => { describe("ask_user execution", () => { it("returns answered details plus readable answer lines", async () => { - const answers = { answers: { deploy_target: { answers: ["Staging (Recommended)"] } } }; + const answers = { answers: { deploy_target: ["Staging (Recommended)"] } }; const gateway = gatewayStub(async (method, _opts, params) => { if (method === "question.request") { return { id: params.id, expiresAtMs: Date.now() + 30_000 }; @@ -599,7 +599,7 @@ describe("ask_user execution", () => { if (!reservation) { throw new Error("expected prompt reservation"); } - const answers = { answers: { deploy_target: { answers: ["Production"] } } }; + const answers = { answers: { deploy_target: ["Production"] } }; let waitCalls = 0; const gateway = gatewayStub(async (method, _opts, params) => { if (method === "question.request") { @@ -735,7 +735,7 @@ describe("ask_user execution", () => { {}, { id: questionId, - answers: { answers: { deploy_target: { answers: ["A custom destination"] } } }, + answers: { answers: { deploy_target: ["A custom destination"] } }, resolvedBy: "plain-text", }, ); diff --git a/src/agents/tools/ask-user-tool.ts b/src/agents/tools/ask-user-tool.ts index cd2b94eeba00..d7e889723602 100644 --- a/src/agents/tools/ask-user-tool.ts +++ b/src/agents/tools/ask-user-tool.ts @@ -177,7 +177,7 @@ export function normalizeAskUserParams(value: unknown): NormalizedAskUserParams throw new ToolInputError(`${prefix}.multiSelect must be a boolean`); } return { - id, + questionId: id, header, question: questionText, options: question.options.map((option, optionIndex) => @@ -474,7 +474,7 @@ export function cancelAskUserPromptDelivery( function answeredResult(questions: readonly QuestionRequestQuestion[], answers: QuestionAnswers) { const payload = { status: "answered" as const, answers }; const lines = questions.map((question) => { - const values = answers.answers[question.id]?.answers ?? []; + const values = answers.answers[question.questionId] ?? []; return `${question.header}: ${values.length > 0 ? values.join(", ") : "(no answer)"}`; }); return textResult(`${lines.join("\n")}\n\n${JSON.stringify(payload, null, 2)}`, payload); @@ -584,13 +584,11 @@ export function createAskUserTool(params: { gatewayCall, waiters: new Set(), } satisfies AskUserQuestionState); - state.sessionKey = sessionKey; - state.questions = normalized.questions; + Object.assign(state, { sessionKey, questions: normalized.questions }); state.expiresAtMs = Date.now() + timeoutMs; state.gatewayCall = gatewayCall; transitionAskUserQuestion(state, { kind: "registering" }); askUserQuestions.set(questionId, state); - let cancellation: | Promise | undefined> | undefined; @@ -647,12 +645,14 @@ export function createAskUserTool(params: { } throw new Error("question.waitAnswer returned an invalid status"); }; - try { state.claim = registerPendingAgentQuestion({ questionId, sessionKey, - questions: normalized.questions, + questions: normalized.questions.map(({ questionId: id, ...question }) => ({ + ...question, + id, + })), gatewayCall, onCancel: () => { if ( diff --git a/src/commands/health.plugins.test.ts b/src/commands/health.plugins.test.ts index aa56da1d7487..1c4963e1461b 100644 --- a/src/commands/health.plugins.test.ts +++ b/src/commands/health.plugins.test.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { Value } from "typebox/value"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { SnapshotSchema } from "../../packages/gateway-protocol/src/schema/snapshot.js"; import { createPluginRecord } from "../plugins/status.test-fixtures.js"; const testConfig = { session: { store: "/tmp/x" } }; @@ -92,6 +94,7 @@ describe("getHealthSnapshot plugin state", () => { const snap = await getHealthSnapshot({ timeoutMs: 10, probe: false }); + expect(Value.Check(SnapshotSchema.properties.health, snap)).toBe(true); expect(snap.plugins?.unavailable).toEqual([ { id: "discord", diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 2006dec9593b..362c2b2760a3 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -863,12 +863,12 @@ describe("getHealthSnapshot", () => { buildTelegramHealthSummaryForTest = (snapshot) => ({ accountId: snapshot.accountId, configured: Boolean(snapshot.configured), - probe: { ok: true, token: "summary-secret" }, + probe: { ok: true, token: "test-token" }, }); probeTelegramAccountForTestOverride = async () => ({ ok: true, bot: { username: "runtime_bot" }, - token: "probe-secret", + token: "test-token", }); const snap = await getHealthSnapshot({ diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts new file mode 100644 index 000000000000..4e984b46fa79 --- /dev/null +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { listCoreGatewayMethodMetadata } from "./core-descriptors.js"; + +const CURRENT_TRAIN_METHODS = [ + "question.request", + "question.waitAnswer", + "question.resolve", + "question.get", + "question.list", + "terminal.open", + "terminal.input", + "terminal.resize", + "terminal.close", + "terminal.attach", + "terminal.list", + "terminal.text", + "terminal.upload", + "worktrees.list", + "worktrees.branches", + "worktrees.create", + "worktrees.remove", + "worktrees.restore", + "worktrees.gc", + "agents.workspace.list", + "agents.workspace.get", + "audit.list", + "audit.activity.list", + "tts.speak", + "environments.list", + "environments.status", + "environments.create", + "environments.destroy", + "sessions.dispatch", + "sessions.reclaim", + "sessions.catalog.list", + "sessions.catalog.read", + "sessions.catalog.continue", + "sessions.catalog.archive", + "approval.get", + "approval.resolve", + "approval.history", + "migrations.memory.plan", + "migrations.memory.apply", + "gateway.suspend.prepare", + "gateway.suspend.status", + "gateway.suspend.resume", + "ui.command", + "device.pair.rename", +] as const; + +describe("core gateway method release trains", () => { + it("records a valid train for every method and dates the 2026.7 families", () => { + const methods = listCoreGatewayMethodMetadata(); + + for (const method of methods) { + expect(method.since, method.name).toMatch(/^(<=)?\d{4}\.\d{1,2}$/); + } + + expect( + methods + .filter((method) => method.since === "2026.7") + .map((method) => method.name) + .toSorted(), + ).toEqual(CURRENT_TRAIN_METHODS.toSorted()); + }); +}); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 10751ee1e16a..f2c722e4cb1f 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -11,396 +11,434 @@ import { type CoreGatewayMethodSpec = { name: string; scope: GatewayMethodScope; + since?: string; advertise?: false; startup?: true; controlPlaneWrite?: true; }; +type CoreGatewayMethodMetadata = Pick; + // This is the canonical core method policy table: every core handler must appear here so // listing, authorization, startup availability, and write throttling stay in sync. const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ - { name: "health", scope: "operator.read" }, - { name: "diagnostics.stability", scope: "operator.read" }, - { name: "doctor.memory.status", scope: "operator.read" }, - { name: "doctor.memory.dreamDiary", scope: "operator.read" }, - { name: "doctor.memory.backfillDreamDiary", scope: "operator.write" }, - { name: "doctor.memory.resetDreamDiary", scope: "operator.write" }, - { name: "doctor.memory.resetGroundedShortTerm", scope: "operator.write" }, - { name: "doctor.memory.repairDreamingArtifacts", scope: "operator.write" }, - { name: "doctor.memory.dedupeDreamDiary", scope: "operator.write" }, - { name: "doctor.memory.remHarness", scope: "operator.read" }, - { name: "logs.tail", scope: "operator.read" }, - { name: "channels.status", scope: "operator.read" }, - { name: "channels.start", scope: "operator.admin" }, - { name: "channels.stop", scope: "operator.admin" }, - { name: "channels.logout", scope: "operator.admin" }, - { name: "status", scope: "operator.read" }, - { name: "usage.status", scope: "operator.read" }, - { name: "usage.cost", scope: "operator.read" }, - { name: "tts.status", scope: "operator.read" }, - { name: "tts.providers", scope: "operator.read" }, - { name: "tts.personas", scope: "operator.read" }, - { name: "tts.enable", scope: "operator.write" }, - { name: "tts.disable", scope: "operator.write" }, - { name: "tts.convert", scope: "operator.write" }, - { name: "tts.setProvider", scope: "operator.write" }, - { name: "tts.setPersona", scope: "operator.write" }, - { name: "config.get", scope: "operator.read" }, - { name: "config.set", scope: "operator.admin" }, - { name: "config.apply", scope: "operator.admin", controlPlaneWrite: true }, - { name: "config.patch", scope: "operator.admin", controlPlaneWrite: true }, - { name: "config.schema", scope: "operator.admin" }, - { name: "config.schema.lookup", scope: "operator.read" }, - { name: "exec.approvals.get", scope: "operator.admin" }, - { name: "exec.approvals.set", scope: "operator.admin" }, - { name: "exec.approvals.node.get", scope: "operator.admin" }, - { name: "exec.approvals.node.set", scope: "operator.admin" }, - { name: "exec.approval.get", scope: "operator.approvals" }, - { name: "exec.approval.list", scope: "operator.approvals" }, - { name: "exec.approval.request", scope: "operator.approvals" }, - { name: "exec.approval.waitDecision", scope: "operator.approvals" }, - { name: "exec.approval.resolve", scope: "operator.approvals" }, - { name: "question.request", scope: "operator.questions" }, - { name: "question.waitAnswer", scope: "operator.questions" }, - { name: "question.resolve", scope: "operator.questions" }, - { name: "question.get", scope: "operator.questions" }, - { name: "question.list", scope: "operator.questions" }, - { name: "plugin.approval.list", scope: "operator.approvals" }, - { name: "plugin.approval.request", scope: "operator.approvals" }, - { name: "plugin.approval.waitDecision", scope: "operator.approvals" }, - { name: "plugin.approval.resolve", scope: "operator.approvals" }, - { name: "plugins.uiDescriptors", scope: "operator.read" }, - { name: "plugins.sessionAction", scope: "dynamic" }, - { name: "openclaw.chat", scope: "operator.admin" }, - { name: "openclaw.changes.list", scope: "operator.admin" }, - { name: "openclaw.approval.list", scope: "operator.approvals" }, - { name: "openclaw.setup.detect", scope: "operator.admin" }, + { name: "health", scope: "operator.read", since: "<=2026.7" }, + { name: "diagnostics.stability", scope: "operator.read", since: "<=2026.7" }, + { name: "doctor.memory.status", scope: "operator.read", since: "<=2026.7" }, + { name: "doctor.memory.dreamDiary", scope: "operator.read", since: "<=2026.7" }, + { name: "doctor.memory.backfillDreamDiary", scope: "operator.write", since: "<=2026.7" }, + { name: "doctor.memory.resetDreamDiary", scope: "operator.write", since: "<=2026.7" }, + { name: "doctor.memory.resetGroundedShortTerm", scope: "operator.write", since: "<=2026.7" }, + { name: "doctor.memory.repairDreamingArtifacts", scope: "operator.write", since: "<=2026.7" }, + { name: "doctor.memory.dedupeDreamDiary", scope: "operator.write", since: "<=2026.7" }, + { name: "doctor.memory.remHarness", scope: "operator.read", since: "<=2026.7" }, + { name: "logs.tail", scope: "operator.read", since: "<=2026.7" }, + { name: "channels.status", scope: "operator.read", since: "<=2026.7" }, + { name: "channels.start", scope: "operator.admin", since: "<=2026.7" }, + { name: "channels.stop", scope: "operator.admin", since: "<=2026.7" }, + { name: "channels.logout", scope: "operator.admin", since: "<=2026.7" }, + { name: "status", scope: "operator.read", since: "<=2026.7" }, + { name: "usage.status", scope: "operator.read", since: "<=2026.7" }, + { name: "usage.cost", scope: "operator.read", since: "<=2026.7" }, + { name: "tts.status", scope: "operator.read", since: "<=2026.7" }, + { name: "tts.providers", scope: "operator.read", since: "<=2026.7" }, + { name: "tts.personas", scope: "operator.read", since: "<=2026.7" }, + { name: "tts.enable", scope: "operator.write", since: "<=2026.7" }, + { name: "tts.disable", scope: "operator.write", since: "<=2026.7" }, + { name: "tts.convert", scope: "operator.write", since: "<=2026.7" }, + { name: "tts.setProvider", scope: "operator.write", since: "<=2026.7" }, + { name: "tts.setPersona", scope: "operator.write", since: "<=2026.7" }, + { name: "config.get", scope: "operator.read", since: "<=2026.7" }, + { name: "config.set", scope: "operator.admin", since: "<=2026.7" }, + { name: "config.apply", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true }, + { name: "config.patch", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true }, + { name: "config.schema", scope: "operator.admin", since: "<=2026.7" }, + { name: "config.schema.lookup", scope: "operator.read", since: "<=2026.7" }, + { name: "exec.approvals.get", scope: "operator.admin", since: "<=2026.7" }, + { name: "exec.approvals.set", scope: "operator.admin", since: "<=2026.7" }, + { name: "exec.approvals.node.get", scope: "operator.admin", since: "<=2026.7" }, + { name: "exec.approvals.node.set", scope: "operator.admin", since: "<=2026.7" }, + { name: "exec.approval.get", scope: "operator.approvals", since: "<=2026.7" }, + { name: "exec.approval.list", scope: "operator.approvals", since: "<=2026.7" }, + { name: "exec.approval.request", scope: "operator.approvals", since: "<=2026.7" }, + { name: "exec.approval.waitDecision", scope: "operator.approvals", since: "<=2026.7" }, + { name: "exec.approval.resolve", scope: "operator.approvals", since: "<=2026.7" }, + { name: "question.request", scope: "operator.questions", since: "2026.7" }, + { name: "question.waitAnswer", scope: "operator.questions", since: "2026.7" }, + { name: "question.resolve", scope: "operator.questions", since: "2026.7" }, + { name: "question.get", scope: "operator.questions", since: "2026.7" }, + { name: "question.list", scope: "operator.questions", since: "2026.7" }, + { name: "plugin.approval.list", scope: "operator.approvals", since: "<=2026.7" }, + { name: "plugin.approval.request", scope: "operator.approvals", since: "<=2026.7" }, + { name: "plugin.approval.waitDecision", scope: "operator.approvals", since: "<=2026.7" }, + { name: "plugin.approval.resolve", scope: "operator.approvals", since: "<=2026.7" }, + { name: "plugins.uiDescriptors", scope: "operator.read", since: "<=2026.7" }, + { name: "plugins.sessionAction", scope: "dynamic", since: "<=2026.7" }, + { name: "openclaw.chat", scope: "operator.admin", since: "<=2026.7" }, + { name: "openclaw.changes.list", scope: "operator.admin", since: "<=2026.7" }, + { name: "openclaw.approval.list", scope: "operator.approvals", since: "<=2026.7" }, + { name: "openclaw.setup.detect", scope: "operator.admin", since: "<=2026.7" }, // Failed activation candidates are non-mutating probes. Keep this admin-only // without the shared three-write budget so the automatic ladder can finish. - { name: "openclaw.setup.activate", scope: "operator.admin" }, - { name: "openclaw.setup.auth.start", scope: "operator.admin" }, - { name: "openclaw.setup.prepare.start", scope: "operator.admin" }, - { name: "wizard.start", scope: "operator.admin" }, - { name: "wizard.next", scope: "operator.admin" }, - { name: "wizard.cancel", scope: "operator.admin" }, - { name: "wizard.status", scope: "operator.admin" }, - { name: "talk.catalog", scope: "operator.read" }, + { name: "openclaw.setup.activate", scope: "operator.admin", since: "<=2026.7" }, + { name: "openclaw.setup.auth.start", scope: "operator.admin", since: "<=2026.7" }, + { name: "openclaw.setup.prepare.start", scope: "operator.admin", since: "<=2026.7" }, + { name: "wizard.start", scope: "operator.admin", since: "<=2026.7" }, + { name: "wizard.next", scope: "operator.admin", since: "<=2026.7" }, + { name: "wizard.cancel", scope: "operator.admin", since: "<=2026.7" }, + { name: "wizard.status", scope: "operator.admin", since: "<=2026.7" }, + { name: "talk.catalog", scope: "operator.read", since: "<=2026.7" }, // Params-aware: reading redacted config needs read; includeSecrets also needs talk secrets. - { name: "talk.config", scope: "dynamic" }, - { name: "talk.client.create", scope: "operator.write" }, - { name: "talk.client.transcript", scope: "operator.write" }, - { name: "talk.client.close", scope: "operator.write" }, - { name: "talk.client.toolCall", scope: "operator.write" }, - { name: "talk.client.steer", scope: "operator.write" }, - { name: "talk.session.create", scope: "operator.write" }, - { name: "talk.session.join", scope: "operator.write" }, - { name: "talk.session.appendAudio", scope: "operator.write" }, - { name: "talk.session.startTurn", scope: "operator.write" }, - { name: "talk.session.endTurn", scope: "operator.write" }, - { name: "talk.session.cancelTurn", scope: "operator.write" }, - { name: "talk.session.cancelOutput", scope: "operator.write" }, - { name: "talk.session.acknowledgeMark", scope: "operator.write" }, - { name: "talk.session.submitToolResult", scope: "operator.write" }, - { name: "talk.session.steer", scope: "operator.write" }, - { name: "talk.session.close", scope: "operator.write" }, - { name: "talk.speak", scope: "operator.write" }, - { name: "talk.mode", scope: "operator.write" }, - { name: "commands.list", scope: "operator.read" }, - { name: "models.list", scope: "operator.read", startup: true }, - { name: "models.authStatus", scope: "operator.read" }, - { name: "models.authLogout", scope: "operator.admin", controlPlaneWrite: true }, - { name: "tools.catalog", scope: "operator.read" }, - { name: "tools.effective", scope: "operator.read", startup: true }, - { name: "tools.invoke", scope: "operator.write" }, - { name: "mcp.app.view", scope: "operator.read" }, - { name: "mcp.app.listTools", scope: "operator.read" }, - { name: "mcp.app.listResources", scope: "operator.read" }, - { name: "mcp.app.listResourceTemplates", scope: "operator.read" }, - { name: "mcp.app.readResource", scope: "operator.read" }, - { name: "mcp.app.callTool", scope: "operator.write" }, - { name: "mcp.app.updateModelContext", scope: "operator.write" }, - { name: "board.get", scope: "operator.read" }, - { name: "board.update", scope: "operator.write" }, - { name: "board.widget.put", scope: "operator.write" }, - { name: "board.widget.grant", scope: "operator.approvals" }, - { name: "board.event", scope: "operator.write" }, - { name: "audit.list", scope: "operator.read" }, - { name: "audit.activity.list", scope: "operator.read" }, - { name: "users.list", scope: "operator.read" }, - { name: "users.self", scope: "operator.write" }, - { name: "users.linkEmail", scope: "operator.admin" }, - { name: "users.setDisplayName", scope: "operator.write" }, - { name: "users.setAvatar", scope: "operator.write" }, - { name: "tasks.list", scope: "operator.read" }, - { name: "tasks.get", scope: "operator.read" }, - { name: "tasks.cancel", scope: "operator.write" }, - { name: "taskSuggestions.list", scope: "operator.read" }, - { name: "taskSuggestions.create", scope: "operator.write" }, - { name: "taskSuggestions.accept", scope: "operator.admin" }, - { name: "taskSuggestions.dismiss", scope: "operator.write" }, - { name: "environments.list", scope: "operator.read" }, - { name: "environments.status", scope: "operator.read" }, - { name: "worktrees.list", scope: "operator.read" }, + { name: "talk.config", scope: "dynamic", since: "<=2026.7" }, + { name: "talk.client.create", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.client.transcript", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.client.close", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.client.toolCall", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.client.steer", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.create", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.join", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.appendAudio", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.startTurn", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.endTurn", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.cancelTurn", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.cancelOutput", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.acknowledgeMark", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.submitToolResult", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.steer", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.session.close", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.speak", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.mode", scope: "operator.write", since: "<=2026.7" }, + { name: "commands.list", scope: "operator.read", since: "<=2026.7" }, + { name: "models.list", scope: "operator.read", since: "<=2026.7", startup: true }, + { name: "models.authStatus", scope: "operator.read", since: "<=2026.7" }, + { + name: "models.authLogout", + scope: "operator.admin", + since: "<=2026.7", + controlPlaneWrite: true, + }, + { name: "tools.catalog", scope: "operator.read", since: "<=2026.7" }, + { name: "tools.effective", scope: "operator.read", since: "<=2026.7", startup: true }, + { name: "tools.invoke", scope: "operator.write", since: "<=2026.7" }, + { name: "mcp.app.view", scope: "operator.read", since: "<=2026.7" }, + { name: "mcp.app.listTools", scope: "operator.read", since: "<=2026.7" }, + { name: "mcp.app.listResources", scope: "operator.read", since: "<=2026.7" }, + { name: "mcp.app.listResourceTemplates", scope: "operator.read", since: "<=2026.7" }, + { name: "mcp.app.readResource", scope: "operator.read", since: "<=2026.7" }, + { name: "mcp.app.callTool", scope: "operator.write", since: "<=2026.7" }, + { name: "mcp.app.updateModelContext", scope: "operator.write", since: "<=2026.7" }, + { name: "board.get", scope: "operator.read", since: "<=2026.7" }, + { name: "board.update", scope: "operator.write", since: "<=2026.7" }, + { name: "board.widget.put", scope: "operator.write", since: "<=2026.7" }, + { name: "board.widget.grant", scope: "operator.approvals", since: "<=2026.7" }, + { name: "board.event", scope: "operator.write", since: "<=2026.7" }, + { name: "audit.list", scope: "operator.read", since: "2026.7" }, + { name: "audit.activity.list", scope: "operator.read", since: "2026.7" }, + { name: "users.list", scope: "operator.read", since: "<=2026.7" }, + { name: "users.self", scope: "operator.write", since: "<=2026.7" }, + { name: "users.linkEmail", scope: "operator.admin", since: "<=2026.7" }, + { name: "users.setDisplayName", scope: "operator.write", since: "<=2026.7" }, + { name: "users.setAvatar", scope: "operator.write", since: "<=2026.7" }, + { name: "tasks.list", scope: "operator.read", since: "<=2026.7" }, + { name: "tasks.get", scope: "operator.read", since: "<=2026.7" }, + { name: "tasks.cancel", scope: "operator.write", since: "<=2026.7" }, + { name: "taskSuggestions.list", scope: "operator.read", since: "<=2026.7" }, + { name: "taskSuggestions.create", scope: "operator.write", since: "<=2026.7" }, + { name: "taskSuggestions.accept", scope: "operator.admin", since: "<=2026.7" }, + { name: "taskSuggestions.dismiss", scope: "operator.write", since: "<=2026.7" }, + { name: "environments.list", scope: "operator.read", since: "2026.7" }, + { name: "environments.status", scope: "operator.read", since: "2026.7" }, + { name: "worktrees.list", scope: "operator.read", since: "2026.7" }, // Read-only git probe, but it accepts arbitrary host paths; keep it at the // same bar as starting worktree sessions instead of plain read scope. - { name: "worktrees.branches", scope: "operator.write" }, + { name: "worktrees.branches", scope: "operator.write", since: "2026.7" }, // Arbitrary host-path directory listing backs the new-session folder picker; // same trust bar as sessions.create with an explicit cwd. - { name: "fs.listDir", scope: "operator.admin" }, - { name: "worktrees.create", scope: "operator.admin", controlPlaneWrite: true }, - { name: "worktrees.remove", scope: "operator.admin", controlPlaneWrite: true }, - { name: "worktrees.restore", scope: "operator.admin", controlPlaneWrite: true }, - { name: "worktrees.gc", scope: "operator.admin", controlPlaneWrite: true }, - { name: "agents.list", scope: "operator.read" }, - { name: "agents.create", scope: "operator.admin" }, - { name: "agents.update", scope: "operator.admin" }, - { name: "agents.delete", scope: "operator.admin" }, - { name: "agents.files.list", scope: "operator.read" }, - { name: "agents.files.get", scope: "operator.read" }, - { name: "agents.files.set", scope: "operator.admin" }, - { name: "sessions.files.list", scope: "operator.read" }, - { name: "sessions.files.get", scope: "operator.read" }, + { name: "fs.listDir", scope: "operator.admin", since: "<=2026.7" }, + { name: "worktrees.create", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true }, + { name: "worktrees.remove", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true }, + { name: "worktrees.restore", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true }, + { name: "worktrees.gc", scope: "operator.admin", since: "2026.7", controlPlaneWrite: true }, + { name: "agents.list", scope: "operator.read", since: "<=2026.7" }, + { name: "agents.create", scope: "operator.admin", since: "<=2026.7" }, + { name: "agents.update", scope: "operator.admin", since: "<=2026.7" }, + { name: "agents.delete", scope: "operator.admin", since: "<=2026.7" }, + { name: "agents.files.list", scope: "operator.read", since: "<=2026.7" }, + { name: "agents.files.get", scope: "operator.read", since: "<=2026.7" }, + { name: "agents.files.set", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.files.list", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.files.get", scope: "operator.read", since: "<=2026.7" }, // Workspace file writes require the same admin scope as agents.files.set. - { name: "sessions.files.set", scope: "operator.admin" }, - { name: "sessions.files.reveal", scope: "operator.admin" }, - { name: "artifacts.list", scope: "operator.read" }, - { name: "artifacts.get", scope: "operator.read" }, - { name: "artifacts.download", scope: "operator.read" }, - { name: "skills.status", scope: "operator.read" }, - { name: "skills.search", scope: "operator.read" }, - { name: "skills.detail", scope: "operator.read" }, - { name: "skills.securityVerdicts", scope: "operator.read" }, - { name: "skills.skillCard", scope: "operator.read" }, - { name: "skills.bins", scope: "node" }, - { name: "skills.upload.begin", scope: "operator.admin" }, - { name: "skills.upload.chunk", scope: "operator.admin" }, - { name: "skills.upload.commit", scope: "operator.admin" }, - { name: "skills.install", scope: "operator.admin" }, - { name: "skills.update", scope: "operator.admin" }, - { name: "skills.curator.status", scope: "operator.read" }, - { name: "skills.curator.pin", scope: "operator.admin" }, - { name: "skills.curator.unpin", scope: "operator.admin" }, - { name: "skills.curator.restore", scope: "operator.admin" }, - { name: "skills.proposals.list", scope: "operator.read" }, - { name: "skills.proposals.inspect", scope: "operator.read" }, - { name: "skills.proposals.historyStatus", scope: "operator.read" }, - { name: "skills.proposals.historyScan", scope: "operator.admin" }, - { name: "skills.proposals.create", scope: "operator.admin" }, - { name: "skills.proposals.update", scope: "operator.admin" }, - { name: "skills.proposals.revise", scope: "operator.admin" }, - { name: "skills.proposals.requestRevision", scope: "operator.admin" }, - { name: "skills.proposals.apply", scope: "operator.admin" }, - { name: "skills.proposals.reject", scope: "operator.admin" }, - { name: "skills.proposals.quarantine", scope: "operator.admin" }, - { name: "update.status", scope: "operator.admin" }, - { name: "update.run", scope: "operator.admin", controlPlaneWrite: true }, - { name: "voicewake.get", scope: "operator.read" }, - { name: "voicewake.set", scope: "operator.write" }, - { name: "secrets.reload", scope: "operator.admin" }, - { name: "secrets.resolve", scope: "operator.admin" }, - { name: "voicewake.routing.get", scope: "operator.read" }, - { name: "voicewake.routing.set", scope: "operator.write" }, - { name: "sessions.list", scope: "operator.read", startup: true }, - { name: "sessions.subscribe", scope: "operator.read" }, - { name: "sessions.unsubscribe", scope: "operator.read" }, - { name: "sessions.messages.subscribe", scope: "operator.read" }, - { name: "sessions.messages.unsubscribe", scope: "operator.read" }, - { name: "sessions.preview", scope: "operator.read" }, - { name: "sessions.describe", scope: "operator.read" }, - { name: "sessions.compaction.list", scope: "operator.read" }, - { name: "sessions.compaction.get", scope: "operator.read" }, - { name: "sessions.compaction.branch", scope: "operator.write" }, - { name: "sessions.compaction.restore", scope: "operator.admin" }, - { name: "sessions.branches.list", scope: "operator.read" }, - { name: "sessions.branches.switch", scope: "operator.admin" }, - { name: "sessions.rewind", scope: "operator.admin" }, - { name: "sessions.fork", scope: "operator.write" }, + { name: "sessions.files.set", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.files.reveal", scope: "operator.admin", since: "<=2026.7" }, + { name: "artifacts.list", scope: "operator.read", since: "<=2026.7" }, + { name: "artifacts.get", scope: "operator.read", since: "<=2026.7" }, + { name: "artifacts.download", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.status", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.search", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.detail", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.securityVerdicts", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.skillCard", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.bins", scope: "node", since: "<=2026.7" }, + { name: "skills.upload.begin", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.upload.chunk", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.upload.commit", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.install", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.update", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.curator.status", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.curator.pin", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.curator.unpin", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.curator.restore", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.list", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.proposals.inspect", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.proposals.historyStatus", scope: "operator.read", since: "<=2026.7" }, + { name: "skills.proposals.historyScan", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.create", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.update", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.revise", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.requestRevision", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.apply", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.reject", scope: "operator.admin", since: "<=2026.7" }, + { name: "skills.proposals.quarantine", scope: "operator.admin", since: "<=2026.7" }, + { name: "update.status", scope: "operator.admin", since: "<=2026.7" }, + { name: "update.run", scope: "operator.admin", controlPlaneWrite: true, since: "<=2026.7" }, + { name: "voicewake.get", scope: "operator.read", since: "<=2026.7" }, + { name: "voicewake.set", scope: "operator.write", since: "<=2026.7" }, + { name: "secrets.reload", scope: "operator.admin", since: "<=2026.7" }, + { name: "secrets.resolve", scope: "operator.admin", since: "<=2026.7" }, + { name: "voicewake.routing.get", scope: "operator.read", since: "<=2026.7" }, + { name: "voicewake.routing.set", scope: "operator.write", since: "<=2026.7" }, + { name: "sessions.list", scope: "operator.read", startup: true, since: "<=2026.7" }, + { name: "sessions.subscribe", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.unsubscribe", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.messages.subscribe", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.messages.unsubscribe", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.preview", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.describe", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.compaction.list", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.compaction.get", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.compaction.branch", scope: "operator.write", since: "<=2026.7" }, + { name: "sessions.compaction.restore", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.branches.list", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.branches.switch", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.rewind", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.fork", scope: "operator.write", since: "<=2026.7" }, // Params-aware: explicit cwd can point at any host checkout and requires admin. - { name: "sessions.create", scope: "dynamic", startup: true }, - { name: "sessions.send", scope: "operator.write", startup: true }, - { name: "sessions.abort", scope: "operator.write", startup: true }, + { name: "sessions.create", scope: "dynamic", since: "<=2026.7", startup: true }, + { name: "sessions.send", scope: "operator.write", since: "<=2026.7", startup: true }, + { name: "sessions.abort", scope: "operator.write", since: "<=2026.7", startup: true }, // Params-aware: write scope may mutate chat-organization fields // (label/category/icon/pinned/archived/unread); every other patch field stays // admin-only. Policy lives in method-scopes.ts. - { name: "sessions.patch", scope: "dynamic" }, - { name: "sessions.pluginPatch", scope: "operator.admin" }, - { name: "sessions.cleanup", scope: "operator.admin" }, - { name: "sessions.reset", scope: "operator.admin" }, + { name: "sessions.patch", scope: "dynamic", since: "<=2026.7" }, + { name: "sessions.pluginPatch", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.cleanup", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.reset", scope: "operator.admin", since: "<=2026.7" }, // State-aware: write scope may delete already-archived sessions // (archive-then-delete); the handler enforces the archived requirement and // admin keeps unrestricted delete. Policy in method-scopes.ts + handler. - { name: "sessions.delete", scope: "dynamic" }, - { name: "sessions.compact", scope: "operator.admin" }, - { name: "sessions.groups.list", scope: "operator.read" }, - { name: "sessions.groups.put", scope: "operator.write" }, - { name: "sessions.groups.rename", scope: "operator.write" }, - { name: "sessions.groups.delete", scope: "operator.write" }, - { name: "last-heartbeat", scope: "operator.read" }, - { name: "set-heartbeats", scope: "operator.admin" }, - { name: "wake", scope: "operator.write" }, - { name: "node.pair.list", scope: "operator.pairing" }, - { name: "node.pair.approve", scope: "operator.pairing" }, - { name: "node.pair.reject", scope: "operator.pairing" }, - { name: "node.pair.remove", scope: "operator.pairing" }, - { name: "device.pair.list", scope: "operator.pairing" }, - { name: "device.pair.approve", scope: "operator.pairing" }, - { name: "device.pair.reject", scope: "operator.pairing" }, - { name: "device.pair.remove", scope: "operator.pairing" }, - { name: "device.pair.rename", scope: "operator.pairing" }, - { name: "device.token.rotate", scope: "operator.pairing" }, - { name: "device.token.revoke", scope: "operator.pairing" }, - { name: "device.pair.setupCode", scope: "operator.admin", advertise: false }, - { name: "node.rename", scope: "operator.pairing" }, - { name: "node.list", scope: "operator.read" }, - { name: "node.describe", scope: "operator.read" }, - { name: "node.pluginSurface.refresh", scope: "node" }, - { name: "node.pluginTools.update", scope: "node" }, - { name: "node.skills.update", scope: "node" }, - { name: "node.pending.drain", scope: "node" }, - { name: "node.pending.enqueue", scope: "operator.write" }, + { name: "sessions.delete", scope: "dynamic", since: "<=2026.7" }, + { name: "sessions.compact", scope: "operator.admin", since: "<=2026.7" }, + { name: "sessions.groups.list", scope: "operator.read", since: "<=2026.7" }, + { name: "sessions.groups.put", scope: "operator.write", since: "<=2026.7" }, + { name: "sessions.groups.rename", scope: "operator.write", since: "<=2026.7" }, + { name: "sessions.groups.delete", scope: "operator.write", since: "<=2026.7" }, + { name: "last-heartbeat", scope: "operator.read", since: "<=2026.7" }, + { name: "set-heartbeats", scope: "operator.admin", since: "<=2026.7" }, + { name: "wake", scope: "operator.write", since: "<=2026.7" }, + { name: "node.pair.list", scope: "operator.pairing", since: "<=2026.7" }, + { name: "node.pair.approve", scope: "operator.pairing", since: "<=2026.7" }, + { name: "node.pair.reject", scope: "operator.pairing", since: "<=2026.7" }, + { name: "node.pair.remove", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.pair.list", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.pair.approve", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.pair.reject", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.pair.remove", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.pair.rename", scope: "operator.pairing", since: "2026.7" }, + { name: "device.token.rotate", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.token.revoke", scope: "operator.pairing", since: "<=2026.7" }, + { name: "device.pair.setupCode", scope: "operator.admin", since: "<=2026.7", advertise: false }, + { name: "node.rename", scope: "operator.pairing", since: "<=2026.7" }, + { name: "node.list", scope: "operator.read", since: "<=2026.7" }, + { name: "node.describe", scope: "operator.read", since: "<=2026.7" }, + { name: "node.pluginSurface.refresh", scope: "node", since: "<=2026.7" }, + { name: "node.pluginTools.update", scope: "node", since: "<=2026.7" }, + { name: "node.skills.update", scope: "node", since: "<=2026.7" }, + { name: "node.pending.drain", scope: "node", since: "<=2026.7" }, + { name: "node.pending.enqueue", scope: "operator.write", since: "<=2026.7" }, // Params-aware: host-sensitive commands raise direct invocation from write to admin. - { name: "node.invoke", scope: "dynamic" }, - { name: "node.pending.pull", scope: "node" }, - { name: "node.pending.ack", scope: "node" }, - { name: "node.invoke.progress", scope: "node" }, - { name: "node.invoke.result", scope: "node" }, - { name: "node.event", scope: "node" }, - { name: "cron.get", scope: "operator.read" }, - { name: "cron.list", scope: "operator.read" }, - { name: "cron.status", scope: "operator.read" }, - { name: "cron.add", scope: "operator.admin" }, - { name: "cron.update", scope: "operator.admin" }, - { name: "cron.remove", scope: "operator.admin" }, - { name: "cron.run", scope: "operator.admin" }, - { name: "cron.runs", scope: "operator.read" }, - { name: "gateway.identity.get", scope: "operator.read" }, - { name: "gateway.restart.preflight", scope: "operator.read" }, - { name: "gateway.restart.request", scope: "operator.admin", controlPlaneWrite: true }, - { name: "system-presence", scope: "operator.read" }, - { name: "system-event", scope: "operator.admin" }, - { name: "message.action", scope: "operator.write" }, - { name: "conversations.send", scope: "operator.admin" }, - { name: "conversations.turn", scope: "operator.admin" }, - { name: "conversations.turn.cancel", scope: "operator.admin" }, - { name: "send", scope: "operator.write" }, + { name: "node.invoke", scope: "dynamic", since: "<=2026.7" }, + { name: "node.pending.pull", scope: "node", since: "<=2026.7" }, + { name: "node.pending.ack", scope: "node", since: "<=2026.7" }, + { name: "node.invoke.progress", scope: "node", since: "<=2026.7" }, + { name: "node.invoke.result", scope: "node", since: "<=2026.7" }, + { name: "node.event", scope: "node", since: "<=2026.7" }, + { name: "cron.get", scope: "operator.read", since: "<=2026.7" }, + { name: "cron.list", scope: "operator.read", since: "<=2026.7" }, + { name: "cron.status", scope: "operator.read", since: "<=2026.7" }, + { name: "cron.add", scope: "operator.admin", since: "<=2026.7" }, + { name: "cron.update", scope: "operator.admin", since: "<=2026.7" }, + { name: "cron.remove", scope: "operator.admin", since: "<=2026.7" }, + { name: "cron.run", scope: "operator.admin", since: "<=2026.7" }, + { name: "cron.runs", scope: "operator.read", since: "<=2026.7" }, + { name: "gateway.identity.get", scope: "operator.read", since: "<=2026.7" }, + { name: "gateway.restart.preflight", scope: "operator.read", since: "<=2026.7" }, + { + name: "gateway.restart.request", + scope: "operator.admin", + since: "<=2026.7", + controlPlaneWrite: true, + }, + { name: "system-presence", scope: "operator.read", since: "<=2026.7" }, + { name: "system-event", scope: "operator.admin", since: "<=2026.7" }, + { name: "message.action", scope: "operator.write", since: "<=2026.7" }, + { name: "conversations.send", scope: "operator.admin", since: "<=2026.7" }, + { name: "conversations.turn", scope: "operator.admin", since: "<=2026.7" }, + { name: "conversations.turn.cancel", scope: "operator.admin", since: "<=2026.7" }, + { name: "send", scope: "operator.write", since: "<=2026.7" }, // Params-aware: ordinary turns need write; /new and /reset mutate lifecycle state as admin. - { name: "agent", scope: "dynamic" }, - { name: "agent.identity.get", scope: "operator.read" }, - { name: "agent.wait", scope: "operator.write", startup: true }, - { name: "chat.history", scope: "operator.read", startup: true }, - { name: "chat.startup", scope: "operator.read", startup: true }, - { name: "chat.metadata", scope: "operator.read", startup: true }, - { name: "chat.message.get", scope: "operator.read", startup: true }, - { name: "chat.abort", scope: "operator.write" }, - { name: "chat.send", scope: "operator.write" }, + { name: "agent", scope: "dynamic", since: "<=2026.7" }, + { name: "agent.identity.get", scope: "operator.read", since: "<=2026.7" }, + { name: "agent.wait", scope: "operator.write", since: "<=2026.7", startup: true }, + { name: "chat.history", scope: "operator.read", since: "<=2026.7", startup: true }, + { name: "chat.startup", scope: "operator.read", since: "<=2026.7", startup: true }, + { name: "chat.metadata", scope: "operator.read", since: "<=2026.7", startup: true }, + { name: "chat.message.get", scope: "operator.read", since: "<=2026.7", startup: true }, + { name: "chat.abort", scope: "operator.write", since: "<=2026.7" }, + { name: "chat.send", scope: "operator.write", since: "<=2026.7" }, // Operator terminal: admin-only PTY surface. Appended to the advertised block // so existing advertised method indices stay stable for older clients. - { name: "terminal.open", scope: "operator.admin" }, - { name: "terminal.input", scope: "operator.admin" }, - { name: "terminal.resize", scope: "operator.admin" }, - { name: "terminal.close", scope: "operator.admin" }, - { name: "assistant.media.get", scope: "operator.read", advertise: false }, - { name: "sessions.get", scope: "operator.read", advertise: false }, - { name: "sessions.resolve", scope: "operator.read", advertise: false }, - { name: "sessions.usage", scope: "operator.read", advertise: false }, - { name: "sessions.usage.timeseries", scope: "operator.read", advertise: false }, - { name: "sessions.usage.logs", scope: "operator.read", advertise: false }, - { name: "poll", scope: "operator.write", advertise: false }, - { name: "sessions.steer", scope: "operator.write", advertise: false }, - { name: "push.test", scope: "operator.write", advertise: false }, - { name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true }, - { name: "attach.revoke", scope: "operator.admin" }, - { name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false }, - { name: "push.web.subscribe", scope: "operator.write", advertise: false }, - { name: "push.web.unsubscribe", scope: "operator.write", advertise: false }, - { name: "push.web.test", scope: "operator.write", advertise: false }, - { name: "config.openFile", scope: "operator.admin", advertise: false }, - { name: "connect", scope: "operator.admin", advertise: false }, - { name: "chat.inject", scope: "operator.admin", advertise: false }, - { name: "nativeHook.invoke", scope: "operator.admin", advertise: false }, - { name: "web.login.start", scope: "operator.admin", advertise: false }, - { name: "web.login.wait", scope: "operator.admin", advertise: false }, + { name: "terminal.open", scope: "operator.admin", since: "2026.7" }, + { name: "terminal.input", scope: "operator.admin", since: "2026.7" }, + { name: "terminal.resize", scope: "operator.admin", since: "2026.7" }, + { name: "terminal.close", scope: "operator.admin", since: "2026.7" }, + { name: "assistant.media.get", scope: "operator.read", since: "<=2026.7", advertise: false }, + { name: "sessions.get", scope: "operator.read", since: "<=2026.7", advertise: false }, + { name: "sessions.resolve", scope: "operator.read", since: "<=2026.7", advertise: false }, + { name: "sessions.usage", scope: "operator.read", since: "<=2026.7", advertise: false }, + { + name: "sessions.usage.timeseries", + scope: "operator.read", + since: "<=2026.7", + advertise: false, + }, + { name: "sessions.usage.logs", scope: "operator.read", since: "<=2026.7", advertise: false }, + { name: "poll", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "sessions.steer", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "push.test", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "attach.grant", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true }, + { name: "attach.revoke", scope: "operator.admin", since: "<=2026.7" }, + { name: "push.web.vapidPublicKey", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "push.web.subscribe", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "push.web.unsubscribe", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "push.web.test", scope: "operator.write", since: "<=2026.7", advertise: false }, + { name: "config.openFile", scope: "operator.admin", since: "<=2026.7", advertise: false }, + { name: "connect", scope: "operator.admin", since: "<=2026.7", advertise: false }, + { name: "chat.inject", scope: "operator.admin", since: "<=2026.7", advertise: false }, + { name: "nativeHook.invoke", scope: "operator.admin", since: "<=2026.7", advertise: false }, + { name: "web.login.start", scope: "operator.admin", since: "<=2026.7", advertise: false }, + { name: "web.login.wait", scope: "operator.admin", since: "<=2026.7", advertise: false }, // Terminal detach/reattach surface. Kept together near the end so previously // advertised method indices stay stable for older clients; new methods append. - { name: "terminal.attach", scope: "operator.admin" }, - { name: "terminal.list", scope: "operator.admin" }, - { name: "terminal.text", scope: "operator.admin" }, - { name: "controlUi.githubPreview", scope: "operator.read" }, + { name: "terminal.attach", scope: "operator.admin", since: "2026.7" }, + { name: "terminal.list", scope: "operator.admin", since: "2026.7" }, + { name: "terminal.text", scope: "operator.admin", since: "2026.7" }, + { name: "controlUi.githubPreview", scope: "operator.read", since: "<=2026.7" }, // Additive discovery methods append here so older clients keep stable indices. - { name: "system.info", scope: "operator.read" }, + { name: "system.info", scope: "operator.read", since: "<=2026.7" }, // Workspace contents stay in the documented trusted operator domain, like session and log // reads. Strong user/tenant isolation requires separate Gateways; see operator-scopes.md. - { name: "agents.workspace.list", scope: "operator.read" }, - { name: "agents.workspace.get", scope: "operator.read" }, - { name: "tts.speak", scope: "operator.write" }, - { name: "plugins.list", scope: "operator.read" }, - { name: "plugins.search", scope: "operator.read" }, - { name: "plugins.install", scope: "operator.admin", controlPlaneWrite: true }, - { name: "plugins.setEnabled", scope: "operator.admin", controlPlaneWrite: true }, - { name: "plugins.uninstall", scope: "operator.admin", controlPlaneWrite: true }, - { name: "plugins.refresh", scope: "operator.admin", controlPlaneWrite: true }, + { name: "agents.workspace.list", scope: "operator.read", since: "2026.7" }, + { name: "agents.workspace.get", scope: "operator.read", since: "2026.7" }, + { name: "tts.speak", scope: "operator.write", since: "2026.7" }, + { name: "plugins.list", scope: "operator.read", since: "<=2026.7" }, + { name: "plugins.search", scope: "operator.read", since: "<=2026.7" }, + { name: "plugins.install", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true }, + { + name: "plugins.setEnabled", + scope: "operator.admin", + since: "<=2026.7", + controlPlaneWrite: true, + }, + { + name: "plugins.uninstall", + scope: "operator.admin", + since: "<=2026.7", + controlPlaneWrite: true, + }, + { name: "plugins.refresh", scope: "operator.admin", since: "<=2026.7", controlPlaneWrite: true }, // Session PR chips read the session's own checkout metadata, matching the // sessions.files.* trusted-operator read domain. - { name: "controlUi.sessionPullRequests", scope: "operator.read" }, + { name: "controlUi.sessionPullRequests", scope: "operator.read", since: "<=2026.7" }, { name: "gateway.suspend.prepare", scope: "operator.admin", + since: "2026.7", startup: true, controlPlaneWrite: true, }, - { name: "gateway.suspend.status", scope: "operator.read" }, + { name: "gateway.suspend.status", scope: "operator.read", since: "2026.7" }, // Resume is the safety escape hatch and must not sit behind write-rate limiting. - { name: "gateway.suspend.resume", scope: "operator.admin" }, + { name: "gateway.suspend.resume", scope: "operator.admin", since: "2026.7" }, // Spends utility-model tokens on cache misses when the opt-in is enabled, so // it needs write scope despite being a read-shaped lookup. - { name: "chat.toolTitles", scope: "operator.write" }, + { name: "chat.toolTitles", scope: "operator.write", since: "<=2026.7" }, // Session checkout diff reads the session's own git worktree, matching the // sessions.files.* trusted-operator read domain. - { name: "sessions.diff", scope: "operator.read" }, + { name: "sessions.diff", scope: "operator.read", since: "<=2026.7" }, // Additive protocol methods append here to preserve existing advertised indices. - { name: "openclaw.setup.verify", scope: "operator.admin" }, + { name: "openclaw.setup.verify", scope: "operator.admin", since: "<=2026.7" }, // Cloud-worker mutations depend on the loaded provider registry and owned // reconciler, so advertise them early but gate dispatch until sidecars are ready. { name: "environments.create", scope: "operator.admin", + since: "2026.7", startup: true, controlPlaneWrite: true, }, { name: "environments.destroy", scope: "operator.admin", + since: "2026.7", startup: true, controlPlaneWrite: true, }, - { name: "sessions.catalog.list", scope: "operator.read" }, - { name: "sessions.catalog.read", scope: "operator.read" }, - { name: "terminal.upload", scope: "operator.admin" }, - { name: "sessions.catalog.continue", scope: "operator.write" }, - { name: "sessions.catalog.archive", scope: "operator.write" }, - { name: "approval.get", scope: "operator.approvals" }, - { name: "approval.resolve", scope: "operator.approvals" }, - { name: "sessions.search", scope: "operator.read" }, + { name: "sessions.catalog.list", scope: "operator.read", since: "2026.7" }, + { name: "sessions.catalog.read", scope: "operator.read", since: "2026.7" }, + { name: "terminal.upload", scope: "operator.admin", since: "2026.7" }, + { name: "sessions.catalog.continue", scope: "operator.write", since: "2026.7" }, + { name: "sessions.catalog.archive", scope: "operator.write", since: "2026.7" }, + { name: "approval.get", scope: "operator.approvals", since: "2026.7" }, + { name: "approval.resolve", scope: "operator.approvals", since: "2026.7" }, + { name: "sessions.search", scope: "operator.read", since: "<=2026.7" }, { name: "sessions.dispatch", scope: "operator.admin", + since: "2026.7", startup: true, controlPlaneWrite: true, }, { name: "sessions.reclaim", scope: "operator.admin", + since: "2026.7", startup: true, controlPlaneWrite: true, }, - { name: "models.probe", scope: "operator.admin" }, + { name: "models.probe", scope: "operator.admin", since: "<=2026.7" }, // Memory migration reads host assistant state and writes agent workspaces. - { name: "migrations.memory.plan", scope: "operator.admin" }, - { name: "migrations.memory.apply", scope: "operator.admin", controlPlaneWrite: true }, - { name: "ui.command", scope: "operator.write" }, - { name: "approval.history", scope: "operator.approvals" }, - { name: "plugin.surface.refresh", scope: "operator.read" }, - { name: "conversations.list", scope: "operator.admin" }, + { name: "migrations.memory.plan", scope: "operator.admin", since: "2026.7" }, + { + name: "migrations.memory.apply", + scope: "operator.admin", + since: "2026.7", + controlPlaneWrite: true, + }, + { name: "ui.command", scope: "operator.write", since: "2026.7" }, + { name: "approval.history", scope: "operator.approvals", since: "2026.7" }, + { name: "plugin.surface.refresh", scope: "operator.read", since: "<=2026.7" }, + { name: "conversations.list", scope: "operator.admin", since: "<=2026.7" }, ] as const; const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap = new Map( @@ -421,7 +459,12 @@ export function listCoreAdvertisedGatewayMethodNames(): string[] { /** Returns all registered core method names, including hidden/internal compatibility methods. */ export function listCoreGatewayMethodNames(): string[] { - return CORE_GATEWAY_METHOD_SPECS.map((spec) => spec.name); + return listCoreGatewayMethodMetadata().map((spec) => spec.name); +} + +/** Returns the public metadata emitted for every core gateway method. */ +export function listCoreGatewayMethodMetadata(): readonly CoreGatewayMethodMetadata[] { + return CORE_GATEWAY_METHOD_SPECS.map(({ name, scope, since }) => ({ name, scope, since })); } /** Looks up the raw core method scope, including node and dynamic sentinel scopes. */ @@ -469,6 +512,7 @@ export function createCoreGatewayMethodDescriptors( handler, owner: { kind: "core", area: "gateway" }, scope: spec.scope, + ...(spec.since ? { since: spec.since } : {}), ...(spec.advertise === false ? { advertise: false } : {}), ...(spec.startup === true ? { startup: "unavailable-until-sidecars" } : {}), ...(spec.controlPlaneWrite === true ? { controlPlaneWrite: true } : {}), diff --git a/src/gateway/methods/descriptor.ts b/src/gateway/methods/descriptor.ts index 9e50fa294ffe..94a730d24a0f 100644 --- a/src/gateway/methods/descriptor.ts +++ b/src/gateway/methods/descriptor.ts @@ -30,6 +30,7 @@ export type GatewayMethodDescriptor = { handler: GatewayMethodHandler; scope: GatewayMethodScope; owner: GatewayMethodOwner; + since?: string; startup?: GatewayMethodStartupAvailability; controlPlaneWrite?: boolean; advertise?: boolean; diff --git a/src/gateway/question-manager.test.ts b/src/gateway/question-manager.test.ts index 55057082605b..6bf89e78a308 100644 --- a/src/gateway/question-manager.test.ts +++ b/src/gateway/question-manager.test.ts @@ -10,7 +10,7 @@ const QUESTION_RESOLVED_ENTRY_GRACE_MS = 15_000; const questions: Question[] = [ { - id: "choice", + questionId: "choice", header: "Choice", question: "Which option?", options: [ @@ -20,33 +20,39 @@ const questions: Question[] = [ isOther: true, }, ]; -const answers = { answers: { choice: { answers: ["Two"] } } }; +const answers = { answers: { choice: ["Two"] } }; const invalidAnswerCases: Array<[string, Question[], QuestionAnswers, string]> = [ ["an empty answer map", questions, { answers: {} }, "choice"], + [ + "a prototype-key question id with no submitted answer", + [{ ...questions[0]!, questionId: "constructor" }], + { answers: {} }, + "constructor", + ], [ "an unknown question id", questions, - { answers: { choice: { answers: ["Two"] }, unknown: { answers: ["value"] } } }, + { answers: { choice: ["Two"], unknown: ["value"] } }, "unknown", ], [ "a missing question answer", - [...questions, { ...questions[0]!, id: "second" }], + [...questions, { ...questions[0]!, questionId: "second" }], answers, "second", ], - ["an empty string", questions, { answers: { choice: { answers: [" "] } } }, "choice"], + ["an empty string", questions, { answers: { choice: [" "] } }, "choice"], [ "multiple values for a single-select question", questions, - { answers: { choice: { answers: ["One", "Two"] } } }, + { answers: { choice: ["One", "Two"] } }, "choice", ], [ "a value outside the declared options", [{ ...questions[0]!, isOther: false }], - { answers: { choice: { answers: ["Three"] } } }, + { answers: { choice: ["Three"] } }, "choice", ], ]; @@ -69,7 +75,7 @@ describe("QuestionManager", () => { const first = manager.request({ questions, timeoutMs: 10_000, agentId: "main" }); vi.setSystemTime(1_001); const second = manager.request({ - questions: [{ ...questions[0]!, id: "other" }], + questions: [{ ...questions[0]!, questionId: "other" }], timeoutMs: 10_000, sessionKey: "agent:main:main", }); @@ -119,22 +125,22 @@ describe("QuestionManager", () => { questions: [{ ...questions[0]!, isOther: false }], timeoutMs: 10_000, }); - expect( - manager.resolve(strict.id, { answers: { choice: { answers: [" Two "] } } }), - ).toMatchObject({ status: "answered" }); + expect(manager.resolve(strict.id, { answers: { choice: [" Two "] } })).toMatchObject({ + status: "answered", + }); const open = manager.request({ questions, timeoutMs: 10_000 }); - expect( - manager.resolve(open.id, { answers: { choice: { answers: ["custom"] } } }), - ).toMatchObject({ status: "answered" }); + expect(manager.resolve(open.id, { answers: { choice: ["custom"] } })).toMatchObject({ + status: "answered", + }); const freeText = manager.request({ questions: [{ ...questions[0]!, options: [], isOther: false }], timeoutMs: 10_000, }); - expect( - manager.resolve(freeText.id, { answers: { choice: { answers: ["custom"] } } }), - ).toMatchObject({ status: "answered" }); + expect(manager.resolve(freeText.id, { answers: { choice: ["custom"] } })).toMatchObject({ + status: "answered", + }); }); it("times out one waiter without resolving the question", async () => { @@ -207,7 +213,7 @@ describe("answer canonicalization", () => { const record = localManager.request({ questions: [ { - id: "pick", + questionId: "pick", header: "Pick", question: "Pick one", options: [{ label: "Two" }, { label: "Three" }], @@ -217,11 +223,11 @@ describe("answer canonicalization", () => { timeoutMs: 60_000, }); const result = localManager.resolve(record.id, { - answers: { pick: { answers: [" Two "] } }, + answers: { pick: [" Two "] }, }); expect(result).toEqual({ status: "answered", - answers: { answers: { pick: { answers: ["Two"] } } }, + answers: { answers: { pick: ["Two"] } }, }); localManager.reset(); }); diff --git a/src/gateway/question-manager.ts b/src/gateway/question-manager.ts index 5cf872c78680..9e4f9a2306d1 100644 --- a/src/gateway/question-manager.ts +++ b/src/gateway/question-manager.ts @@ -242,22 +242,28 @@ export class QuestionManager { /** Validates answers against stored questions and returns them in canonical form. */ private validateAnswers(questions: Question[], answers: QuestionAnswers): QuestionAnswers { const submittedIds = Object.keys(answers.answers); - const questionsById = new Map(questions.map((question) => [question.id, question])); + const questionsById = new Map(questions.map((question) => [question.questionId, question])); const unknownId = submittedIds.find((id) => !questionsById.has(id)); if (unknownId) { throw this.invalidAnswer(unknownId, "is not part of this request"); } + // Canonical rebuilds every key as an own property, so downstream readers of + // resolved answers can index the record directly without prototype checks. const canonical: QuestionAnswers = { answers: {} }; for (const question of questions) { - const values = answers.answers[question.id]?.answers; + // Object.hasOwn: the id grammar admits "constructor"; a plain index read + // would return the inherited prototype member instead of undefined. + const values = Object.hasOwn(answers.answers, question.questionId) + ? answers.answers[question.questionId] + : undefined; if (!values || values.length === 0) { - throw this.invalidAnswer(question.id, "requires an answer"); + throw this.invalidAnswer(question.questionId, "requires an answer"); } if (values.some((value) => !value.trim())) { - throw this.invalidAnswer(question.id, "contains an empty answer"); + throw this.invalidAnswer(question.questionId, "contains an empty answer"); } if (!question.multiSelect && values.length > 1) { - throw this.invalidAnswer(question.id, "does not allow multiple answers"); + throw this.invalidAnswer(question.questionId, "does not allow multiple answers"); } // Store the declared option label when a value matches trim-insensitively; // downstream renderers compare answers to option labels exactly. @@ -270,9 +276,9 @@ export class QuestionManager { !question.isOther && canonicalValues.some((value) => !question.options.some((option) => option.label === value)) ) { - throw this.invalidAnswer(question.id, "contains an unknown option"); + throw this.invalidAnswer(question.questionId, "contains an unknown option"); } - canonical.answers[question.id] = { answers: canonicalValues }; + canonical.answers[question.questionId] = canonicalValues; } return canonical; } diff --git a/src/gateway/server-methods/question.test.ts b/src/gateway/server-methods/question.test.ts index deb5c1855023..24576b045807 100644 --- a/src/gateway/server-methods/question.test.ts +++ b/src/gateway/server-methods/question.test.ts @@ -41,8 +41,8 @@ async function call(method: string, params: Record) { const requestParams = { questions: [ { - id: "destination", - header: "Longer than twelve", + questionId: "destination", + header: "Destination", question: "Where next?", options: [], multiSelect: false, @@ -56,7 +56,7 @@ const requestParams = { }; describe("question gateway methods", () => { - it("requests normalized questions, then gets and lists them", async () => { + it("requests questions, then gets and lists them", async () => { const requested = await call("question.request", { ...requestParams, id: "client-question-id", @@ -68,7 +68,7 @@ describe("question gateway methods", () => { "question.requested", expect.objectContaining({ id, - questions: [expect.objectContaining({ header: "Longer than " })], + questions: [expect.objectContaining({ header: "Destination" })], status: "pending", }), ); @@ -88,7 +88,7 @@ describe("question gateway methods", () => { it("broadcasts answered and expired terminal states", async () => { const requested = await call("question.request", requestParams); const id = (requested[1] as { id: string }).id; - const answers = { answers: { destination: { answers: ["Home"] } } }; + const answers = { answers: { destination: ["Home"] } }; expect(await call("question.resolve", { id, answers, resolvedBy: "control-ui" })).toEqual([ true, @@ -173,7 +173,7 @@ describe("question gateway methods", () => { const resolved = await call("question.resolve", { id, - answers: { answers: { destination: { answers: ["Somewhere else"] } } }, + answers: { answers: { destination: ["Somewhere else"] } }, }); expect(resolved[0]).toBe(false); diff --git a/src/gateway/server-methods/question.ts b/src/gateway/server-methods/question.ts index ef1cafdf3523..871857bf3b97 100644 --- a/src/gateway/server-methods/question.ts +++ b/src/gateway/server-methods/question.ts @@ -1,5 +1,4 @@ // Question gateway methods create, inspect, wait for, and resolve transient prompts. -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { ErrorCodes, errorShape, @@ -58,18 +57,18 @@ function managerError(error: unknown, respond: RespondFn): boolean { function normalizeQuestions(params: QuestionRequestParams): Question[] { const ids = new Set(); return params.questions.map((question) => { - if (ids.has(question.id)) { - throw new QuestionRequestValidationError(`duplicate question id '${question.id}'`); + if (ids.has(question.questionId)) { + throw new QuestionRequestValidationError(`duplicate question id '${question.questionId}'`); } - ids.add(question.id); + ids.add(question.questionId); if (question.options.length === 1) { throw new QuestionRequestValidationError( - `question '${question.id}' must have either no options or 2 to 4 options`, + `question '${question.questionId}' must have either no options or 2 to 4 options`, ); } if (question.isSecret) { throw new QuestionRequestValidationError( - `question '${question.id}': secret questions are not supported yet`, + `question '${question.questionId}': secret questions are not supported yet`, ); } const optionLabels = new Set(); @@ -77,15 +76,12 @@ function normalizeQuestions(params: QuestionRequestParams): Question[] { const normalizedLabel = option.label.trim().toLowerCase(); if (optionLabels.has(normalizedLabel)) { throw new QuestionRequestValidationError( - `question '${question.id}' has duplicate option label '${option.label}'`, + `question '${question.questionId}' has duplicate option label '${option.label}'`, ); } optionLabels.add(normalizedLabel); } - return { - ...question, - header: truncateUtf16Safe(question.header, 12), - }; + return question; }); } diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index f0be173be775..accff7643bfc 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -177,6 +177,21 @@ describe("session catalog Gateway methods", () => { } }); + it("rejects host cursors without a catalog selector", async () => { + const respond = await call("sessions.catalog.list", { + cursors: { "gateway:local": "next" }, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: "catalogId is required when cursors are provided", + }), + ); + }); + it("normalizes search once before dispatching every provider", async () => { const alphaList = vi.fn(async () => []); const zetaList = vi.fn(async () => []); diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 49adca1ac585..4ab19397ceb7 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -168,6 +168,14 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { return; } const request = params as SessionsCatalogListParams; + if (request.cursors !== undefined && request.catalogId === undefined) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "catalogId is required when cursors are provided"), + ); + return; + } let selected: SessionCatalogProvider[]; if (request.catalogId) { const provider = providerOrRespond(request.catalogId, respond); @@ -216,7 +224,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { search, limitPerHost: request.limitPerHost, hostIds: request.hostIds, - ...("cursors" in request ? { cursors: request.cursors } : {}), + ...(request.cursors !== undefined ? { cursors: request.cursors } : {}), ...(onHost ? { onHost } : {}), }); return catalogResult(provider, hosts, undefined, createSession); diff --git a/src/gateway/server-methods/terminal-upload.ts b/src/gateway/server-methods/terminal-upload.ts index 00959df5c1c6..900220c60b2e 100644 --- a/src/gateway/server-methods/terminal-upload.ts +++ b/src/gateway/server-methods/terminal-upload.ts @@ -5,7 +5,7 @@ import { type TerminalUploadParams, validateTerminalUploadParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { isCanonicalTerminalUploadBase64 } from "../../../packages/gateway-protocol/src/terminal-upload-constants.js"; +import { isCanonicalTerminalUploadBase64 } from "../../../packages/gateway-protocol/src/schema/terminal-constants.js"; import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; function invalid(respond: GatewayRequestHandlerOptions["respond"], detail: string): void { diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index f4d92ed509e9..2796bb78c7cd 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -31,7 +31,7 @@ export function buildGatewaySnapshot(opts?: { includeSensitive?: boolean }): Sna const uptimeMs = Math.round(process.uptime() * 1000); const updateAvailable = getUpdateAvailable() ?? undefined; // Health is async; caller should await getHealthSnapshot and replace later if needed. - const emptyHealth: unknown = {}; + const emptyHealth: Snapshot["health"] = {}; const snapshot: Snapshot = { presence, health: emptyHealth, diff --git a/src/gateway/worker-environments/inference-runtime.test.ts b/src/gateway/worker-environments/inference-runtime.test.ts index 6255fd56f6a2..ef58790f3ec7 100644 --- a/src/gateway/worker-environments/inference-runtime.test.ts +++ b/src/gateway/worker-environments/inference-runtime.test.ts @@ -768,8 +768,11 @@ describe("worker inference provider runtime", () => { it("preserves adaptive provider policy while lowering the core stream effort", async () => { const runtime = setup(); - const inferenceRequest = request(); - inferenceRequest.options.reasoning = "adaptive"; + const baseRequest = request(); + const inferenceRequest = { + ...baseRequest, + options: { ...baseRequest.options, reasoning: "adaptive" as const }, + }; expect(await runtime.executor(params(inferenceRequest, vi.fn()))).toMatchObject({ type: "done", diff --git a/src/infra/question-channel-runtime-internal.ts b/src/infra/question-channel-runtime-internal.ts index b6eea12ea5fa..977b2e7ea050 100644 --- a/src/infra/question-channel-runtime-internal.ts +++ b/src/infra/question-channel-runtime-internal.ts @@ -42,7 +42,7 @@ function collectAnsweredLabels( return []; } const optionLabels = new Set(question.options.map((option) => option.label)); - return (answers[question.id]?.answers ?? []).filter((answer) => optionLabels.has(answer)); + return (answers[question.questionId] ?? []).filter((answer) => optionLabels.has(answer)); }); } diff --git a/src/infra/question-channel-runtime.test.ts b/src/infra/question-channel-runtime.test.ts index 11d65f1a14e2..1d7dce5f7eba 100644 --- a/src/infra/question-channel-runtime.test.ts +++ b/src/infra/question-channel-runtime.test.ts @@ -8,7 +8,7 @@ const record: QuestionRecord = { status: "pending", questions: [ { - id: "target", + questionId: "target", header: "Target", question: "Deploy where?", options: [{ label: "Staging" }, { label: "Production" }], @@ -28,7 +28,7 @@ describe("question channel runtime", () => { const event = { id: record.id, status: "answered" as const, - answers: { answers: { target: { answers: ["Production"] } } }, + answers: { answers: { target: ["Production"] } }, }; runtime.handleResolved(event); runtime.handleResolved(event); @@ -60,7 +60,7 @@ describe("question channel runtime", () => { runtime.handleResolved({ id: record.id, status: "answered", - answers: { answers: { target: { answers: ["@everyone secret-ish text"] } } }, + answers: { answers: { target: ["@everyone secret-ish text"] } }, }); await vi.waitFor(() => expect(finalize).toHaveBeenCalledWith("Answered")); @@ -111,7 +111,7 @@ describe("terminal status labels", () => { id: "ask_q", questions: [ { - id: "deploy", + questionId: "deploy", header: "Deploy", question: "Where?", options: [{ label: "Staging" }, { label: "Production" }], @@ -121,7 +121,7 @@ describe("terminal status labels", () => { createdAtMs: 1, expiresAtMs: 2, status: "answered", - answers: { answers: { deploy: { answers: ["Staging"] } } }, + answers: { answers: { deploy: ["Staging"] } }, }; const finalize = vi.fn(); const runtime = createQuestionChannelRuntime(); @@ -130,7 +130,7 @@ describe("terminal status labels", () => { runtime.handleResolved({ id: "ask_q", status: "answered", - answers: { answers: { deploy: { answers: ["Staging"] } } }, + answers: { answers: { deploy: ["Staging"] } }, }); await vi.waitFor(() => expect(finalize).toHaveBeenCalledWith("Answered: Staging")); diff --git a/src/infra/question-gateway-resolver.test.ts b/src/infra/question-gateway-resolver.test.ts index 8392d5e22097..8295dc70a3e1 100644 --- a/src/infra/question-gateway-resolver.test.ts +++ b/src/infra/question-gateway-resolver.test.ts @@ -12,7 +12,7 @@ const pendingRecord = { status: "pending", questions: [ { - id: "deploy_target", + questionId: "deploy_target", header: "Target", question: "Where should this deploy?", options: [{ label: "Staging" }, { label: "Production" }], @@ -37,7 +37,7 @@ describe("resolveQuestionOverGateway", () => { it("maps the rendered option value to the canonical question id", async () => { hoisted.callGateway.mockResolvedValueOnce({ question: pendingRecord }).mockResolvedValueOnce({ status: "answered", - answers: { answers: { deploy_target: { answers: ["Production"] } } }, + answers: { answers: { deploy_target: ["Production"] } }, }); await expect( @@ -65,7 +65,7 @@ describe("resolveQuestionOverGateway", () => { method: "question.resolve", params: { id: recordId, - answers: { answers: { deploy_target: { answers: ["Production"] } } }, + answers: { answers: { deploy_target: ["Production"] } }, resolvedBy: "telegram:42", }, }), @@ -127,7 +127,7 @@ describe("resolveQuestionOverGateway", () => { questions: [ ...pendingRecord.questions, { - id: "region", + questionId: "region", header: "Region", question: "Which region?", options: [{ label: "EU" }, { label: "US" }], diff --git a/src/infra/question-gateway-resolver.ts b/src/infra/question-gateway-resolver.ts index be6a81e0f9ce..d9d5775d651d 100644 --- a/src/infra/question-gateway-resolver.ts +++ b/src/infra/question-gateway-resolver.ts @@ -99,7 +99,7 @@ export async function resolveQuestionOverGateway( method: "question.resolve", params: { id: params.questionId, - answers: { answers: { [question.id]: { answers: [optionValue] } } }, + answers: { answers: { [question.questionId]: [optionValue] } }, resolvedBy: params.senderId?.trim() || undefined, }, }); @@ -110,5 +110,5 @@ export async function resolveQuestionOverGateway( } throw error; } - return { status: "answered", questionId: question.id, optionValue }; + return { status: "answered", questionId: question.questionId, optionValue }; } diff --git a/src/infra/question-reaction-runtime.test.ts b/src/infra/question-reaction-runtime.test.ts index c03c8763caec..d13dcdba9c66 100644 --- a/src/infra/question-reaction-runtime.test.ts +++ b/src/infra/question-reaction-runtime.test.ts @@ -96,7 +96,7 @@ describe("question reaction runtime", () => { status: "pending", questions: [ { - id: "target", + questionId: "target", header: "Target", question: "Deploy where?", options: [{ label: "Staging" }, { label: "Production" }], @@ -124,7 +124,7 @@ describe("question reaction runtime", () => { expect.objectContaining({ method: "question.resolve", params: expect.objectContaining({ - answers: { answers: { target: { answers: ["Production"] } } }, + answers: { answers: { target: ["Production"] } }, }), }), ); diff --git a/src/infra/terminal-file-upload.test.ts b/src/infra/terminal-file-upload.test.ts index 91a47676d49a..d3303feddf85 100644 --- a/src/infra/terminal-file-upload.test.ts +++ b/src/infra/terminal-file-upload.test.ts @@ -1,8 +1,8 @@ import { mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { MAX_TERMINAL_UPLOAD_BYTES } from "../../packages/gateway-protocol/src/terminal-upload-constants.js"; -import { isCanonicalTerminalUploadBase64 } from "../../packages/gateway-protocol/src/terminal-upload-constants.js"; +import { MAX_TERMINAL_UPLOAD_BYTES } from "../../packages/gateway-protocol/src/schema/terminal-constants.js"; +import { isCanonicalTerminalUploadBase64 } from "../../packages/gateway-protocol/src/schema/terminal-constants.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { ensureTerminalUploadCleanup, stageTerminalUpload } from "./terminal-file-upload.js"; diff --git a/src/infra/terminal-file-upload.ts b/src/infra/terminal-file-upload.ts index c72740d3b62b..c540eda26914 100644 --- a/src/infra/terminal-file-upload.ts +++ b/src/infra/terminal-file-upload.ts @@ -6,7 +6,7 @@ import { MAX_TERMINAL_UPLOAD_BASE64_LENGTH, MAX_TERMINAL_UPLOAD_BYTES, terminalUploadDecodedSize, -} from "../../packages/gateway-protocol/src/terminal-upload-constants.js"; +} from "../../packages/gateway-protocol/src/schema/terminal-constants.js"; import { logWarn } from "../logger.js"; const TERMINAL_UPLOAD_PREFIX = "openclaw-terminal-upload-"; diff --git a/src/plugin-sdk/session-catalog.ts b/src/plugin-sdk/session-catalog.ts index 2e664230fbff..e6f6469db537 100644 --- a/src/plugin-sdk/session-catalog.ts +++ b/src/plugin-sdk/session-catalog.ts @@ -16,6 +16,7 @@ export type { SessionCatalogCapabilities, SessionCatalogDescriptor, SessionCatalogHost, + SessionCatalogLocator, SessionCatalogSession, SessionCatalogTranscriptItem, SessionsCatalogArchiveParams, diff --git a/src/worker/worker-rpc-clients.test.ts b/src/worker/worker-rpc-clients.test.ts index 38b002c07b0d..4ba0138a3d73 100644 --- a/src/worker/worker-rpc-clients.test.ts +++ b/src/worker/worker-rpc-clients.test.ts @@ -477,7 +477,10 @@ describe("worker inference proxy client", () => { const onStreamGap = vi.fn(); const terminal = doneOutcome(); - const request = structuredClone(INFERENCE_REQUEST); + const request = { + ...structuredClone(INFERENCE_REQUEST), + modelRef: { ...INFERENCE_REQUEST.modelRef }, + }; const outcome = client.start(request, { onEvent, onStreamGap }); request.modelRef.model = "caller-mutation"; await vi.waitFor(() => expect(harness.requestInferenceStart).toHaveBeenCalledOnce()); diff --git a/ui/src/app/question-prompt.test.ts b/ui/src/app/question-prompt.test.ts index 2c4a8cb14ed3..654fbc0f6c60 100644 --- a/ui/src/app/question-prompt.test.ts +++ b/ui/src/app/question-prompt.test.ts @@ -27,7 +27,7 @@ function requestedPayload(overrides: Record = {}) { id: "question-1", questions: [ { - id: "format", + questionId: "format", header: "Format", question: "Which format should I use?", options: [{ label: "Compact", description: "Keep it brief" }, { label: "Detailed" }], @@ -70,7 +70,7 @@ describe("question event parsing", () => { id: "question-1", sessionKey: "agent:main:main", status: "pending", - questions: [{ id: "format", options: [{ label: "Compact" }, { label: "Detailed" }] }], + questions: [{ questionId: "format", options: [{ label: "Compact" }, { label: "Detailed" }] }], }); expect( handleQuestionPromptEvent(state, { @@ -78,13 +78,13 @@ describe("question event parsing", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Compact"] } } }, + answers: { answers: { format: ["Compact"] } }, }, }), ).toBe(true); expect(state.prompts.get("question-1")).toMatchObject({ status: "answered", - answers: { answers: { format: { answers: ["Compact"] } } }, + answers: { answers: { format: ["Compact"] } }, }); }); @@ -100,7 +100,7 @@ describe("question event parsing", () => { handleQuestionPromptEvent(state, { event: "question.requested", payload: requestedPayload({ - questions: [{ id: "Bad ID", header: "Bad", question: "Bad?", options: [] }], + questions: [{ questionId: "Bad ID", header: "Bad", question: "Bad?", options: [] }], }), }), ).toBe(false); @@ -110,7 +110,7 @@ describe("question event parsing", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: "Compact" } } }, + answers: { answers: { format: "Compact" } }, }, }), ).toBe(false); @@ -146,14 +146,14 @@ describe("question prompt state", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }, }); expect(state.prompts.get("question-1")).toMatchObject({ status: "answered", answeredElsewhere: true, - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }); }); @@ -178,7 +178,7 @@ describe("question prompt state", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Compact"] } } }, + answers: { answers: { format: ["Compact"] } }, }, }); releaseRequest(); @@ -213,7 +213,7 @@ describe("question prompt state", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }, }); rejectRequest(new Error("question already resolved")); @@ -224,7 +224,7 @@ describe("question prompt state", () => { answeredElsewhere: true, localResolutionConfirmed: false, submitting: false, - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }); }); @@ -249,7 +249,7 @@ describe("question prompt state", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Compact"] } } }, + answers: { answers: { format: ["Compact"] } }, }, }); rejectRequest(new Error("connection closed")); @@ -259,7 +259,7 @@ describe("question prompt state", () => { status: "answered", answeredElsewhere: false, submitting: false, - answers: { answers: { format: { answers: ["Compact"] } } }, + answers: { answers: { format: ["Compact"] } }, }); }); @@ -334,9 +334,9 @@ describe("question RPC helpers", () => { id: "question-1", answers: { answers: { - format: { answers: ["Compact"] }, - destination: { answers: ["My own target"] }, - extras: { answers: ["Tests", "Docs"] }, + format: ["Compact"], + destination: ["My own target"], + extras: ["Tests", "Docs"], }, }, }); @@ -420,7 +420,7 @@ describe("refreshPendingQuestions", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }, }); finishList({ questions: [requestedPayload()] }); @@ -439,7 +439,7 @@ describe("refreshPendingQuestions", () => { : Promise.resolve({ question: requestedPayload({ status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }), }), ); @@ -453,7 +453,7 @@ describe("refreshPendingQuestions", () => { payload: { id: "question-1", status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }, }); finishList({ questions: [] }); @@ -463,7 +463,7 @@ describe("refreshPendingQuestions", () => { expect(state.prompts.get("question-1")).toMatchObject({ status: "answered", answeredElsewhere: true, - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }); expect(state.unmatchedResolutions.size).toBe(0); }); @@ -476,7 +476,7 @@ describe("refreshPendingQuestions", () => { return { question: requestedPayload({ status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }), }; }); @@ -495,7 +495,7 @@ describe("refreshPendingQuestions", () => { expect(state.prompts.get("question-1")).toMatchObject({ status: "answered", answeredElsewhere: true, - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }); }); @@ -512,7 +512,7 @@ describe("refreshPendingQuestions", () => { return { question: requestedPayload({ status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }), }; }); @@ -533,7 +533,7 @@ describe("refreshPendingQuestions", () => { expect(state.prompts.get("question-1")).toMatchObject({ status: "answered", answeredElsewhere: true, - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }); }); @@ -573,7 +573,7 @@ describe("refreshPendingQuestions", () => { : { question: requestedPayload({ status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }), }, ); @@ -597,7 +597,7 @@ describe("refreshPendingQuestions", () => { status: "answered", locallyExpired: false, answeredElsewhere: true, - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }); }); @@ -630,7 +630,7 @@ describe("refreshPendingQuestions", () => { finishGet({ question: requestedPayload({ status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }), }); @@ -655,7 +655,7 @@ describe("refreshPendingQuestions", () => { return Promise.resolve({ question: requestedPayload({ status: "answered", - answers: { answers: { format: { answers: ["Detailed"] } } }, + answers: { answers: { format: ["Detailed"] } }, }), }); }); diff --git a/ui/src/app/question-prompt.ts b/ui/src/app/question-prompt.ts index fd7aa78f9dea..d83e37adc893 100644 --- a/ui/src/app/question-prompt.ts +++ b/ui/src/app/question-prompt.ts @@ -67,16 +67,33 @@ function readTimestamp(value: unknown): number | null { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; } +const MAX_HEADER_GRAPHEMES = 12; + +function clampHeaderGraphemes(header: string): string { + const segments = [...new Intl.Segmenter().segment(header)]; + if (segments.length <= MAX_HEADER_GRAPHEMES) { + return header; + } + return segments + .slice(0, MAX_HEADER_GRAPHEMES) + .map((part) => part.segment) + .join(""); +} + function parseQuestion(value: unknown): Question | null { if (!isRecord(value)) { return null; } - const id = readNonEmptyString(value.id); + const questionId = readNonEmptyString(value.questionId); const header = typeof value.header === "string" ? value.header : null; const question = readNonEmptyString(value.question); - if (!id || !/^[a-z][a-z0-9_]*$/.test(id) || header === null || header.length > 12 || !question) { + if (!questionId || !/^[a-z][a-z0-9_]*$/.test(questionId) || header === null || !question) { return null; } + // Clamp instead of reject: the gateway enforces the 12-cap with grapheme + // semantics, and any re-count here (UTF-16, code points, or a second grapheme + // impl) can disagree at the boundary and silently drop the whole prompt. + const clampedHeader = clampHeaderGraphemes(header); if (!Array.isArray(value.options) || value.options.length > 4) { return null; } @@ -104,8 +121,8 @@ function parseQuestion(value: unknown): Question | null { } } return { - id, - header, + questionId, + header: clampedHeader, question, options, ...(value.multiSelect === true ? { multiSelect: true } : {}), @@ -118,18 +135,14 @@ function parseQuestionAnswers(value: unknown): QuestionAnswers | null { return null; } const answers: QuestionAnswers["answers"] = {}; - for (const [id, answerValue] of Object.entries(value.answers)) { - if ( - !/^[a-z][a-z0-9_]*$/.test(id) || - !isRecord(answerValue) || - !Array.isArray(answerValue.answers) - ) { + for (const [questionId, answerValue] of Object.entries(value.answers)) { + if (!/^[a-z][a-z0-9_]*$/.test(questionId) || !Array.isArray(answerValue)) { return null; } - if (!answerValue.answers.every((answer) => typeof answer === "string")) { + if (!answerValue.every((answer) => typeof answer === "string")) { return null; } - answers[id] = { answers: [...answerValue.answers] }; + answers[questionId] = [...answerValue]; } return { answers }; } @@ -148,9 +161,9 @@ function questionAnswersEqual( leftIds.every( (id, index) => id === rightIds[index] && - left.answers[id]?.answers.length === right.answers[id]?.answers.length && - left.answers[id]?.answers.every((answer, answerIndex) => - Object.is(answer, right.answers[id]?.answers[answerIndex]), + left.answers[id]?.length === right.answers[id]?.length && + left.answers[id]?.every((answer, answerIndex) => + Object.is(answer, right.answers[id]?.[answerIndex]), ), ) ); @@ -173,7 +186,7 @@ function parseQuestionRecord(payload: unknown): QuestionRecord | null { if (questions.some((question) => question === null)) { return null; } - const questionIds = new Set(questions.map((question) => question?.id)); + const questionIds = new Set(questions.map((question) => question?.questionId)); if (questionIds.size !== questions.length) { return null; } @@ -554,9 +567,7 @@ export function disposeQuestionPromptState(state: QuestionPromptState): void { function buildAnswers(values: QuestionAnswerValues): QuestionAnswers { return { - answers: Object.fromEntries( - Object.entries(values).map(([id, answers]) => [id, { answers: [...answers] }]), - ), + answers: Object.fromEntries(Object.entries(values).map(([id, answers]) => [id, [...answers]])), }; } diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index ffc4f2dd5fe3..3f16e86ae363 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -39,8 +39,8 @@ export function adoptedCatalogSessionKeys(catalogs: readonly SessionCatalog[]): for (const catalog of catalogs) { for (const host of catalog.hosts) { for (const session of host.sessions) { - if (session.openClawSessionKey) { - keys.add(session.openClawSessionKey); + if (session.sessionKey) { + keys.add(session.sessionKey); } } } @@ -78,7 +78,7 @@ export function bindAdoptedCatalogSession( ...host, sessions: host.sessions.map((session) => session.threadId === detail.threadId - ? { ...session, openClawSessionKey: detail.sessionKey } + ? { ...session, sessionKey: detail.sessionKey } : session, ), } @@ -170,9 +170,7 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) { host.sessions.map((session) => ({ host, session })), ); const liveRows = rows.flatMap(({ session }) => { - const row = session.openClawSessionKey - ? liveRowsByKey.get(session.openClawSessionKey) - : undefined; + const row = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined; return row ? [row] : []; }); const hasActiveRun = liveRows.some((row) => row.hasActiveRun === true); @@ -344,9 +342,7 @@ function renderCatalogSessionRow( typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000 ? rawTimestamp * 1000 : rawTimestamp; - const adoptedRow = session.openClawSessionKey - ? liveRowsByKey.get(session.openClawSessionKey) - : undefined; + const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined; if (adoptedRow) { const label = session.name || session.threadId; return params.renderLiveRow(adoptedRow, { @@ -360,7 +356,7 @@ function renderCatalogSessionRow( hostId: host.hostId, threadId: session.threadId, } satisfies CatalogSessionKey; - const key = session.openClawSessionKey ?? buildCatalogSessionKey(catalogKey); + const key = session.sessionKey ?? buildCatalogSessionKey(catalogKey); const label = session.name || session.threadId; const meta = formatSidebarTimestamp(timestamp); const search = searchForSession(key); diff --git a/ui/src/e2e/question-flow.e2e.test.ts b/ui/src/e2e/question-flow.e2e.test.ts index 4ebf407a57da..a1eccbc64a65 100644 --- a/ui/src/e2e/question-flow.e2e.test.ts +++ b/ui/src/e2e/question-flow.e2e.test.ts @@ -26,7 +26,7 @@ let server: ControlUiE2eServer; function questionRecord( id: string, questions: Array<{ - id: string; + questionId: string; header: string; question: string; options: Array<{ label: string; description?: string }>; @@ -129,7 +129,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { await composer.fill("Keep this release note draft"); const request = questionRecord("question-deploy-target", [ { - id: "deploy_target", + questionId: "deploy_target", header: "Deploy", question: "Where should I deploy?", options: [ @@ -211,7 +211,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { id: request.id, answers: { answers: { - deploy_target: { answers: ["Staging (Recommended)"] }, + deploy_target: ["Staging (Recommended)"], }, }, }); @@ -221,7 +221,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { status: "answered", answers: { answers: { - deploy_target: { answers: ["Staging (Recommended)"] }, + deploy_target: ["Staging (Recommended)"], }, }, }); @@ -243,7 +243,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { const { gateway, page } = await openQuestionPage(); const request = questionRecord("question-release-checks", [ { - id: "release_checks", + questionId: "release_checks", header: "Checks", question: "Which release checks should I run?", options: [ @@ -275,7 +275,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { id: request.id, answers: { answers: { - release_checks: { answers: ["Tests", "Metrics"] }, + release_checks: ["Tests", "Metrics"], }, }, }); @@ -285,7 +285,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { const { gateway, page } = await openQuestionPage(); const elsewhere = questionRecord("question-external-answer", [ { - id: "approval_path", + questionId: "approval_path", header: "Approval", question: "Who should approve the release?", options: [{ label: "Maintainer" }, { label: "Release manager" }], @@ -293,7 +293,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { ]); const expired = questionRecord("question-expired-window", [ { - id: "release_window", + questionId: "release_window", header: "Window", question: "When should the release start?", options: [{ label: "Now" }, { label: "Tomorrow" }], @@ -304,7 +304,7 @@ describeControlUiE2e("Control UI Gateway question flow", () => { await gateway.emitGatewayEvent("question.resolved", { id: elsewhere.id, status: "answered", - answers: { answers: { approval_path: { answers: ["Release manager"] } } }, + answers: { answers: { approval_path: ["Release manager"] } }, }); await emitRequested(gateway, expired); await gateway.emitGatewayEvent("question.resolved", { @@ -314,14 +314,14 @@ describeControlUiE2e("Control UI Gateway question flow", () => { const stepper = questionRecord("question-release-plan", [ { - id: "channel", + questionId: "channel", header: "Channel", question: "Which release channel should I use?", options: [{ label: "Beta" }, { label: "Stable" }], isOther: true, }, { - id: "notes", + questionId: "notes", header: "Notes", question: "Which notes should I include?", options: [{ label: "Highlights" }, { label: "Full details" }], diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 21ec2fc35554..68d93cb4e367 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -56,7 +56,7 @@ function questionPrompt(id: string, question: string): QuestionPrompt { id, questions: [ { - id: "choice", + questionId: "choice", header: "Choice", question, options: [{ label: "Yes" }, { label: "No" }], diff --git a/ui/src/pages/chat/chat-thread.question.test.ts b/ui/src/pages/chat/chat-thread.question.test.ts index bd2b7fa70818..35980858f13b 100644 --- a/ui/src/pages/chat/chat-thread.question.test.ts +++ b/ui/src/pages/chat/chat-thread.question.test.ts @@ -12,7 +12,7 @@ function prompt(status: QuestionPrompt["status"]): QuestionPrompt { id: "question-1", questions: [ { - id: "format", + questionId: "format", header: "Format", question: "Which format?", options: [{ label: "Compact" }, { label: "Detailed" }], @@ -75,7 +75,7 @@ describe("question chat items", () => { it("renders answered and skipped prompts as compact summary lines", () => { const answered = prompt("answered"); - answered.answers = { answers: { format: { answers: ["Compact"] } } }; + answered.answers = { answers: { format: ["Compact"] } }; const skipped = prompt("cancelled"); const container = document.createElement("div"); @@ -94,7 +94,7 @@ describe("question chat items", () => { it("keeps supplied answer labels when another client resolved the question", () => { const answered = prompt("answered"); answered.answeredElsewhere = true; - answered.answers = { answers: { format: { answers: ["Detailed"] } } }; + answered.answers = { answers: { format: ["Detailed"] } }; const container = document.createElement("div"); render(renderChatQuestionSummary(answered), container); diff --git a/ui/src/pages/chat/components/chat-question-card.test.ts b/ui/src/pages/chat/components/chat-question-card.test.ts index e540ccb7afa2..1364867d06bf 100644 --- a/ui/src/pages/chat/components/chat-question-card.test.ts +++ b/ui/src/pages/chat/components/chat-question-card.test.ts @@ -14,7 +14,7 @@ function gatewayPrompt(overrides: Partial = {}): QuestionPrompt id: "question-1", questions: [ { - id: "format", + questionId: "format", header: "Format", question: "Which format should I use?", options: [ @@ -90,14 +90,14 @@ describe("shared question panel", () => { const prompt = gatewayPrompt({ questions: [ { - id: "target", + questionId: "target", header: "Target", question: "Where should I send it?", options: [{ label: "Chat" }, { label: "File" }], isOther: true, }, { - id: "extras", + questionId: "extras", header: "Extras", question: "Which extras should I include?", options: [{ label: "Tests" }, { label: "Docs" }], @@ -174,7 +174,7 @@ describe("shared question panel", () => { questions: [ ...gatewayPrompt().questions, { - id: "confirm", + questionId: "confirm", header: "Confirm", question: "Ready to continue?", options: [{ label: "Ready" }], diff --git a/ui/src/pages/chat/components/chat-question-card.ts b/ui/src/pages/chat/components/chat-question-card.ts index ce3d5bb45fb8..80c40594d0c7 100644 --- a/ui/src/pages/chat/components/chat-question-card.ts +++ b/ui/src/pages/chat/components/chat-question-card.ts @@ -6,7 +6,7 @@ import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; type QuestionPanelQuestion = { - id: string; + questionId: string; header: string; question: string; options: Array<{ label: string; description?: string }>; @@ -59,9 +59,9 @@ function formatRemaining(expiresAtMs: number, nowMs: number): string { function promptDraftAnswers(prompt: QuestionPrompt): Record { return Object.fromEntries( prompt.questions.map((question) => { - const draft = prompt.drafts.get(question.id); + const draft = prompt.drafts.get(question.questionId); return [ - question.id, + question.questionId, [...(draft?.selected ?? []), ...(draft?.freeText.trim() ? [draft.freeText.trim()] : [])], ]; }), @@ -70,9 +70,9 @@ function promptDraftAnswers(prompt: QuestionPrompt): Record { function updatePromptDrafts(prompt: QuestionPrompt, answersById: Record): void { for (const question of prompt.questions) { - const values = answersById[question.id] ?? []; + const values = answersById[question.questionId] ?? []; const optionLabels = new Set(question.options.map((option) => option.label)); - prompt.drafts.set(question.id, { + prompt.drafts.set(question.questionId, { selected: new Set(values.filter((value) => optionLabels.has(value))), freeText: values.find((value) => !optionLabels.has(value)) ?? "", }); @@ -142,7 +142,7 @@ function terminalAnswer(prompt: QuestionPrompt, question: QuestionPanelQuestion) if (prompt.status === "unavailable") { return t("chat.questions.unavailable"); } - const answer = prompt.answers?.answers[question.id]?.answers.join(", "); + const answer = prompt.answers?.answers[question.questionId]?.join(", "); if (answer) { return answer; } @@ -234,14 +234,14 @@ class ChatQuestionPanel extends LitElement { const freeTextById = new Map(); for (const question of model.questions) { const optionLabels = new Set(question.options.map((option) => option.label)); - const values = model.answersById[question.id] ?? []; + const values = model.answersById[question.questionId] ?? []; selectedById.set( - question.id, + question.questionId, values.filter((value) => optionLabels.has(value)), ); const custom = values.filter((value) => !optionLabels.has(value)).join(", "); if (custom) { - freeTextById.set(question.id, custom); + freeTextById.set(question.questionId, custom); } } this.selectedById = selectedById; @@ -257,14 +257,14 @@ class ChatQuestionPanel extends LitElement { } private answerValues(question: QuestionPanelQuestion): string[] { - const selected = this.selectedById.get(question.id) ?? []; - const freeText = this.freeTextById.get(question.id)?.trim(); + const selected = this.selectedById.get(question.questionId) ?? []; + const freeText = this.freeTextById.get(question.questionId)?.trim(); return [...selected, ...(freeText ? [freeText] : [])]; } private buildAnswers(model: QuestionPanelViewModel): Record { return Object.fromEntries( - model.questions.map((question) => [question.id, this.answerValues(question)]), + model.questions.map((question) => [question.questionId, this.answerValues(question)]), ); } @@ -288,9 +288,9 @@ class ChatQuestionPanel extends LitElement { advance = true, ): void { const selectedById = new Map(this.selectedById); - const current = selectedById.get(question.id) ?? []; + const current = selectedById.get(question.questionId) ?? []; selectedById.set( - question.id, + question.questionId, question.multiSelect ? current.includes(label) ? current.filter((value) => value !== label) @@ -301,7 +301,7 @@ class ChatQuestionPanel extends LitElement { this.selectedById = selectedById; if (!question.multiSelect) { const freeTextById = new Map(this.freeTextById); - freeTextById.delete(question.id); + freeTextById.delete(question.questionId); this.freeTextById = freeTextById; } this.answersChanged(model); @@ -320,9 +320,9 @@ class ChatQuestionPanel extends LitElement { question: QuestionPanelQuestion, value: string, ): void { - this.freeTextById = new Map(this.freeTextById).set(question.id, value); + this.freeTextById = new Map(this.freeTextById).set(question.questionId, value); if (!question.multiSelect && value.trim()) { - this.selectedById = new Map(this.selectedById).set(question.id, []); + this.selectedById = new Map(this.selectedById).set(question.questionId, []); } this.answersChanged(model); } @@ -556,9 +556,13 @@ class ChatQuestionPanel extends LitElement { aria-label=${question.header} > ${question.options.map((option, index) => { - const selected = (this.selectedById.get(question.id) ?? []).includes(option.label); + const selected = (this.selectedById.get(question.questionId) ?? []).includes( + option.label, + ); const radioTabIndex = - selected || (!this.selectedById.get(question.id)?.length && index === 0) ? 0 : -1; + selected || (!this.selectedById.get(question.questionId)?.length && index === 0) + ? 0 + : -1; return html`