mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Question>): Map<String, List<String>>? {
|
||||
val result = linkedMapOf<String, List<String>>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<QuestionOption>,
|
||||
@@ -92,7 +92,7 @@ data class Question(
|
||||
|
||||
@Serializable
|
||||
data class QuestionAnswers(
|
||||
val answers: Map<String, QuestionAnswersAnswersValue>,
|
||||
val answers: Map<String, List<String>>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -130,11 +130,6 @@ data class GatewayNodeInvokeResultParamsError(
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QuestionAnswersAnswersValue(
|
||||
val answers: List<String>,
|
||||
)
|
||||
|
||||
enum class GatewayMethod(
|
||||
val rawValue: String,
|
||||
) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-2
@@ -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")),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -365,6 +365,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
let turnId: string | undefined;
|
||||
let removeRequestHandler: (() => void) | undefined;
|
||||
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
|
||||
const activeDynamicToolCalls = new Set<Promise<unknown>>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -37,7 +37,7 @@ function createGatewayStub() {
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
const resolveParams = params as {
|
||||
answers?: { answers: Record<string, { answers: string[] }> };
|
||||
answers?: { answers: Record<string, string[]> };
|
||||
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({
|
||||
|
||||
@@ -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<string, string[]>): JsonObject {
|
||||
return {
|
||||
answers: Object.fromEntries(
|
||||
Object.entries(answers).map(([questionId, values]) => [questionId, { answers: values }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyUserInputResponse(): JsonObject {
|
||||
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,7 +35,7 @@ function createGatewayStub() {
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
const resolved = params as {
|
||||
answers?: { answers: Record<string, { answers: string[] }> };
|
||||
answers?: { answers: Record<string, string[]> };
|
||||
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];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AuditActivityListParams>(
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export function lazyCompile<T>(
|
||||
schema: TSchema,
|
||||
precheck?: (data: unknown) => ValidationError | undefined,
|
||||
): ProtocolValidator<T>;
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export function lazyCompile<T = unknown>(
|
||||
schema: TSchema,
|
||||
precheck?: (data: unknown) => ValidationError | undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
SessionCatalogCapabilities,
|
||||
SessionCatalogDescriptor,
|
||||
SessionCatalogHost,
|
||||
SessionCatalogLocator,
|
||||
SessionCatalogSession,
|
||||
SessionCatalogTranscriptItem,
|
||||
SessionsCatalogArchiveParams,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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")]),
|
||||
|
||||
@@ -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())])),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof EnvironmentStatusSchema>;
|
||||
export type WorkerEnvironmentState = Static<typeof WorkerEnvironmentStateSchema>;
|
||||
export type WorkerTunnelStatus = Static<typeof WorkerTunnelStatusSchema>;
|
||||
export type WorkerEnvironmentMetadata = Static<typeof WorkerEnvironmentMetadataSchema>;
|
||||
export type WorkerEnvironmentProfileSummary = Static<typeof WorkerEnvironmentProfileSummarySchema>;
|
||||
export type EnvironmentSummary = Static<typeof EnvironmentSummarySchema>;
|
||||
export type EnvironmentsCreateParams = Static<typeof EnvironmentsCreateParamsSchema>;
|
||||
export type EnvironmentsCreateResult = Static<typeof EnvironmentsCreateResultSchema>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -86,12 +86,11 @@ export type ChatMessageGetResult = Static<typeof ChatMessageGetResultSchema>;
|
||||
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<typeof RunToolBindingsSchema>;
|
||||
|
||||
/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */
|
||||
export const ChatSendParamsSchema = closedObject({
|
||||
|
||||
@@ -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<typeof MemoryMigrationItemStatusSchema>;
|
||||
export type MemoryMigrationItem = Static<typeof MemoryMigrationItemSchema>;
|
||||
export type MemoryMigrationProviderPlan = Static<typeof MemoryMigrationProviderPlanSchema>;
|
||||
export type MigrationsMemoryPlanParams = Static<typeof MigrationsMemoryPlanParamsSchema>;
|
||||
export type MigrationsMemoryPlanResult = Static<typeof MigrationsMemoryPlanResultSchema>;
|
||||
export type MigrationsMemoryApplyParams = Static<typeof MigrationsMemoryApplyParamsSchema>;
|
||||
export type MigrationsMemoryApplyResult = Static<typeof MigrationsMemoryApplyResultSchema>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<typeof QuestionOptionSchema>;
|
||||
export type Question = Static<typeof QuestionSchema>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof SessionCatalogCapabilitiesSchema>;
|
||||
export type SessionCatalogLocator = Static<typeof SessionCatalogLocatorSchema>;
|
||||
export type SessionCatalogDescriptor = Static<typeof SessionCatalogDescriptorSchema>;
|
||||
export type SessionCatalogSession = Static<typeof SessionCatalogSessionSchema>;
|
||||
export type SessionCatalogHost = Static<typeof SessionCatalogHostSchema>;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TSchema } from "typebox";
|
||||
|
||||
/** Adds protocol-vintage metadata without changing the schema's validated value shape. */
|
||||
export function withSince<T extends TSchema>(train: string, schema: T): T {
|
||||
Object.assign(schema, { "x-openclaw-since": train });
|
||||
return schema;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof SystemEventParamsSchema>;
|
||||
export const validateSystemEventParams = lazyCompile(SystemEventParamsSchema);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<typeof TerminalAckResultSchema>;
|
||||
|
||||
/** 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<typeof TerminalDataEventSchema>;
|
||||
|
||||
/** 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<typeof TerminalExitEventSchema>;
|
||||
|
||||
/** 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<typeof TerminalEventSchema>;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<const Properties extends TProperties>(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,
|
||||
|
||||
@@ -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<const Properties extends TProperties>(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<WorkerTranscriptMessage, { role: "user" }>,
|
||||
"content"
|
||||
> & {
|
||||
content: string | Extract<WorkerTranscriptMessage, { role: "user" }>["content"];
|
||||
runtimeContextCarrier?: boolean;
|
||||
};
|
||||
type WorkerInferenceContextMessage =
|
||||
| WorkerInferenceUserMessage
|
||||
| Extract<WorkerTranscriptMessage, { role: "assistant" | "toolResult" }>;
|
||||
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<WorkerTranscriptMessage, { role: "assistant" }>["usage"];
|
||||
type WorkerInferenceAssistantMessage = Omit<
|
||||
Extract<WorkerTranscriptMessage, { role: "assistant" }>,
|
||||
"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<typeof WorkerInferenceModelRefSchema>;
|
||||
export type WorkerInferenceContext = Static<typeof WorkerInferenceContextSchema>;
|
||||
export type WorkerInferenceOptions = Static<typeof WorkerInferenceOptionsSchema>;
|
||||
export type WorkerInferenceStartParams = Static<typeof WorkerInferenceStartParamsSchema>;
|
||||
export type WorkerInferenceStartResult = Static<typeof WorkerInferenceStartResultSchema>;
|
||||
export type WorkerInferenceErrorReason = Static<typeof WorkerInferenceErrorReasonSchema>;
|
||||
export type WorkerInferenceErrorShape = Static<typeof WorkerInferenceErrorShapeSchema>;
|
||||
export type WorkerInferenceStartRequestFrame = Static<
|
||||
typeof WorkerInferenceStartRequestFrameSchema
|
||||
>;
|
||||
export type WorkerInferenceStartResponseFrame = Static<
|
||||
typeof WorkerInferenceStartResponseFrameSchema
|
||||
>;
|
||||
export type WorkerInferenceCancelParams = Static<typeof WorkerInferenceCancelParamsSchema>;
|
||||
export type WorkerInferenceCancelResult = Static<typeof WorkerInferenceCancelResultSchema>;
|
||||
export type WorkerInferenceCancelRequestFrame = Static<
|
||||
typeof WorkerInferenceCancelRequestFrameSchema
|
||||
>;
|
||||
export type WorkerInferenceCancelResponseFrame = Static<
|
||||
typeof WorkerInferenceCancelResponseFrameSchema
|
||||
>;
|
||||
export type WorkerInferenceEventParams = Static<typeof WorkerInferenceEventParamsSchema>;
|
||||
export type WorkerInferenceEventFrame = Static<typeof WorkerInferenceEventFrameSchema>;
|
||||
export type WorkerInferenceTerminalOutcome = Static<typeof WorkerInferenceTerminalOutcomeSchema>;
|
||||
export type WorkerInferenceTerminalParams = Static<typeof WorkerInferenceTerminalParamsSchema>;
|
||||
export type WorkerInferenceTerminalFrame = Static<typeof WorkerInferenceTerminalFrameSchema>;
|
||||
|
||||
function isSafeWorkerInferenceJson(data: unknown): boolean {
|
||||
const stack: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: data }];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, JsonSchema> = {};
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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:" },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<string, { answers: string[] }> };
|
||||
answers: { answers: Record<string, string[]> };
|
||||
}>();
|
||||
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<string, { answers: string[] }> } }
|
||||
).answers;
|
||||
const resolvedAnswers = (params as { answers?: { answers: Record<string, string[]> } })
|
||||
.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<string, { answers: string[] }> };
|
||||
answers: { answers: Record<string, string[]> };
|
||||
}>();
|
||||
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<string, { answers: string[] }> } }
|
||||
).answers;
|
||||
const resolvedAnswers = (params as { answers?: { answers: Record<string, string[]> } })
|
||||
.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<string, { answers: string[] }> };
|
||||
answers: { answers: Record<string, string[]> };
|
||||
}>();
|
||||
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<string, { answers: string[] }> } })
|
||||
.answers;
|
||||
const answers = (params as { answers?: { answers: Record<string, string[]> } }).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 };
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<QuestionWaitAnswerResult> {
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<Extract<QuestionWaitAnswerResult, { status: "answered" }> | 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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -11,396 +11,434 @@ import {
|
||||
type CoreGatewayMethodSpec = {
|
||||
name: string;
|
||||
scope: GatewayMethodScope;
|
||||
since?: string;
|
||||
advertise?: false;
|
||||
startup?: true;
|
||||
controlPlaneWrite?: true;
|
||||
};
|
||||
|
||||
type CoreGatewayMethodMetadata = Pick<CoreGatewayMethodSpec, "name" | "scope" | "since">;
|
||||
|
||||
// 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<string, CoreGatewayMethodSpec> = 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 } : {}),
|
||||
|
||||
@@ -30,6 +30,7 @@ export type GatewayMethodDescriptor = {
|
||||
handler: GatewayMethodHandler;
|
||||
scope: GatewayMethodScope;
|
||||
owner: GatewayMethodOwner;
|
||||
since?: string;
|
||||
startup?: GatewayMethodStartupAvailability;
|
||||
controlPlaneWrite?: boolean;
|
||||
advertise?: boolean;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ async function call(method: string, params: Record<string, unknown>) {
|
||||
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);
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => []);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user