refactor(gateway): remove dead rpc surfaces (#121387)

This commit is contained in:
Peter Steinberger
2026-08-10 02:41:57 -07:00
committed by GitHub
parent 0296785c5f
commit bab4546b41
53 changed files with 219 additions and 2573 deletions
@@ -178,7 +178,6 @@ enum class GatewayMethod(
DoctorMemoryResetGroundedShortTerm("doctor.memory.resetGroundedShortTerm"),
DoctorMemoryRepairDreamingArtifacts("doctor.memory.repairDreamingArtifacts"),
DoctorMemoryDedupeDreamDiary("doctor.memory.dedupeDreamDiary"),
DoctorMemoryRemHarness("doctor.memory.remHarness"),
LogsTail("logs.tail"),
ChannelsStatus("channels.status"),
ChannelsStart("channels.start"),
@@ -241,11 +240,7 @@ enum class GatewayMethod(
TalkClientToolCall("talk.client.toolCall"),
TalkClientSteer("talk.client.steer"),
TalkSessionCreate("talk.session.create"),
TalkSessionJoin("talk.session.join"),
TalkSessionAppendAudio("talk.session.appendAudio"),
TalkSessionStartTurn("talk.session.startTurn"),
TalkSessionEndTurn("talk.session.endTurn"),
TalkSessionCancelTurn("talk.session.cancelTurn"),
TalkSessionCancelOutput("talk.session.cancelOutput"),
TalkSessionAcknowledgeMark("talk.session.acknowledgeMark"),
TalkSessionSubmitToolResult("talk.session.submitToolResult"),
@@ -343,17 +338,14 @@ enum class GatewayMethod(
SecretsReload("secrets.reload"),
SecretsResolve("secrets.resolve"),
VoicewakeRoutingGet("voicewake.routing.get"),
VoicewakeRoutingSet("voicewake.routing.set"),
SessionsList("sessions.list"),
SessionsSubscribe("sessions.subscribe"),
SessionsUnsubscribe("sessions.unsubscribe"),
SessionsMessagesSubscribe("sessions.messages.subscribe"),
SessionsMessagesUnsubscribe("sessions.messages.unsubscribe"),
SessionsViewersSet("sessions.viewers.set"),
SessionsPreview("sessions.preview"),
SessionsDescribe("sessions.describe"),
SessionsCompactionList("sessions.compaction.list"),
SessionsCompactionGet("sessions.compaction.get"),
SessionsCompactionBranch("sessions.compaction.branch"),
SessionsCompactionRestore("sessions.compaction.restore"),
SessionsBranchesList("sessions.branches.list"),
@@ -413,7 +405,6 @@ enum class GatewayMethod(
CronRun("cron.run"),
CronRuns("cron.runs"),
GatewayIdentityGet("gateway.identity.get"),
GatewayRestartPreflight("gateway.restart.preflight"),
GatewayRestartRequest("gateway.restart.request"),
SystemPresence("system-presence"),
SystemEvent("system-event"),
@@ -461,7 +452,6 @@ enum class GatewayMethod(
WebLoginWait("web.login.wait"),
TerminalAttach("terminal.attach"),
TerminalList("terminal.list"),
TerminalText("terminal.text"),
ControlUiGithubPreview("controlUi.githubPreview"),
SystemInfo("system.info"),
AgentsWorkspaceList("agents.workspace.list"),
@@ -6734,28 +6734,6 @@ public struct SessionsCompactionListParams: Codable, Sendable {
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let agentid: String?
public let checkpointid: String
public init(
key: String,
agentid: String? = nil,
checkpointid: String)
{
self.key = key
self.agentid = agentid
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case agentid = "agentId"
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let agentid: String?
@@ -6822,28 +6800,6 @@ public struct SessionsCompactionListResult: Codable, Sendable {
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
@@ -11102,28 +11058,6 @@ public struct TalkSessionCancelOutputParams: Codable, Sendable {
}
}
public struct TalkSessionCancelTurnParams: Codable, Sendable {
public let sessionid: String
public let turnid: String?
public let reason: String?
public init(
sessionid: String,
turnid: String? = nil,
reason: String? = nil)
{
self.sessionid = sessionid
self.turnid = turnid
self.reason = reason
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case turnid = "turnId"
case reason
}
}
public struct TalkSessionCreateParams: Codable, Sendable {
public let sessionkey: String?
public let spawnedby: String?
@@ -11260,138 +11194,6 @@ public struct TalkSessionCreateResult: Codable, Sendable {
}
}
public struct TalkSessionJoinParams: Codable, Sendable {
public let sessionid: String
public let token: String
public init(
sessionid: String,
token: String)
{
self.sessionid = sessionid
self.token = token
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case token
}
}
public struct TalkSessionJoinResult: Codable, Sendable {
public let id: String
public let roomid: String
public let roomurl: String
public let sessionkey: String
public let sessionid: String?
public let channel: String?
public let target: String?
public let provider: String?
public let model: String?
public let voice: String?
public let mode: AnyCodable
public let transport: AnyCodable
public let brain: AnyCodable
public let createdat: Double
public let expiresat: Double
public let room: [String: AnyCodable]
public init(
id: String,
roomid: String,
roomurl: String,
sessionkey: String,
sessionid: String? = nil,
channel: String? = nil,
target: String? = nil,
provider: String? = nil,
model: String? = nil,
voice: String? = nil,
mode: AnyCodable,
transport: AnyCodable,
brain: AnyCodable,
createdat: Double,
expiresat: Double,
room: [String: AnyCodable])
{
self.id = id
self.roomid = roomid
self.roomurl = roomurl
self.sessionkey = sessionkey
self.sessionid = sessionid
self.channel = channel
self.target = target
self.provider = provider
self.model = model
self.voice = voice
self.mode = mode
self.transport = transport
self.brain = brain
self.createdat = createdat
self.expiresat = expiresat
self.room = room
}
private enum CodingKeys: String, CodingKey {
case id
case roomid = "roomId"
case roomurl = "roomUrl"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case channel
case target
case provider
case model
case voice
case mode
case transport
case brain
case createdat = "createdAt"
case expiresat = "expiresAt"
case room
}
}
public struct TalkSessionTurnParams: Codable, Sendable {
public let sessionid: String
public let turnid: String?
public init(
sessionid: String,
turnid: String? = nil)
{
self.sessionid = sessionid
self.turnid = turnid
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case turnid = "turnId"
}
}
public struct TalkSessionTurnResult: Codable, Sendable {
public let ok: Bool
public let turnid: String?
public let events: [TalkEvent]?
public init(
ok: Bool,
turnid: String? = nil,
events: [TalkEvent]? = nil)
{
self.ok = ok
self.turnid = turnid
self.events = events
}
private enum CodingKeys: String, CodingKey {
case ok
case turnid = "turnId"
case events
}
}
public struct TalkSessionSteerParams: Codable, Sendable {
public let sessionid: String
public let sessionkey: String?
@@ -15394,34 +15196,6 @@ public struct TerminalListResult: Codable, Sendable {
}
}
public struct TerminalTextParams: Codable, Sendable {
public let sessionid: String
public init(
sessionid: String)
{
self.sessionid = sessionid
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
}
}
public struct TerminalTextResult: Codable, Sendable {
public let text: String
public init(
text: String)
{
self.text = text
}
private enum CodingKeys: String, CodingKey {
case text
}
}
public struct TerminalUploadParams: Codable, Sendable {
public let sessionid: String
public let name: String
+79 -79
View File
@@ -1,116 +1,116 @@
1c88517e5da24dc4f66f9d84fd9ee7165a87af1188fa37379b4b476b806c4c2a module/account-core
9669347cf05fa6a933201a02eeb7a35950e57d5da3cab89f3cf376d866316d3f module/account-helpers
ecdcae2fe366048497da9665ec9b481d80fe2b2b9a63cb4416753c9475487bea module/account-core
40d44524ca231f3e6ef2d5241a963b42050ed8ad3223ea64640fae4dc90e5291 module/account-helpers
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
2ccf6bdc0cae7e136a0ed9feba2cab10047f432fb1c50c4f711df1a5cc6e5414 module/account-resolution
be1c933a14a9e218a28e7544ad2aac009913ea2211413a9c1258417ca0ac2bc9 module/account-resolution
4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives
de9cff892a779401b945de7ef0171e6f9ea4e43944dc9d628dc9ea3e95d9d0e6 module/agent-harness
a8ed8da8c0a5e3c34840f2b869ea96ff37c4cbac6721822cd59e2cf8d0e27ee5 module/agent-harness-runtime
c4c918620045035603967551cf3fdcd606367ecd7c4ef56ad93699fb63b84c22 module/agent-harness
286b0def9192f44999b482ea4209e69c91045a34ef1674def1dab16d3ecf1297 module/agent-harness-runtime
cdf661f6e5b9118ae3b33f0c5e4aec1351ad89b0b61e8b3e02abb7409b4e16da module/agent-media-payload
12b5557fde05afbfeac00f5024662eeed7f3f9356edc5b200c74da7c61de8650 module/agent-runtime
9fa72f7d99675604759681e04b0228989b6c6305566eb3e3b2469eecd35324ac module/agent-scope-runtime
d7e99bb6931d7f3a08099c41c80b76ae90bd94a25d7aa337a4648e8149ce4386 module/agent-runtime
904a765c68c458d67c1d5b7a30ccd9b28e56615f818142dd37a5023c05cfe22a module/agent-scope-runtime
8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from
eba56a699695ffd3bcbf897da0aa93b51294abf1b9542907648b1c30db8c5763 module/allowlist-config-edit
aaa2a8f8db2108108973a547eb952cd886339a6773551e4e8fab91bd4ab1aa6c module/approval-auth-runtime
93dd8e4ff9f17ff60909d6fd313b9f79369dc18f1540c9e25eaf1a3ac6b64419 module/approval-client-runtime
7d1810c8342c4b281371cc2333f4f000ee9e284336938de96ba3765ebbc0c3f7 module/approval-delivery-runtime
0b11bcea0a4b248126d478b50111f922f888750f90a47eb7648d8f19cc36b0a3 module/approval-gateway-runtime
df0e6aca83ecfb53d3b0154b3414e72448be5c46c6b0fb6162f6dc14286c010a module/allowlist-config-edit
525ff6a6681c0c4c3ecddd5f49ce5f9127b3dd4991e0b10f802a208d658ff534 module/approval-auth-runtime
f0287ea003b8af1980d40dcb60c31f2ce54660795bc79b2b36b24a472bebf550 module/approval-client-runtime
19586b5567dd7063f5060096b82bfa50bf8ca84d4ae5b2875f7b34e06596ff46 module/approval-delivery-runtime
dc7028580b3c3b8bf6812421e2082d226a10607268f04106b12eebc7850e7ffa module/approval-gateway-runtime
eee50f080f9447135fe72dba2102cf0faf9194d37e5fb07e4a0dc78ac719f77d module/approval-handler-adapter-runtime
2ee0442d19db08d563f61e7d71d5d45776d2a06c4d233317c5171e1a297dcef3 module/approval-handler-runtime
74e71e8128d253324f7b6b9f7057d3e62674ba924e0a53d1e1a507d1995b8e93 module/approval-native-runtime
aee9ee8745eba7a1ff464920ecb1807c0b4cab603d0506b42a3647c301cc132d module/approval-reply-runtime
b331f4287768acb1a89ba7431355eb557f11e8a1c849984cbd6a53e402989e22 module/approval-runtime
1536bafb393712324b4aaa68ccd150ac89aab8c2a8f437636c3418e5b2b6d79f module/approval-handler-runtime
53d841e27fc25ed2d7dc599bab24273ae99c7cb532db1811362f7f9feffd84cc module/approval-native-runtime
a15ac005239135ce90fabffd8cc9c2c939d8948f99d7ca6940154f9566564ab4 module/approval-reply-runtime
42ebdb257a9f4df22b3fd2d3201bc7e4124b7d89a47a06ef5f0ab172285ccc85 module/approval-runtime
01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive
d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param
0d9b23b23425e07595d7457b3b158197244b7ba8615d9fbc358bad13dddb417f module/channel-actions
07defbe9505e2fdf12066d8b235708bc4a60cce017b732043c85def65788a6ee module/channel-config-helpers
10fc989f02a8d081fc34c9953623269a5a1290e2c9db2c85a49a7037a4113c08 module/channel-config-helpers
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
484894c32a2fa1f6ca75538d854569dafbfe48e30c081fc3231f813b3054686c module/channel-config-schema
6ebf3439b567a420b56f6037f900e9c275d479851dfe8919236427a91b14cfa2 module/channel-contract
965f3716cc3feba0cadbb63e91535e090f57d6ebc75e36eb8cd1d7bfb46eed54 module/channel-core
6b10236b41e8ad618ed2c055a9e4d847ff6baf22be0c9f653849604fbdcc7af2 module/channel-dm-policy
7660776f4a2d0db95a7144fccbdac9acec702a5900ea3a969417621371fbc1e0 module/channel-entry-contract
eb997d5af42ac5363472a6dc47cfd9fe9a4c89e9908fbfac7927e919260b2979 module/channel-contract
9a38aea0efbcf690b4b5796477ef96784827df804026b22ba913ce004412e4d0 module/channel-core
c699a67bbbd046bcb4777eb6d288868f2fcfe90ed9faef2abb028d858311b780 module/channel-dm-policy
312b48644d96413bdff93dba31f90b74dc389308a0bbdf34735be09b630defed module/channel-entry-contract
421352c351ccf8044f0ba3045c40ba048e6992917f7caf9c92faff2b2e7a4711 module/channel-feedback
7d637cf7d7036881afcc78aa13e976a77390826421354aed37a562449951cbd1 module/channel-inbound
9a55a636ba4e714775b15afd72d5dcf0f3857fb5ed8380a624913ddd515cc8b7 module/channel-inbound
378cbd56a4ff711bd748094a145be0f6a3a363f29f608c81f02a3fc3fb2f1edd module/channel-inbound-debounce
5d19511cd325d1d902ee5b4c848de0f15ded5fce22c5a88f057d159c1c88d336 module/channel-ingress-runtime
5b8ee0501ac6511e1632dd0b0e7040936869f567e795e744023b388817e903e7 module/channel-lifecycle
423e100d8237df2a1cf4208ef2169c60ed3b30a9a0188661c65ade5de94730c2 module/channel-lifecycle
0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging
aca360581c758eebb19e4bc6feaef33c3815750f63747a5c9747658073490602 module/channel-message
763f05d6de11e3721f438d9dec72f212f47d43b77e2d7eb121b49a45bd10c42b module/channel-outbound
647eae943f2cfee9864fb7f3d9eadd53ce268709cfc18dd8ba4a0c21e6efa9d3 module/channel-pairing
8900eb25e84fc06b4493501412b7bf0b705e7740c61c9464ca419bfa39b03aaa module/channel-plugin-common
a29f22b55aba14d405aa20efe1dd5a7ae273da7269df9ef6308c734a35b1327a module/channel-policy
586c10b30a014eec4954ebeddf3a54f7362bcc9ae34f15044156dc45e5f4e30e module/channel-reply-pipeline
137785f5f6f6ef1c4e763818cc2f34aca5d94ddb6eb062d03c0ce1b6d4777a5b module/channel-message
6fd0d9f7be5a14c2f81304ce9d1d137d45750ad07b47f4d973fbfbfac7c962bb module/channel-outbound
05ad5c943facb81d915ea4d591163482eff1f0b73995dfd34830d07178616b5b module/channel-pairing
5377d958426380546a2e967505df59e909ce3952cc797aad43fd674c51d8afa1 module/channel-plugin-common
2b6f57ab0bd9128551a42c4c4ecd7c3bd4087af9766b86df56ff21f0446c8eff module/channel-policy
5c9491d6fb202d8123638bcbdc7171ebce5ccb30a856efdd028d71696580cbcc module/channel-reply-pipeline
482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context
4b7d11e77e58bf284f7081df8c4b00e2e658ec19cf645f063db1e506cd7cdf3c module/channel-secret-basic-runtime
574046ce5310620dfa9d989a9bc17121ff2a7cf5d87a20bab7d6f8bf2e3578fb module/channel-secret-runtime
fff80c8bbba1f326cb1dfdd7c2f7197566bc7dd09e59719cd1c0d0cd4bce7f1b module/channel-send-result
5cdb34f390e21878296d6e8ff573f03137ce921721cb01029bfd3d9899784064 module/channel-setup
6464055d5579ad2182ca9fd9c1f603b2554f2d97d9525afb581e21fdd057aa22 module/channel-status
f8f5c78f42f917326012fe80d3dfc31500b1120a17258b6164a75564a608fdc9 module/channel-send-result
84ea037642408624d7c8db58a999d7be6ce733581bb820a8065f76f351f3500f module/channel-setup
aa15810e4698859e5e74a873f3bca85a9e537c83fbed6bb7879b7e606b6766d7 module/channel-status
b227a529438b8765fef0a1af03effeed1b8594152a0e8cfb222a14caa4020a56 module/channel-streaming
b2f920ff4a6b4190e6d6ea0a3effb001751e092f0e3ac0cf296721ff8c383d86 module/channel-streaming-config
fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
452fa6bff0199f4812f970fd8f0627c1e1ac8f9ffcd90215c61913a326734400 module/command-auth
4c28bb503793e4a50f47c3a00a5f98eff82d7231801a526e47cb572e033b4777 module/command-auth-native
450a6ce9aa82c78a5b76d1a53d44ae490597cf255eda76d458b9f6da8ee4d9b3 module/command-auth
6f9ac353bbc3cae31911f300d36e0b554f41f5fb1699a06c5d22cb7301fc2f67 module/command-auth-native
b99ff6081946a8cc08e8e63d852cb484462808c92add2fb23cb36633583cb66e module/command-detection
9f6332a1db7cd6f2fcb92d8ac2e4af32379d694bfbeeab736f304815214b364a module/command-primitives-runtime
e461ac9cb7520441e5867fe4ff7882341f3d1338b7a0c12934f2e1645ef37f91 module/command-status
d55bb5460aa3de0685f0f34940d9bd9ad5970f6d7554ea0dd2089dd4a8dd1e62 module/config-contracts
ec10d7ce3379d2d2d45dc3e6897b56d092adca57c74d4a27f3855f82437c6e81 module/config-contracts
75a80626b1583434ebeffd6d48c066aaaffdb2c6e215abe98f106c6b927415f4 module/config-mutation
c9819e1920b8cc1ea94ab60e7642aae1f1ba0e36f485bc7b2bbabdd607bd2bf4 module/config-runtime
a7c4aa19a193bb2ec71e8a36b74f223a9fa6e035c2b97eb81ef2f13e35b20347 module/conversation-runtime
39984c45f2bd4526aee7dd0d513bd54e2999e9329c29cafd142cca028db3ef8f module/core
1d323dff55dc2f9e2fa2fed4f3843c356fe7ed2f33cdd4fe2064f9f69769fc00 module/dedupe-runtime
fe1fb6ca8307528dda4fd6c48f180a79840d98ef18be6e633544f351ac0ab120 module/config-runtime
d2ac206c6ed4f1492d4136c704fd55a4aa68de11bc36f610faaf16714bfa4862 module/conversation-runtime
81e8859ae52f71f2f696a39cdc05ad7564b98fa628c37742949ed52964ecbc35 module/core
72e018a028188038c6cd2d835e674ddc10a164be3640c4042a0312c53e592485 module/dedupe-runtime
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
b8d4ff8d1a3f9d28962ad3ebe4215f713fba47a28fbce5f083aae72a9316e6c0 module/diagnostic-runtime
769f3de78b553a4dcd6a94fe7fd58cdd4f8ecdd6ee6f21c4f138e5421f949ad6 module/directory-runtime
f2989a9ed18babed3a248f1ed43c8f0ed30c2dc04f446ddef9c9211d86f81b26 module/discord
4dc492621bb2ebde58fc4cf9d9d1f2a3ff95411bb88230fc179513725ddb1959 module/directory-runtime
414109dd3f9bdb6f2da4b1268cce76266f9aa1fd27bd88c6aa876554e2b9fe43 module/discord
64adc7f42bebf579531d8e18615b4f2384dcd9f35265f88c5129c10277b12eee module/error-runtime
05ff25c56097b12fd9956115eb4bf9a5171574a2137fd04a4e34f195e3813e5f module/extension-shared
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
7d5e8139694e5072ba098a974f04d2baf20168dca41902078a4376cea84af340 module/gateway-runtime
e28aacaa954810a8c006248e6c70375171c1cb874a3ca073e722eb99db8ac5d9 module/gateway-runtime
1b1c6bd5bfc0cfb0c5bb9bd97f8ac1928750cb87232a3415dd066cc21d7b48b7 module/group-access
7473b4186cff61f392bcd6f2ca34b90c7e4423a3423684b86fe09fc50af2d790 module/health
8e397381a96f97b12dd34d66daa43e630a2373fab820e8c88331c3bfd48c564b module/hook-runtime
16ca326bf06e43319841dc1c00e952601a5edfac77e337dead2d547c32f459d3 module/health
eba928a25e2e53c039c968265a20d711037810d1f62f9960e7599f55a77315f2 module/hook-runtime
f5e190bbfe0c21e76b7281a73cf5e9db806a1ed6e724fac2fb83ade5ccb827a0 module/inbound-envelope
4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery
ad34f303355f2ab08c5ebd637b475d99a2573c57046695d2268a58820ff5f482 module/inbound-reply-dispatch
a8f4b2c417b3d14b3a1db5c6dbadffbcbee055b9617a15618bbb02349e8086f5 module/infra-runtime
a7601573292ae23f895240d4dfe63eb1dd5d1abf61ea76c9d0581a3f71ad8f10 module/inbound-reply-dispatch
02a6281f1cef538918987da2c2f13ee4eee52c70e5d01f6035c38b60b67400bb module/infra-runtime
ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once
97742f0953ffc4270763b0d62f24875a0c3ed2ca6d90e96a18c52adc15a20cd0 module/interactive-runtime
c2a7b1b42422ec85ec6c8655adcf987dbb410574ca8539ea6d259420a47fd296 module/interactive-runtime
408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store
e907fd3a98185f2c261f2aafcaa5a19ee1d7b459d519a498397d629f84c68312 module/lazy-runtime
9874591cf115a4ad2c9a6cecd7fd6d2bb0a5d0a4c435d35b85a1ad3efdb85979 module/logging-core
f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix
ae17382b302c022012418146971c7bf69ed94316c51b62dbbf516e56ab4b327e module/media-local-roots
f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime
f8913abdbc52f393eb85fc575025ef77444ae0b5dc8ad3421b478888831062ee module/media-runtime
47a9fa2d97a666a6ed149ed810bbb5561d885503868f28c7fd7e274551c6a43f module/media-runtime
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
3a4e5c9a84a98b012fbe30f298dbe6168a0d0cdcf57b412f809cd413bc826e39 module/media-understanding
7c05291f026d0fb27a0359a8f86e5c23902ff7e54fecfc1c4cce820e48f95288 module/media-understanding-runtime
e2a03902d2553cb400e7346b228485e445c9e28a3781f04230a7cc68697ff70c module/meeting-runtime
d1b7c9a8121e395df2152b46f6cf7c2762065ea48e9b11fe6588d40d9bf370ba module/media-understanding-runtime
889ead61a31f459bfa2945dcc23900f20381b52a9802dd83d39ad012a644f5d7 module/meeting-runtime
f457e2035a9ccefcb6010a0a14b415f92772dbead4dacedb796ecb5d564892c9 module/memory-core-host-engine-foundation
b6fbb58ed1f9aea0d06b1f784df0c7b9c1dfaed884b8287a71a316a9f751058f module/memory-host-core
e826728ee339ff50ae54fac274dc27b25398dfa73f16266b77f98a39073f958e module/memory-host-core
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
a534dca8e9fa29467f3b6d7a8a8f85617c68c081fcbae7fb2418c042e02677e4 module/model-session-runtime
297b0a7e58f3d3ced06fdad37133d9c52728c95e868d67268608448736203d32 module/models-provider-runtime
662b73f9e9de8b083d8d8a6953ecdcc1dc29a97e8b6e8e5edbc64e3fdcf21a89 module/native-command-config-runtime
01fbccef009c2a41162f5327f7ab087d1345b1288e00e342fe7042eb20a24e56 module/model-session-runtime
3fc7a8d2d7dd9ac72172c82b77937b3686ce7fcc748fe397399e7e54a1107efa module/models-provider-runtime
6e577f2b80c1923eea10467a57cf580bec7c323d365f59a5df3e118b7858cec4 module/native-command-config-runtime
c241f194708a75e6f539b58b7837f1f5eb68b9f205ba9c74d2c5e149f1d742e0 module/native-command-registry
35bc6e2da664788158dbbab1f733975409c4279694bf9b1b81a36a00796cdf2d module/param-readers
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
e0a68ab64db24432eebb162b4aa376d359aece930f6de45ccd0af4e523496a66 module/plugin-config-runtime
3aaea8abb68776110b234b26d035214a45034becb466cdcbd3e3f247ea5e220f module/plugin-entry
fddd23b29272e17486033f0c7e386d9c686104543a08d6cc22d0883af56bbf6a module/plugin-runtime
7eef18139d5d8814b259250d512bdf1cdd3c97f1e22718286bd362a9677ff758 module/provider-auth
e0e0a6bd27b239056ccd90444adfc83927219169390fd5fd890f5ae1dbcea1a7 module/provider-catalog-runtime
6029e593c85031ce6c7388f243d38edfa5e557de639f9808678eb26523e88b81 module/plugin-entry
aeb0d99af2c470a793baee65c99373556705a15fc41ec2edab0f79581b827806 module/plugin-runtime
3340c8a464cc9b0695aa164a5a6ffa468c199bfdcd3d2b9d460e70ffe4115501 module/provider-auth
4c92396b10b9a0f198c652a230f1fb2069302d6f024e73d9850dc93b2b7b71c8 module/provider-catalog-runtime
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
da880c9378f0f1a7d38e0743a5ff11788fc9fa6fe6be6bb765045ba067d7cde8 module/question-gateway-runtime
0618ab2d2265728db989716743dcf0b2bb9731f42b22af12feb256ad11db786c module/reply-chunking
ba046ea97af8606e7d731b670a54f70109e865661378ae1b59cef638ff20f05d module/reply-dispatch-runtime
eed49c747302e717039cb840e5ccb01fb766253ebfc80ba1e696c9aa8b5f5319 module/question-gateway-runtime
3dff46dd2c25f3101d993179358d3e95797cb3c5f9c8359bec8af01c19187607 module/reply-chunking
e693542047cb5fc52ae9bcba49f475aec6b8fa28ffde3048741e22b4b20e9180 module/reply-dispatch-runtime
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
f56300977279980a614329933bba3df90fc3eb552bb7675cfd0bbfa3f02ef7b4 module/reply-payload
01ca6d4d7e9f376d28626b7a70b7794a6de5de3bee16c83adc7d834f0693d7a8 module/reply-runtime
d3c3aa7f8d77ba6adc07f080628119af0e6b0b27f3a06f1ddd3bc498a5decf81 module/reply-payload
0d183e6869e075ce2c13d525ed1c196de916df1f6d022d544fef550dc97fe750 module/reply-runtime
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
eee39bd28309cd706671cd86d598706286f99f5987f31615b77af8d81d696fff module/routing
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
@@ -118,34 +118,34 @@ eee39bd28309cd706671cd86d598706286f99f5987f31615b77af8d81d696fff module/routing
3269124490363a8063eb6230f703609ec9c4d4f02c137a035b19290aff831592 module/runtime-config-snapshot
9e8651266b12dca1f0231a8d0956cc9142b43ff2b6c300f180a3f7c859bc96e7 module/runtime-env
49e9b6a8195c89704eaa80656f176444af7cacbf639b759f41f2c78ae6bfcfd9 module/runtime-group-policy
3ee1e17bb68396a8557bd30cea7293ee4979c9b9c86333115877e2b21ff179fa module/runtime-store
83ea367056ccebb3f2ea1fcd1879e4d0a86682aa8dfbfb19975dbc9ab992010a module/runtime-store
d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file
8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input
333ee3f8889687fc284902a7ffb2fe9dbb41a9f4e3f7fe1f6b0d8bc330de6225 module/secret-input-runtime
91737225f39e684805fe8bede33933cbb5cefe0c0dbeb0346f69b4a9e29be929 module/secret-ref-runtime
83778b8f94cd4d8a5b5f7db46b1e265adc5b4d03e02289685ca07d6bef9302cb module/security-runtime
03629a7bde3bb0f7cb0861429ffb4a5948e502dc3aa373c4539f95b70c70f893 module/session-catalog
c4905e0f528512ad687ff0f3971f8185e7cc84f934c3d9a3e2920a978cd1df55 module/session-discussion
208e3cffe0ee35b7b3ac70dc58298d7d4e5c3ecdde0caab831249d59f8c23372 module/session-store-runtime
bdac01dcf56887b055d4f8d5a8265b22ed09ce9034a598ce6228844b5ede90ba module/setup
536e82f1ff0bf8249df31b22128c6b995df4b96605cc011e935cf64fbd7ace55 module/setup-runtime
fdc9f9ac16fd4f781d76dd620df2bc580c8892c767f1fcecbcea756ea5e02b5c module/security-runtime
35e8b82391dde381b1872d9be78e7d40f1175cde088fd9e31bcf7dbadde701de module/session-catalog
97a094346b2f4ec802d0641f87fb7f4f5bbb98d5513775d107d63e0b9b9364f3 module/session-discussion
4a906445ca358e6c54d641d7fe4e7d2a8f2f78af8bed7d920174192d5f83ca61 module/session-store-runtime
337f63bf878dad8affd71b8e4e66252653ca4fdbadabd398ce527718deecd5c0 module/setup
ad996e771d8ccf51e1a8944b54d4c0b3695da35617a4c64981bd5e3fdb865f96 module/setup-runtime
d0cb4c5abb7484352088f556c1ba7c7b147d7b57977b8246e5cb7187937768b7 module/setup-tools
1a46fed85c7276ad4ebb94dabde0a136e9f2cde30eef7c9a1dd693c9810b70a6 module/skill-commands-runtime
e8db3d935a46133296dfe7f39fd11deb3cdadd83d4cb220d65966017950f439a module/speech-settings
25a7806738a87c093d149ebdef506931789e08000c92d5da3ed12ed0256a8294 module/ssrf-policy
17bf8e8c96a055e7600e9b145c31db1e9d9424b22d08cfe8fe6a2beb70421b95 module/ssrf-runtime
07ddadba30645d0a3bb8f449cc68eb3c98cde57b3d0a97b646477536f0790509 module/skill-commands-runtime
4bb230f9beb1b3948e1cbdf3c25d8a0c607af81d1566d347d1c02d20af088341 module/speech-settings
05f645dc200055946e7386c2fd022bf9b6c9062e212b086aa22872ab2e8f2429 module/ssrf-policy
ef358b38d1441e23557003c18d33fef9b2c390edab2201f1ad428f1fe80b782f module/ssrf-runtime
cf7f004d754d995bd4989b09a8a539635588c9e679deba65ecc66d9c25e513b2 module/state-paths
5168efd1af74791d92fda7120bfa79ed28a38c117db0fde69837b551c1437edd module/status-helpers
58cf4ad2dbf36ac3dfc00ba9dfcf94f7e296a9cc28f1b880a466b99478529663 module/status-helpers
f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime
ed66a1aa1464728d8f96977013bda42bffbe048d42f364cb45f5bf7a5e1bea46 module/telegram-account
718c4ceabc0608514bbd90d12fd20e35a3ae536808193cd79a1d04bfcf862d83 module/telegram-account
aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path
87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking
0e3168845e5021738db84e85768500e54aacee269026da73a6b70f350a29362a module/text-runtime
90530f4b8c562d80376cea1f7022025814e0caebdc1c9b3a158e6e82721ec095 module/tool-plugin
e696b8fddc53b01c773eeaadaf5c9501555279b329ea334d18783200beb0ce4f module/tool-plugin
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
60cdfe9308ac2b41be32162cc19f9749f4479bed825f95dfda706dfbfee1eed0 module/tool-send
603abbf9716886fbcdf5cbb3ccac71c59be959695e3ca4822d3e482cf3851381 module/tool-send
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
60274a16c028f96d9da2bbbf42aed4911e091dae7b7dd1ed1ca43f490b64e3b5 module/webhook-ingress
83ed2035fa87753020e4977e088f8c09ecdcca03c9cf730484dea8f23f8c3619 module/webhook-ingress
e7c422d17088a42544f2c9e5b78b3a2a460c2a16c78a34f2da3b089cd6e9f680 module/webhook-request-guards
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod
+2 -5
View File
@@ -529,7 +529,6 @@ methods. Treat this as feature discovery, not a full enumeration of
- `usage.cost` returns aggregated cost usage summaries for a date range. Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only for an explicit live embedding provider ping. Pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to one agent workspace; omitting it aggregates configured Dreaming workspaces.
- `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }`; omitted, they operate on the configured default agent workspace.
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients, including workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates. Requires `operator.read`.
- `sessions.usage` returns per-session usage summaries. Pass `agentId` for one agent, or `agentScope: "all"` to list configured agents together.
Both usage methods accept `mode: "specific"` with an IANA `timeZone` for DST-aware calendar-day boundaries and buckets. `utcOffset` remains supported for older clients and as a fallback when the Gateway runtime does not recognize the requested zone.
- `sessions.usage.timeseries` returns timeseries usage for one session.
@@ -569,7 +568,7 @@ methods. Treat this as feature discovery, not a full enumeration of
- `terminal.upload` accepts one base64 file up to 16 MiB, stages it in a private 24-hour temporary directory on the session's Gateway or paired-node host, and returns the absolute path. The caller must still paste or otherwise use that path; the RPC never writes terminal input or executes a command.
- `terminal.data` and `terminal.exit` events stream to the connection owner and attached viewers. Task-owned agent terminals close when their authoritative task reaches a terminal state; ordinary conversation-owned agent terminals remain persistent.
- Sessions whose connection drops are detached, not killed: they stay reattachable for `gateway.terminal.detachedSessionTimeoutSeconds` (default 300; `0` restores kill-on-disconnect) while recent output accumulates in a bounded server-side buffer.
- `terminal.list` returns attachable sessions; `terminal.attach` rebinds a live-or-detached session to the calling connection and returns the replay buffer (tmux-style take-over — a previous live owner receives `terminal.exit` with reason `detached`); `terminal.text` reads the buffer as plain text without attaching.
- `terminal.list` returns attachable sessions; `terminal.attach` rebinds a live-or-detached session to the calling connection and returns the replay buffer (tmux-style take-over — a previous live owner receives `terminal.exit` with reason `detached`).
- Every terminal method requires `operator.admin`; `gateway.terminal.enabled` is on by default and refuses every method when set to `false`. Fully sandboxed agents are refused, and an agent policy change closes existing and in-flight PTYs, detached ones included.
</Accordion>
@@ -578,9 +577,7 @@ methods. Treat this as feature discovery, not a full enumeration of
- `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways.
- `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`).
- `talk.session.create` (`operator.talk`) creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, non-admin callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
- `talk.session.join` validates a managed-room session token, emits `session.ready` or `session.replaced` as needed, and returns room/session metadata plus recent Talk events, never the plaintext token or its hash.
- `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions.
- `talk.session.startTurn`, `talk.session.endTurn`, and `talk.session.cancelTurn` drive managed-room turn lifecycle with stale-turn rejection before state clears.
- `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions.
- `talk.session.submitToolResult` completes a provider tool call emitted by a gateway-owned realtime relay session. The request waits for any asynchronous completion signal exposed by the provider bridge; failed submissions keep the linked run active and do not emit a successful tool-result event. Pass `options: { willContinue: true }` for interim tool output or `options: { suppressResponse: true }` when the provider bridge advertises suppression support and the result should not start another response.
- `talk.session.steer` sends active-run voice control into a gateway-owned agent-backed Talk session: `{ sessionId, text, mode? }`, where `mode` is `status`, `steer`, `cancel`, or `followup`; omitted mode is classified from the spoken text.
@@ -635,7 +632,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Session control">
- `sessions.list` returns the current session index, including per-row `agentRuntime` metadata when an agent runtime backend is configured. When cloud-worker placement is enabled or durable recovery state exists, session rows also include a closed `placement` state (`local`, `requested`, `provisioning`, `syncing`, `starting`, `active`, `draining`, `reconciling`, `reclaimed`, or `failed`) plus state-specific environment, owner-epoch, workspace, bundle, ACK-cursor, or recovery fields.
- `sessions.subscribe` and `sessions.unsubscribe` toggle session change event subscriptions for the current WS client.
- `sessions.subscribe` enables session change events for the current WebSocket client. The subscription ends when that client disconnects.
- `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session. Pass `includeApprovals: true` to also receive sanitized `session.approval` lifecycle events for approvals whose persisted audience includes that exact session and whose reviewer binding authorizes the subscribing client. The subscribe response then includes a bounded pending `approvalReplay`; it is authoritative when `truncated` is false. The opt-in is per subscribe call, not sticky: re-subscribing to the same session without `includeApprovals: true` removes an existing approval subscription. In addition to normal session-read authority, this opt-in requires `operator.admin`, or `operator.approvals` on a paired device.
- `sessions.preview` returns bounded transcript previews for specific session keys.
- `sessions.describe` returns one gateway session row for an exact session key.
+1 -1
View File
@@ -12,7 +12,7 @@ Talk mode covers five runtime shapes:
- **iOS Talk (realtime)**: client-owned WebRTC for OpenAI realtime configs that select `webrtc` transport or omit transport, including framed and frameless transcript/audio events. Explicit `gateway-relay`, `provider-websocket`, and non-OpenAI realtime configs stay on the Gateway-owned relay; non-realtime configs use the native speech loop.
- **Browser Talk**: `talk.client.create` for client-owned `webrtc`/`provider-websocket` sessions, or `talk.session.create` for Gateway-owned `gateway-relay` sessions. `managed-room` is reserved for Gateway handoff and walkie-talkie rooms.
- **Android Talk (realtime)**: Android uses Gateway-owned relay realtime when `talk.catalog` reports the realtime group ready and the configured model passes the Android client gate; it never opens a client-owned WebRTC session. The Gateway now supports `gpt-live-*` relay sessions, but Android intentionally keeps those models on native speech recognition, Gateway chat, and `talk.speak` until the relay path is proven live from an Android device.
- **Transcription-only clients**: `talk.session.create({ mode: "transcription", transport: "gateway-relay", brain: "none" })`, then `talk.session.appendAudio`, `talk.session.cancelTurn`, and `talk.session.close` for captions/dictation without an assistant voice response. One-shot uploaded voice notes still use the [media understanding](/nodes/media-understanding) audio path.
- **Transcription-only clients**: `talk.session.create({ mode: "transcription", transport: "gateway-relay", brain: "none" })`, then `talk.session.appendAudio` and `talk.session.close` for captions/dictation without an assistant voice response. One-shot uploaded voice notes still use the [media understanding](/nodes/media-understanding) audio path.
Native Talk is a continuous loop: listen for speech, send the transcript to the model through the active session, wait for the response, then speak it via the configured Talk provider (`talk.speak`).
+3 -4
View File
@@ -29,10 +29,9 @@ Wake words and routing rules live in the Gateway state database, `~/.openclaw/st
### Routing (trigger to target)
| Method | Params | Result |
| ----------------------- | ------------------------------------ | ------------------------------------ |
| `voicewake.routing.get` | none | `{ config: VoiceWakeRoutingConfig }` |
| `voicewake.routing.set` | `{ config: VoiceWakeRoutingConfig }` | `{ config: VoiceWakeRoutingConfig }` |
| Method | Params | Result |
| ----------------------- | ------ | ------------------------------------ |
| `voicewake.routing.get` | none | `{ config: VoiceWakeRoutingConfig }` |
```json
{
+14 -18
View File
@@ -1012,30 +1012,26 @@ The supported `talk.session.create` combinations are intentionally small:
Method map for readers migrating from the older `talk.realtime.*` /
`talk.transcription.*` / `talk.handoff.*` families (all removed):
| Old | New |
| -------------------------------- | -------------------------------------------------------- |
| `talk.realtime.session` | `talk.client.create` |
| `talk.realtime.toolCall` | `talk.client.toolCall` |
| `talk.realtime.relayAudio` | `talk.session.appendAudio` |
| `talk.realtime.relayCancel` | `talk.session.cancelOutput` or `talk.session.cancelTurn` |
| `talk.realtime.relayToolResult` | `talk.session.submitToolResult` |
| `talk.realtime.relayStop` | `talk.session.close` |
| `talk.transcription.session` | `talk.session.create({ mode: "transcription" })` |
| `talk.transcription.relayAudio` | `talk.session.appendAudio` |
| `talk.transcription.relayCancel` | `talk.session.cancelTurn` |
| `talk.transcription.relayStop` | `talk.session.close` |
| `talk.handoff.create` | `talk.session.create({ transport: "managed-room" })` |
| `talk.handoff.join` | `talk.session.join` |
| `talk.handoff.revoke` | `talk.session.close` |
| Old | New |
| -------------------------------- | ---------------------------------------------------- |
| `talk.realtime.session` | `talk.client.create` |
| `talk.realtime.toolCall` | `talk.client.toolCall` |
| `talk.realtime.relayAudio` | `talk.session.appendAudio` |
| `talk.realtime.relayCancel` | `talk.session.cancelOutput` |
| `talk.realtime.relayToolResult` | `talk.session.submitToolResult` |
| `talk.realtime.relayStop` | `talk.session.close` |
| `talk.transcription.session` | `talk.session.create({ mode: "transcription" })` |
| `talk.transcription.relayAudio` | `talk.session.appendAudio` |
| `talk.transcription.relayCancel` | `talk.session.close` |
| `talk.transcription.relayStop` | `talk.session.close` |
| `talk.handoff.create` | `talk.session.create({ transport: "managed-room" })` |
| `talk.handoff.revoke` | `talk.session.close` |
The unified control vocabulary is also deliberately narrow:
| Method | Applies to | Contract |
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `talk.session.appendAudio` | `realtime/gateway-relay`, `transcription/gateway-relay` | Append a base64 PCM audio chunk to the provider session owned by the same Gateway connection. |
| `talk.session.startTurn` | `stt-tts/managed-room` | Start a managed-room user turn. |
| `talk.session.endTurn` | `stt-tts/managed-room` | End the active turn after stale-turn validation. |
| `talk.session.cancelTurn` | all Gateway-owned sessions | Cancel active capture/provider/agent/TTS work for a turn. |
| `talk.session.cancelOutput` | `realtime/gateway-relay` | Stop assistant audio output without necessarily ending the user turn. |
| `talk.session.submitToolResult` | `realtime/gateway-relay` | Complete a provider tool call after any asynchronous completion exposed by its bridge; pass `options.willContinue` for interim output or, when supported, `options.suppressResponse` to avoid another assistant response. |
| `talk.session.steer` | agent-backed Talk sessions | Send spoken `status`, `steer`, `cancel`, or `followup` control to the active embedded run resolved from the Talk session. |
+1 -1
View File
@@ -411,7 +411,7 @@ Eligibility is per session and per host. Gateway-local sessions start the provid
Connection-owned sessions survive disconnects: a page reload, laptop sleep, or network blip detaches the session on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over.
Agent-owned sessions are not bound to a browser connection. `terminal.attach` adds each browser as a viewer without taking ownership, and closing a viewer tab detaches only that browser. Conversation-owned PTYs remain until the agent closes them, their process exits, policy disables them, or the Gateway shuts down. PTYs opened by a detached task close automatically when that task succeeds, fails, times out, is cancelled, or is lost. `terminal.list` marks each entry as connection- or agent-owned, and `terminal.text` lets an admin connection read recent plain-text output without attaching.
Agent-owned sessions are not bound to a browser connection. `terminal.attach` adds each browser as a viewer without taking ownership, and closing a viewer tab detaches only that browser. Conversation-owned PTYs remain until the agent closes them, their process exits, policy disables them, or the Gateway shuts down. PTYs opened by a detached task close automatically when that task succeeds, fails, times out, is cancelled, or is lost. `terminal.list` marks each entry as connection- or agent-owned.
The terminal is also available as a [full-screen terminal document](/web/urls#special-documents-and-startup-modes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal.
+1 -13
View File
@@ -36,12 +36,9 @@ import {
validateTalkClientToolCallParams,
validateTalkSessionAppendAudioParams,
validateTalkSessionCancelOutputParams,
validateTalkSessionCancelTurnParams,
validateTalkSessionCreateParams,
validateTalkSessionJoinParams,
validateTalkSessionSubmitToolResultParams,
validateTalkSessionSteerParams,
validateTalkSessionTurnParams,
validateWakeParams,
type ValidationError,
} from "./index.js";
@@ -796,14 +793,6 @@ describe("validateTalkSession", () => {
);
expectRejected(validateTalkSessionCreateParams, [{ mode: "realtime", language: "de-DE" }]);
});
it("accepts managed-room join and turn lifecycle params", () => {
expectAccepted(validateTalkSessionJoinParams, [talkSession({ token: "token-1" })]);
expectAccepted(validateTalkSessionTurnParams, [talkSession({ turnId: "turn-1" })]);
expectAccepted(validateTalkSessionCancelTurnParams, [
talkSession({ turnId: "turn-1", reason: "barge-in" }),
]);
});
});
describe("validateTalkClientToolCallParams", () => {
@@ -836,11 +825,10 @@ describe("validateTalkAgentControlParams", () => {
});
describe("validateTalkSessionRelayParams", () => {
it("accepts session audio, cancel, output cancel, and tool result params", () => {
it("accepts session audio, output cancel, and tool result params", () => {
expectAccepted(validateTalkSessionAppendAudioParams, [
talkSession({ audioBase64: "aGVsbG8=", timestamp: 123 }),
]);
expectAccepted(validateTalkSessionCancelTurnParams, [talkSession({ reason: "barge-in" })]);
expectAccepted(validateTalkSessionCancelOutputParams, [talkSession({ reason: "barge-in" })]);
expectAccepted(validateTalkSessionSubmitToolResultParams, [
talkSession({
-8
View File
@@ -218,7 +218,6 @@ export {
SessionsDiffParamsSchema,
SessionsDiffResultSchema,
SessionsCompactionListParamsSchema,
SessionsCompactionGetParamsSchema,
SessionsCompactionBranchParamsSchema,
SessionsCompactionRestoreParamsSchema,
SessionBranchSchema,
@@ -404,13 +403,8 @@ export {
TalkSessionAppendAudioParamsSchema,
TalkSessionAcknowledgeMarkParamsSchema,
TalkSessionCancelOutputParamsSchema,
TalkSessionCancelTurnParamsSchema,
TalkSessionCreateParamsSchema,
TalkSessionCreateResultSchema,
TalkSessionJoinParamsSchema,
TalkSessionJoinResultSchema,
TalkSessionTurnParamsSchema,
TalkSessionTurnResultSchema,
TalkSessionSteerParamsSchema,
TalkSessionSubmitToolResultParamsSchema,
TalkSessionCloseParamsSchema,
@@ -549,8 +543,6 @@ export {
TerminalAttachResultSchema,
TerminalSessionInfoSchema,
TerminalListResultSchema,
TerminalTextParamsSchema,
TerminalTextResultSchema,
TerminalUploadParamsSchema,
TerminalUploadResultSchema,
TerminalAckResultSchema,
@@ -285,12 +285,6 @@ export const TalkAgentControlResultSchema = closedObject({
deliveredAtMs: Type.Optional(Type.Number()),
});
/** Joins an existing managed-room Talk session. */
export const TalkSessionJoinParamsSchema = closedObject({
sessionId: NonEmptyString,
token: NonEmptyString,
});
/** Creates a gateway-managed Talk session for realtime, transcription, or relay use. */
export const TalkSessionCreateParamsSchema = closedObject({
sessionKey: Type.Optional(Type.String()),
@@ -316,19 +310,6 @@ export const TalkSessionAppendAudioParamsSchema = closedObject({
timestamp: Type.Optional(Type.Number()),
});
/** Starts or advances a Talk turn within a session. */
export const TalkSessionTurnParamsSchema = closedObject({
sessionId: NonEmptyString,
turnId: Type.Optional(Type.String()),
});
/** Cancels the active or named Talk turn. */
export const TalkSessionCancelTurnParamsSchema = closedObject({
sessionId: NonEmptyString,
turnId: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
});
/** Cancels currently streaming Talk output without necessarily ending the turn. */
export const TalkSessionCancelOutputParamsSchema = closedObject({
sessionId: NonEmptyString,
@@ -362,33 +343,6 @@ export const TalkSessionCloseParamsSchema = closedObject({
sessionId: NonEmptyString,
});
/** Mutable room state returned when a client joins a managed Talk room. */
const TalkSessionManagedRoomStateSchema = closedObject({
activeClientId: Type.Optional(Type.String()),
activeTurnId: Type.Optional(Type.String()),
recentTalkEvents: Type.Array(TalkEventSchema),
});
/** Managed-room session record shared with browser clients. */
const TalkSessionManagedRoomRecordSchema = closedObject({
id: NonEmptyString,
roomId: NonEmptyString,
roomUrl: NonEmptyString,
sessionKey: NonEmptyString,
sessionId: Type.Optional(Type.String()),
channel: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
voice: Type.Optional(Type.String()),
mode: TalkModeSchema,
transport: TalkTransportSchema,
brain: TalkBrainSchema,
createdAt: Type.Number(),
expiresAt: Type.Number(),
room: TalkSessionManagedRoomStateSchema,
});
/** Empty request payload for reading configured Talk provider capabilities. */
export const TalkCatalogParamsSchema = closedObject({});
@@ -473,16 +427,6 @@ export const TalkSessionCreateResultSchema = closedObject({
expiresAt: Type.Optional(Type.Number()),
});
/** Result for a Talk turn request, optionally including emitted events. */
export const TalkSessionTurnResultSchema = closedObject({
ok: Type.Boolean(),
turnId: Type.Optional(Type.String()),
events: Type.Optional(Type.Array(TalkEventSchema)),
});
/** Managed-room record returned to clients after joining an existing Talk session. */
export const TalkSessionJoinResultSchema = TalkSessionManagedRoomRecordSchema;
/** Generic success result for Talk session lifecycle calls. */
export const TalkSessionOkResultSchema = closedObject({
ok: Type.Boolean(),
@@ -795,13 +739,8 @@ export type TalkClientCloseParams = Static<typeof TalkClientCloseParamsSchema>;
export type TalkClientMutationResult = Static<typeof TalkClientMutationResultSchema>;
export type TalkSessionCreateParams = Static<typeof TalkSessionCreateParamsSchema>;
export type TalkSessionCreateResult = Static<typeof TalkSessionCreateResultSchema>;
export type TalkSessionJoinParams = Static<typeof TalkSessionJoinParamsSchema>;
export type TalkSessionJoinResult = Static<typeof TalkSessionJoinResultSchema>;
export type TalkSessionAppendAudioParams = Static<typeof TalkSessionAppendAudioParamsSchema>;
export type TalkSessionTurnParams = Static<typeof TalkSessionTurnParamsSchema>;
export type TalkSessionCancelTurnParams = Static<typeof TalkSessionCancelTurnParamsSchema>;
export type TalkSessionCancelOutputParams = Static<typeof TalkSessionCancelOutputParamsSchema>;
export type TalkSessionTurnResult = Static<typeof TalkSessionTurnResultSchema>;
export type TalkSessionSteerParams = Static<typeof TalkSessionSteerParamsSchema>;
export type TalkSessionSubmitToolResultParams = Static<
typeof TalkSessionSubmitToolResultParamsSchema
@@ -21,13 +21,8 @@ export const ChannelProtocolSchemas = {
TalkSessionAppendAudioParams: channels.TalkSessionAppendAudioParamsSchema,
TalkSessionAcknowledgeMarkParams: talkMarks.TalkSessionAcknowledgeMarkParamsSchema,
TalkSessionCancelOutputParams: channels.TalkSessionCancelOutputParamsSchema,
TalkSessionCancelTurnParams: channels.TalkSessionCancelTurnParamsSchema,
TalkSessionCreateParams: channels.TalkSessionCreateParamsSchema,
TalkSessionCreateResult: channels.TalkSessionCreateResultSchema,
TalkSessionJoinParams: channels.TalkSessionJoinParamsSchema,
TalkSessionJoinResult: channels.TalkSessionJoinResultSchema,
TalkSessionTurnParams: channels.TalkSessionTurnParamsSchema,
TalkSessionTurnResult: channels.TalkSessionTurnResultSchema,
TalkSessionSteerParams: channels.TalkSessionSteerParamsSchema,
TalkSessionSubmitToolResultParams: channels.TalkSessionSubmitToolResultParamsSchema,
TalkSessionCloseParams: channels.TalkSessionCloseParamsSchema,
@@ -3,11 +3,9 @@ import * as sessions from "./sessions.js";
export const SessionLifecycleProtocolSchemas = {
SessionsCompactionListParams: sessions.SessionsCompactionListParamsSchema,
SessionsCompactionGetParams: sessions.SessionsCompactionGetParamsSchema,
SessionsCompactionBranchParams: sessions.SessionsCompactionBranchParamsSchema,
SessionsCompactionRestoreParams: sessions.SessionsCompactionRestoreParamsSchema,
SessionsCompactionListResult: sessions.SessionsCompactionListResultSchema,
SessionsCompactionGetResult: sessions.SessionsCompactionGetResultSchema,
SessionsCompactionBranchResult: sessions.SessionsCompactionBranchResultSchema,
SessionsCompactionRestoreResult: sessions.SessionsCompactionRestoreResultSchema,
SessionsRewindParams: sessions.SessionsRewindParamsSchema,
@@ -582,13 +582,6 @@ export const SessionsCompactionListParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
});
/** Reads one compaction checkpoint by id. */
export const SessionsCompactionGetParamsSchema = closedObject({
key: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
checkpointId: NonEmptyString,
});
/** Creates a new branch from a compaction checkpoint. */
export const SessionsCompactionBranchParamsSchema = closedObject({
key: NonEmptyString,
@@ -667,13 +660,6 @@ export const SessionsCompactionListResultSchema = closedObject({
checkpoints: Type.Array(SessionCompactionCheckpointSchema),
});
/** Get response for a single compaction checkpoint. */
export const SessionsCompactionGetResultSchema = closedObject({
ok: Type.Literal(true),
key: NonEmptyString,
checkpoint: SessionCompactionCheckpointSchema,
});
/** Branch response with the newly created session key and entry metadata. */
export const SessionsCompactionBranchResultSchema = closedObject({
ok: Type.Literal(true),
@@ -784,11 +770,9 @@ export type SessionsCompanionStateResult = Static<typeof SessionsCompanionStateR
export type SessionsCompanionResetParams = Static<typeof SessionsCompanionResetParamsSchema>;
export type SessionsCompanionResetResult = Static<typeof SessionsCompanionResetResultSchema>;
export type SessionsCompactionListParams = Static<typeof SessionsCompactionListParamsSchema>;
export type SessionsCompactionGetParams = Static<typeof SessionsCompactionGetParamsSchema>;
export type SessionsCompactionBranchParams = Static<typeof SessionsCompactionBranchParamsSchema>;
export type SessionsCompactionRestoreParams = Static<typeof SessionsCompactionRestoreParamsSchema>;
export type SessionsCompactionListResult = Static<typeof SessionsCompactionListResultSchema>;
export type SessionsCompactionGetResult = Static<typeof SessionsCompactionGetResultSchema>;
export type SessionsCompactionBranchResult = Static<typeof SessionsCompactionBranchResultSchema>;
export type SessionsCompactionRestoreResult = Static<typeof SessionsCompactionRestoreResultSchema>;
export type SessionsRewindParams = Static<typeof SessionsRewindParamsSchema>;
@@ -12,8 +12,6 @@ import {
TerminalOpenResultSchema,
TerminalResizeParamsSchema,
TerminalSessionInfoSchema,
TerminalTextParamsSchema,
TerminalTextResultSchema,
TerminalUploadParamsSchema,
TerminalUploadResultSchema,
} from "./terminal.js";
@@ -28,8 +26,6 @@ export const TerminalProtocolSchemas = {
TerminalAttachResult: TerminalAttachResultSchema,
TerminalSessionInfo: TerminalSessionInfoSchema,
TerminalListResult: TerminalListResultSchema,
TerminalTextParams: TerminalTextParamsSchema,
TerminalTextResult: TerminalTextResultSchema,
TerminalUploadParams: TerminalUploadParamsSchema,
TerminalUploadResult: TerminalUploadResultSchema,
TerminalAckResult: TerminalAckResultSchema,
@@ -126,14 +126,6 @@ export const TerminalListResultSchema = closedObject({
});
export type TerminalListResult = Static<typeof TerminalListResultSchema>;
/** Reads the current output buffer as plain text without attaching. */
export const TerminalTextParamsSchema = closedObject({ sessionId: NonEmptyString });
export type TerminalTextParams = Static<typeof TerminalTextParamsSchema>;
/** Plain-text buffer contents (ANSI stripped); an agent/LLM affordance. */
export const TerminalTextResultSchema = closedObject({ text: Type.String() });
export type TerminalTextResult = Static<typeof TerminalTextResultSchema>;
/** Shared ok/void result for input, resize, and close. */
export const TerminalAckResultSchema = closedObject({ ok: Type.Boolean() });
export type TerminalAckResult = Static<typeof TerminalAckResultSchema>;
@@ -5,7 +5,6 @@ import {
TerminalInputParamsSchema,
TerminalOpenParamsSchema,
TerminalResizeParamsSchema,
TerminalTextParamsSchema,
TerminalUploadParamsSchema,
TerminalUploadResultSchema,
} from "./schema/terminal.js";
@@ -15,6 +14,5 @@ export const validateTerminalInputParams = lazyCompile(TerminalInputParamsSchema
export const validateTerminalResizeParams = lazyCompile(TerminalResizeParamsSchema);
export const validateTerminalCloseParams = lazyCompile(TerminalCloseParamsSchema);
export const validateTerminalAttachParams = lazyCompile(TerminalAttachParamsSchema);
export const validateTerminalTextParams = lazyCompile(TerminalTextParamsSchema);
export const validateTerminalUploadParams = lazyCompile(TerminalUploadParamsSchema);
export const validateTerminalUploadResult = lazyCompile(TerminalUploadResultSchema);
@@ -243,7 +243,6 @@ export const validateSessionsGroupsDeleteParams = compile(S.SessionsGroupsDelete
export const validateSessionsGroupsMutationResult = compile(S.SessionsGroupsMutationResultSchema);
export const validateSessionsCompactParams = compile(S.SessionsCompactParamsSchema);
export const validateSessionsCompactionListParams = compile(S.SessionsCompactionListParamsSchema);
export const validateSessionsCompactionGetParams = compile(S.SessionsCompactionGetParamsSchema);
export const validateSessionsCompactionBranchParams = compile(
S.SessionsCompactionBranchParamsSchema,
);
@@ -302,13 +301,10 @@ export const validateTalkClientToolCallResult = compile(S.TalkClientToolCallResu
export const validateTalkClientTranscriptParams = compile(S.TalkClientTranscriptParamsSchema);
export const validateTalkClientSteerParams = compile(S.TalkClientSteerParamsSchema);
export const validateTalkSessionCreateParams = compile(S.TalkSessionCreateParamsSchema);
export const validateTalkSessionJoinParams = compile(S.TalkSessionJoinParamsSchema);
export const validateTalkSessionAppendAudioParams = compile(S.TalkSessionAppendAudioParamsSchema);
export const validateTalkSessionAcknowledgeMarkParams = compile(
S.TalkSessionAcknowledgeMarkParamsSchema,
);
export const validateTalkSessionTurnParams = compile(S.TalkSessionTurnParamsSchema);
export const validateTalkSessionCancelTurnParams = compile(S.TalkSessionCancelTurnParamsSchema);
export const validateTalkSessionCancelOutputParams = compile(S.TalkSessionCancelOutputParamsSchema);
export const validateTalkSessionSteerParams = compile(S.TalkSessionSteerParamsSchema);
export const validateTalkSessionSubmitToolResultParams = compile(
@@ -33,7 +33,6 @@ import {
buildHashedArgPatternFromArgv,
resolvePolicyTargetCandidatePath,
} from "../infra/exec-command-resolution.js";
import { createSafeGatewayRestartPreflight } from "../infra/restart-coordinator.js";
import {
getActiveGatewayRootWorkCount,
markGatewayRestartDraining,
@@ -2388,21 +2387,7 @@ EOF`,
suspension?.release();
await spawnStarted;
expect(
createSafeGatewayRestartPreflight({
getQueueSize: () => 0,
getPendingReplies: () => 0,
getEmbeddedRuns: () => 0,
getCronRuns: () => 0,
getBackgroundExecSessions: () => 0,
getActiveTasks: () => 0,
getTaskBlockers: () => [],
}),
).toMatchObject({
safe: false,
counts: { rootRequests: 1, totalActive: 1 },
blockers: [{ kind: "root-request", count: 1 }],
});
expect(getActiveGatewayRootWorkCount()).toBe(1);
allowSpawn();
await vi.waitFor(() => {
expect(markBackgroundedMock).toHaveBeenCalledOnce();
-8
View File
@@ -100,11 +100,7 @@ describe("method scope resolution", () => {
["talk.client.toolCall", ["operator.talk"]],
["talk.client.steer", ["operator.talk"]],
["talk.session.create", ["operator.talk"]],
["talk.session.join", ["operator.talk"]],
["talk.session.appendAudio", ["operator.talk"]],
["talk.session.startTurn", ["operator.talk"]],
["talk.session.endTurn", ["operator.talk"]],
["talk.session.cancelTurn", ["operator.talk"]],
["talk.session.cancelOutput", ["operator.talk"]],
["talk.session.acknowledgeMark", ["operator.talk"]],
["talk.session.submitToolResult", ["operator.talk"]],
@@ -701,11 +697,7 @@ describe("operator scope authorization", () => {
"talk.client.toolCall",
"talk.client.steer",
"talk.session.create",
"talk.session.join",
"talk.session.appendAudio",
"talk.session.startTurn",
"talk.session.endTurn",
"talk.session.cancelTurn",
"talk.session.cancelOutput",
"talk.session.acknowledgeMark",
"talk.session.submitToolResult",
@@ -26,7 +26,6 @@ const CURRENT_TRAIN_METHODS = [
"terminal.close",
"terminal.attach",
"terminal.list",
"terminal.text",
"terminal.upload",
"worktrees.list",
"worktrees.branches",
-10
View File
@@ -43,7 +43,6 @@ const CORE_GATEWAY_METHOD_SPECS = [
["doctor.memory.resetGroundedShortTerm", "doctor", "operator.write", "<=2026.7"],
["doctor.memory.repairDreamingArtifacts", "doctor", "operator.write", "<=2026.7"],
["doctor.memory.dedupeDreamDiary", "doctor", "operator.write", "<=2026.7"],
["doctor.memory.remHarness", "doctor", "operator.read", "<=2026.7"],
["logs.tail", "logs", "operator.read", "<=2026.7"],
["channels.status", "channels", "operator.read", "<=2026.7"],
["channels.start", "channels", "operator.admin", "<=2026.7"],
@@ -109,11 +108,7 @@ const CORE_GATEWAY_METHOD_SPECS = [
["talk.client.toolCall", "talk", "operator.talk", "<=2026.7"],
["talk.client.steer", "talk", "operator.talk", "<=2026.7"],
["talk.session.create", "talk", "operator.talk", "<=2026.7"],
["talk.session.join", "talk", "operator.talk", "<=2026.7"],
["talk.session.appendAudio", "talk", "operator.talk", "<=2026.7"],
["talk.session.startTurn", "talk", "operator.talk", "<=2026.7"],
["talk.session.endTurn", "talk", "operator.talk", "<=2026.7"],
["talk.session.cancelTurn", "talk", "operator.talk", "<=2026.7"],
["talk.session.cancelOutput", "talk", "operator.talk", "<=2026.7"],
["talk.session.acknowledgeMark", "talk", "operator.talk", "<=2026.7"],
["talk.session.submitToolResult", "talk", "operator.talk", "<=2026.7"],
@@ -222,17 +217,14 @@ const CORE_GATEWAY_METHOD_SPECS = [
["secrets.reload", null, "operator.admin", "<=2026.7"],
["secrets.resolve", null, "operator.admin", "<=2026.7"],
["voicewake.routing.get", "voicewake-routing", "operator.read", "<=2026.7"],
["voicewake.routing.set", "voicewake-routing", "operator.write", "<=2026.7"],
["sessions.list", "sessions-read", "operator.read", "<=2026.7", { startup: true }],
["sessions.subscribe", "sessions-subscriptions", "operator.read", "<=2026.7"],
["sessions.unsubscribe", "sessions-subscriptions", "operator.read", "<=2026.7"],
["sessions.messages.subscribe", "sessions-subscriptions", "operator.read", "<=2026.7"],
["sessions.messages.unsubscribe", "sessions-subscriptions", "operator.read", "<=2026.7"],
["sessions.viewers.set", "sessions-subscriptions", "operator.read", "2026.7"],
["sessions.preview", "sessions-read", "operator.read", "<=2026.7"],
["sessions.describe", "sessions-read", "operator.read", "<=2026.7"],
["sessions.compaction.list", "sessions-compaction-queries", "operator.read", "<=2026.7"],
["sessions.compaction.get", "sessions-compaction-queries", "operator.read", "<=2026.7"],
["sessions.compaction.branch", "sessions-compaction-checkpoints", "operator.write", "<=2026.7"],
["sessions.compaction.restore", "sessions-compaction-checkpoints", "operator.admin", "<=2026.7"],
["sessions.branches.list", "sessions-rewind", "operator.read", "<=2026.7"],
@@ -307,7 +299,6 @@ const CORE_GATEWAY_METHOD_SPECS = [
["cron.run", "cron", "operator.admin", "<=2026.7"],
["cron.runs", "cron", "operator.read", "<=2026.7"],
["gateway.identity.get", "system", "operator.read", "<=2026.7"],
["gateway.restart.preflight", "restart", "operator.read", "<=2026.7"],
["gateway.restart.request", "restart", "operator.admin", "<=2026.7", { controlPlaneWrite: true }],
["system-presence", "system", "operator.read", "<=2026.7"],
["system-event", "system", "operator.admin", "<=2026.7"],
@@ -362,7 +353,6 @@ const CORE_GATEWAY_METHOD_SPECS = [
// advertised method indices stay stable for older clients; new methods append.
["terminal.attach", "terminal", "operator.admin", "2026.7"],
["terminal.list", "terminal", "operator.admin", "2026.7"],
["terminal.text", "terminal", "operator.admin", "2026.7"],
["controlUi.githubPreview", "control-ui", "operator.read", "<=2026.7"],
// Additive discovery methods append here so older clients keep stable indices.
["system.info", "system", "operator.read", "<=2026.7"],
+1 -5
View File
@@ -166,7 +166,7 @@ describe("listGatewayMethods", () => {
"doctor.memory.dreamDiary",
"doctor.memory.backfillDreamDiary",
]);
expect(methods.slice(32, 37)).toEqual([
expect(methods.slice(31, 36)).toEqual([
"exec.approvals.get",
"exec.approvals.set",
"exec.approvals.node.get",
@@ -239,11 +239,7 @@ describe("listGatewayMethods", () => {
expect(methods).toContain("talk.client.toolCall");
expect(methods).toContain("talk.client.steer");
expect(methods).toContain("talk.session.create");
expect(methods).toContain("talk.session.join");
expect(methods).toContain("talk.session.appendAudio");
expect(methods).toContain("talk.session.startTurn");
expect(methods).toContain("talk.session.endTurn");
expect(methods).toContain("talk.session.cancelTurn");
expect(methods).toContain("talk.session.cancelOutput");
expect(methods).toContain("talk.session.acknowledgeMark");
expect(methods).toContain("talk.session.submitToolResult");
@@ -8,7 +8,6 @@ export {
dedupeDreamDiaryEntries,
loadShortTermPromotionDreamingStats,
previewGroundedRemMarkdown,
previewRemHarness,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
repairDreamingArtifacts,
+1 -278
View File
@@ -22,7 +22,6 @@ const resolveMemorySearchConfig = vi.hoisted(() =>
);
const getMemorySearchManager = vi.hoisted(() => vi.fn());
const previewGroundedRemMarkdown = vi.hoisted(() => vi.fn());
const previewRemHarness = vi.hoisted(() => vi.fn());
const dedupeDreamDiaryEntries = vi.hoisted(() => vi.fn());
const writeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
@@ -62,7 +61,6 @@ vi.mock("./doctor.memory-core-runtime.js", () => ({
dedupeDreamDiaryEntries,
loadShortTermPromotionDreamingStats,
previewGroundedRemMarkdown,
previewRemHarness,
writeBackfillDiaryEntries,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
@@ -83,9 +81,7 @@ const DOCTOR_MEMORY_TARGET_METHODS = [
"doctor.memory.dedupeDreamDiary",
] as const;
type DoctorMemoryMethod =
| (typeof DOCTOR_MEMORY_TARGET_METHODS)[number]
| "doctor.memory.remHarness";
type DoctorMemoryMethod = (typeof DOCTOR_MEMORY_TARGET_METHODS)[number];
const invokeDoctorMemory = async (
method: DoctorMemoryMethod,
@@ -1470,277 +1466,4 @@ describe("doctor.memory.dreamDiary", () => {
});
});
describe("doctor.memory.remHarness", () => {
const makeHarnessPreview = (
overrides: Partial<{
workspaceDir: string;
remSkipped: boolean;
rem: Record<string, unknown>;
grounded: Record<string, unknown> | null;
deep: Record<string, unknown>;
remConfig: Record<string, unknown>;
deepConfig: Record<string, unknown>;
}> = {},
) => ({
workspaceDir: overrides.workspaceDir ?? "/tmp/openclaw",
nowMs: 0,
remConfig: {
enabled: true,
lookbackDays: 7,
limit: 25,
minPatternStrength: 0.35,
...overrides.remConfig,
},
deepConfig: {
minScore: 0.75,
minRecallCount: 3,
minUniqueQueries: 2,
recencyHalfLifeDays: 14,
...overrides.deepConfig,
},
recallEntryCount: 0,
remSkipped: overrides.remSkipped ?? false,
rem: {
sourceEntryCount: 0,
reflections: [],
candidateTruths: [],
candidateKeys: [],
bodyLines: [],
...overrides.rem,
},
grounded: overrides.grounded ?? null,
groundedInputPaths: [],
deep: {
candidateLimit: 25,
candidateCount: 0,
truncated: false,
candidates: [],
...overrides.deep,
},
});
beforeEach(() => {
getRuntimeConfig.mockClear().mockReturnValue({} as OpenClawConfig);
resolveDefaultAgentId.mockClear().mockReturnValue("main");
resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw");
previewRemHarness.mockReset().mockResolvedValue(makeHarnessPreview());
previewGroundedRemMarkdown.mockReset();
});
it("returns an empty preview payload for an empty workspace", async () => {
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond);
expectRecordFields(mockCallArg(previewRemHarness), {
workspaceDir: "/tmp/openclaw",
grounded: false,
includePromoted: false,
candidateLimit: 25,
groundedFileLimit: 10,
remPreviewLimit: 50,
});
expect(previewGroundedRemMarkdown).not.toHaveBeenCalled();
const payload = respondPayload(respond);
expectRecordFields(payload, {
ok: true,
agentId: "main",
workspaceDir: "/tmp/openclaw",
grounded: null,
});
expectRecordFields(payload.rem, {
skipped: false,
sourceEntryCount: 0,
reflections: [],
candidateTruths: [],
});
expectRecordFields(payload.deep, {
candidateLimit: 25,
truncated: false,
candidates: [],
});
});
it("maps REM preview and deep candidates into the payload", async () => {
previewRemHarness.mockResolvedValue(
makeHarnessPreview({
rem: {
sourceEntryCount: 2,
reflections: ["reflection line"],
candidateTruths: [{ snippet: "truthy snippet", confidence: 0.72, evidence: "a" }],
candidateKeys: ["a"],
bodyLines: ["## REM", "- truthy snippet"],
},
deep: {
candidates: [
{
key: "memory/2026-04-14.md:12:16",
path: "memory/2026-04-14.md",
startLine: 12,
endLine: 16,
source: "memory",
snippet: "durable fact",
recallCount: 4,
uniqueQueries: 3,
avgScore: 0.81,
maxScore: 0.92,
ageDays: 1,
firstRecalledAt: "2026-04-13T10:00:00.000Z",
lastRecalledAt: "2026-04-14T10:00:00.000Z",
promotedAt: undefined,
},
],
},
}),
);
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond);
const payload = respondPayload(respond);
expectRecordFields(payload, { ok: true });
expectRecordFields(payload.rem, {
reflections: ["reflection line"],
candidateTruths: [{ snippet: "truthy snippet", confidence: 0.72 }],
bodyLines: ["## REM", "- truthy snippet"],
});
const deep = expectRecordFields(payload.deep, {
candidateLimit: 25,
truncated: false,
});
expectRecordFields((deep.candidates as unknown[])[0], {
key: "memory/2026-04-14.md:12:16",
path: "memory/2026-04-14.md",
snippet: "durable fact",
recallCount: 4,
uniqueQueries: 3,
avgScore: 0.81,
promoted: false,
});
});
it("invokes grounded preview when grounded=true and daily files exist", async () => {
previewRemHarness.mockResolvedValue(
makeHarnessPreview({
grounded: {
scannedFiles: 2,
files: [
{ path: "memory/2026-04-13.md", renderedMarkdown: "## REM\n- a" },
{ path: "memory/2026-04-14.md", renderedMarkdown: "## REM\n- b" },
],
},
}),
);
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond, {
params: { grounded: true },
});
expectRecordFields(mockCallArg(previewRemHarness), { grounded: true });
const payload = respondPayload(respond);
expectRecordFields(payload.grounded, {
scannedFiles: 2,
files: [
{ path: "memory/2026-04-13.md", renderedMarkdown: "## REM\n- a" },
{ path: "memory/2026-04-14.md", renderedMarkdown: "## REM\n- b" },
],
});
});
it("passes bounded grounded and REM preview limits to the shared harness", async () => {
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond, {
params: { grounded: true },
});
expectRecordFields(mockCallArg(previewRemHarness), {
grounded: true,
groundedFileLimit: 10,
remPreviewLimit: 50,
});
});
it("maps requested empty grounded preview into an empty payload", async () => {
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond, {
params: { grounded: true },
});
expectRecordFields(respondPayload(respond), {
grounded: { scannedFiles: 0, files: [] },
});
});
it("returns an error payload when the recall store read fails", async () => {
previewRemHarness.mockRejectedValue(new Error("disk boom"));
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond);
const payload = respondPayload(respond);
expectRecordFields(payload, {
ok: false,
agentId: "main",
workspaceDir: "/tmp/openclaw",
});
expect(String(payload.error)).toContain("disk boom");
});
it("caps deep candidates and reports truncated when the store exceeds the limit", async () => {
const overflowCandidate = (index: number) => ({
key: `memory/2026-04-14.md:${index}:${index + 1}`,
path: "memory/2026-04-14.md",
startLine: index,
endLine: index + 1,
source: "memory",
snippet: `snippet-${index}`,
recallCount: 3,
uniqueQueries: 2,
avgScore: 0.6,
maxScore: 0.9,
ageDays: 1,
firstRecalledAt: "2026-04-13T10:00:00.000Z",
lastRecalledAt: "2026-04-14T10:00:00.000Z",
promotedAt: undefined,
});
previewRemHarness.mockResolvedValue(
makeHarnessPreview({
deep: {
candidateLimit: 25,
candidateCount: 25,
truncated: true,
candidates: Array.from({ length: 25 }, (_unused, index) => overflowCandidate(index)),
},
}),
);
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond);
expectRecordFields(mockCallArg(previewRemHarness), { candidateLimit: 25 });
const payload = respondPayload(respond) as {
ok: boolean;
deep: { candidateLimit: number; truncated: boolean; candidates: unknown[] };
};
expect(payload.ok).toBe(true);
expect(payload.deep.candidateLimit).toBe(25);
expect(payload.deep.truncated).toBe(true);
expect(payload.deep.candidates).toHaveLength(25);
});
it("clamps caller-supplied limit within [1, REM_HARNESS_MAX_CANDIDATE_LIMIT]", async () => {
const respond = vi.fn();
await invokeDoctorMemory("doctor.memory.remHarness", respond, { params: { limit: 500 } });
expectRecordFields(mockCallArg(previewRemHarness), { candidateLimit: 100 });
const payload = respondPayload(respond) as {
deep: { candidateLimit: number };
};
expect(payload.deep.candidateLimit).toBe(100);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+1 -181
View File
@@ -1,5 +1,4 @@
// Doctor gateway methods inspect and repair memory dreaming artifacts, managed
// cron state, and REM harness previews for operator diagnostics.
// Doctor gateway methods inspect and repair memory dreaming artifacts and managed cron state.
import fs from "node:fs/promises";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
@@ -26,7 +25,6 @@ import {
dedupeDreamDiaryEntries,
loadShortTermPromotionDreamingStats,
previewGroundedRemMarkdown,
previewRemHarness,
removeBackfillDiaryEntries,
removeGroundedShortTermCandidates,
repairDreamingArtifacts,
@@ -39,10 +37,6 @@ const MANAGED_DEEP_SLEEP_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DEEP_SLEEP_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DEEP_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
const DREAM_DIARY_FILE_NAMES = ["DREAMS.md", "dreams.md"] as const;
const REM_HARNESS_DEFAULT_CANDIDATE_LIMIT = 25;
const REM_HARNESS_MAX_CANDIDATE_LIMIT = 100;
const REM_HARNESS_MAX_GROUNDED_FILES = 10;
const REM_HARNESS_MAX_REM_PREVIEW_LIMIT = 50;
type DoctorMemoryDreamingPhasePayload = {
enabled: boolean;
@@ -191,75 +185,6 @@ export type DoctorMemoryDreamActionPayload = {
keptEntries?: number;
};
export type DoctorMemoryRemHarnessCandidatePayload = {
key: string;
path: string;
startLine: number;
endLine: number;
snippet: string;
recallCount: number;
uniqueQueries: number;
avgScore: number;
maxScore: number;
ageDays: number;
firstRecalledAt: string;
lastRecalledAt: string;
promoted: boolean;
promotedAt?: string;
};
export type DoctorMemoryRemHarnessCandidateTruthPayload = {
snippet: string;
confidence: number;
};
export type DoctorMemoryRemHarnessGroundedFilePayload = {
path: string;
renderedMarkdown: string;
};
export type DoctorMemoryRemHarnessSuccessPayload = {
ok: true;
agentId: string;
workspaceDir: string;
remConfig: {
enabled: boolean;
lookbackDays: number;
limit: number;
minPatternStrength: number;
};
deepConfig: {
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
recencyHalfLifeDays: number;
maxAgeDays: number | null;
};
rem: {
skipped: boolean;
sourceEntryCount: number;
reflections: string[];
candidateTruths: DoctorMemoryRemHarnessCandidateTruthPayload[];
bodyLines: string[];
};
grounded: {
scannedFiles: number;
files: DoctorMemoryRemHarnessGroundedFilePayload[];
} | null;
deep: {
candidateLimit: number;
truncated: boolean;
candidates: DoctorMemoryRemHarnessCandidatePayload[];
};
};
export type DoctorMemoryRemHarnessErrorPayload = {
ok: false;
agentId: string;
workspaceDir: string;
error: string;
};
function extractIsoDayFromPath(filePath: string): string | null {
const match = filePath.replaceAll("\\", "/").match(/(\d{4}-\d{2}-\d{2})(?:-[^/]+)?\.md$/i);
return match?.[1] ?? null;
@@ -1031,110 +956,5 @@ export const doctorHandlers: GatewayRequestHandlers = {
};
respond(true, payload, undefined);
},
"doctor.memory.remHarness": async ({ params, respond, context }) => {
const cfg = context.getRuntimeConfig();
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const req = asOptionalRecord(params);
const grounded = Boolean(req?.grounded);
const includePromoted = Boolean(req?.includePromoted);
const requestedLimit =
typeof req?.limit === "number" && Number.isFinite(req.limit)
? Math.floor(req.limit)
: REM_HARNESS_DEFAULT_CANDIDATE_LIMIT;
const candidateLimit = Math.max(1, Math.min(REM_HARNESS_MAX_CANDIDATE_LIMIT, requestedLimit));
try {
const preview = await previewRemHarness({
workspaceDir,
cfg,
pluginConfig: resolveMemoryDreamingPluginConfig(cfg),
grounded,
includePromoted,
candidateLimit,
groundedFileLimit: REM_HARNESS_MAX_GROUNDED_FILES,
remPreviewLimit: REM_HARNESS_MAX_REM_PREVIEW_LIMIT,
});
const groundedPayload: DoctorMemoryRemHarnessSuccessPayload["grounded"] = preview.grounded
? {
scannedFiles: preview.grounded.scannedFiles,
files: preview.grounded.files.map((file) => ({
path: file.path,
renderedMarkdown: file.renderedMarkdown,
})),
}
: grounded
? { scannedFiles: 0, files: [] }
: null;
const payload: DoctorMemoryRemHarnessSuccessPayload = {
ok: true,
agentId,
workspaceDir,
remConfig: {
enabled: preview.remConfig.enabled,
lookbackDays: preview.remConfig.lookbackDays,
limit: preview.remConfig.limit,
minPatternStrength: preview.remConfig.minPatternStrength,
},
deepConfig: {
minScore: preview.deepConfig.minScore,
minRecallCount: preview.deepConfig.minRecallCount,
minUniqueQueries: preview.deepConfig.minUniqueQueries,
recencyHalfLifeDays: preview.deepConfig.recencyHalfLifeDays,
maxAgeDays:
typeof preview.deepConfig.maxAgeDays === "number"
? preview.deepConfig.maxAgeDays
: null,
},
rem: {
skipped: preview.remSkipped,
sourceEntryCount: preview.rem.sourceEntryCount,
reflections: [...preview.rem.reflections],
candidateTruths: preview.rem.candidateTruths.map((truth) => ({
snippet: truth.snippet,
confidence: truth.confidence,
})),
bodyLines: [...preview.rem.bodyLines],
},
grounded: groundedPayload,
deep: {
candidateLimit,
truncated: preview.deep.truncated,
candidates: preview.deep.candidates.map((candidate) => {
const promoted =
typeof candidate.promotedAt === "string" && candidate.promotedAt.length > 0;
const payloadLocal: DoctorMemoryRemHarnessCandidatePayload = {
key: candidate.key,
path: candidate.path,
startLine: candidate.startLine,
endLine: candidate.endLine,
snippet: candidate.snippet,
recallCount: candidate.recallCount,
uniqueQueries: candidate.uniqueQueries,
avgScore: candidate.avgScore,
maxScore: candidate.maxScore,
ageDays: candidate.ageDays,
firstRecalledAt: candidate.firstRecalledAt,
lastRecalledAt: candidate.lastRecalledAt,
promoted,
};
if (promoted) {
payloadLocal.promotedAt = candidate.promotedAt;
}
return payloadLocal;
}),
},
};
respond(true, payload, undefined);
} catch (err) {
const payload: DoctorMemoryRemHarnessErrorPayload = {
ok: false,
agentId,
workspaceDir,
error: `gateway rem-harness probe failed: ${formatError(err)}`,
};
respond(true, payload, undefined);
}
},
};
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -11,18 +11,6 @@ const requestGatewayRestartWithSignalAdmission = vi.hoisted(() => vi.fn());
const readActiveGatewayLockIdentity = vi.hoisted(() => vi.fn());
vi.mock("../../infra/restart-coordinator.js", () => ({
createSafeGatewayRestartPreflight: vi.fn(() => ({
safe: true,
counts: {
queueSize: 0,
pendingReplies: 0,
embeddedRuns: 0,
activeTasks: 0,
totalActive: 0,
},
blockers: [],
summary: "safe to restart now",
})),
requestSafeGatewayRestart: (opts: unknown) => requestSafeGatewayRestart(opts),
}));
+1 -7
View File
@@ -3,10 +3,7 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coerci
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { readActiveGatewayLockIdentity } from "../../infra/gateway-lock.js";
import {
createSafeGatewayRestartPreflight,
requestSafeGatewayRestart,
} from "../../infra/restart-coordinator.js";
import { requestSafeGatewayRestart } from "../../infra/restart-coordinator.js";
import type { GatewayRestartIntent } from "../../infra/restart-intent.js";
import { requestGatewayRestartWithSignalAdmission } from "../../infra/restart.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -162,7 +159,4 @@ export const restartHandlers: GatewayRequestHandlers = {
});
respond(true, result);
},
"gateway.restart.preflight": async ({ respond }) => {
respond(true, createSafeGatewayRestartPreflight());
},
};
@@ -1,15 +1,6 @@
// Read-only compaction checkpoint queries.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
validateSessionsCompactionGetParams,
validateSessionsCompactionListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import {
getSessionCompactionCheckpoint,
listSessionCompactionCheckpoints,
} from "../session-compaction-checkpoints.js";
import { validateSessionsCompactionListParams } from "../../../packages/gateway-protocol/src/index.js";
import { listSessionCompactionCheckpoints } from "../session-compaction-checkpoints.js";
import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js";
import { loadAccessorSessionEntryForGatewayTarget, requireSessionKey } from "./sessions-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -53,55 +44,4 @@ export const sessionCheckpointQueryHandlers: GatewayRequestHandlers = {
undefined,
);
},
"sessions.compaction.get": ({ params, respond, context }) => {
if (
!assertValidParams(
params,
validateSessionsCompactionGetParams,
"sessions.compaction.get",
respond,
)
) {
return;
}
const p = params;
const key = requireSessionKey(p.key, respond);
if (!key) {
return;
}
const checkpointId = normalizeOptionalString(p.checkpointId) ?? "";
if (!checkpointId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required"));
return;
}
const cfg = context.getRuntimeConfig();
const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId);
if (!requestedAgent.ok) {
respond(false, undefined, requestedAgent.error);
return;
}
const { entry, canonicalKey } = loadAccessorSessionEntryForGatewayTarget({
key,
cfg,
agentId: requestedAgent.agentId,
});
const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId });
if (!checkpoint) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`),
);
return;
}
respond(
true,
{
ok: true,
key: canonicalKey,
checkpoint,
},
undefined,
);
},
};
@@ -24,13 +24,6 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = {
}
respond(true, { subscribed: Boolean(connId) }, undefined);
},
"sessions.unsubscribe": ({ client, context, respond }) => {
const connId = client?.connId?.trim();
if (connId) {
context.unsubscribeSessionEvents(connId);
}
respond(true, { subscribed: false }, undefined);
},
"sessions.viewers.set": ({ params, client, context, respond }) => {
if (
!assertValidParams(
@@ -7,7 +7,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { createDeferred } from "../../../test/helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSafeGatewayRestartPreflight } from "../../infra/restart-coordinator.js";
import type { SystemAgentApprovalRequestPayload } from "../../infra/system-agent-approvals.js";
import { resetPluginStateStoreForTests } from "../../plugin-state/plugin-state-store.js";
import { getCommandLaneSnapshot } from "../../process/command-queue.js";
@@ -914,12 +913,6 @@ describe("openclaw.chat", () => {
await approvalStarted.promise;
try {
expect(systemAgentLane()).toMatchObject({ activeCount: 1, queuedCount: 0 });
const restartPreflight = createSafeGatewayRestartPreflight();
expect(restartPreflight.safe).toBe(false);
expect(restartPreflight.counts.queueSize).toBe(1);
expect(restartPreflight.blockers).toContainEqual(
expect.objectContaining({ kind: "queue", count: 1 }),
);
} finally {
releaseApproval.resolve();
}
@@ -936,7 +929,6 @@ describe("openclaw.chat", () => {
summary: "Scheduled Gateway restart",
});
expect(getActiveGatewayRootWorkCount()).toBe(0);
expect(createSafeGatewayRestartPreflight().counts.queueSize).toBe(0);
});
it("reuses a live session, then requires fresh fallback verification after failure", async () => {
+2 -197
View File
@@ -7,13 +7,10 @@ import {
errorShape,
validateTalkSessionAppendAudioParams,
validateTalkSessionCancelOutputParams,
validateTalkSessionCancelTurnParams,
validateTalkSessionCloseParams,
validateTalkSessionCreateParams,
validateTalkSessionJoinParams,
validateTalkSessionSteerParams,
validateTalkSessionSubmitToolResultParams,
validateTalkSessionTurnParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { buildAgentMainSessionKey } from "../../routing/session-key.js";
import { REALTIME_VOICE_AGENT_CONSULT_TOOL } from "../../talk/agent-consult-tool.js";
@@ -24,16 +21,7 @@ import { ensureClientVoiceAgentSessionEntry } from "../../talk/client-voice-sess
import { resolveConfiguredRealtimeVoiceProvider } from "../../talk/provider-resolver.js";
import { ADMIN_SCOPE } from "../operator-scopes.js";
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
import {
cancelTalkHandoffTurn,
createTalkHandoff,
endTalkHandoffTurn,
getTalkHandoff,
joinTalkHandoff,
revokeTalkHandoff,
startTalkHandoffTurn,
type TalkHandoffTurnResult,
} from "../talk-handoff.js";
import { createTalkHandoff, getTalkHandoff, revokeTalkHandoff } from "../talk-handoff.js";
import {
cancelTalkRealtimeRelayTurn,
createTalkRealtimeRelaySession,
@@ -47,10 +35,8 @@ import {
getUnifiedTalkSession,
rememberUnifiedTalkSession,
requireUnifiedTalkSessionConn,
type UnifiedTalkSessionRecord,
} from "../talk-session-registry.js";
import {
cancelTalkTranscriptionRelayTurn,
createTalkTranscriptionRelaySession,
sendTalkTranscriptionRelayAudio,
stopTalkTranscriptionRelaySession,
@@ -70,14 +56,10 @@ import {
resolveConfiguredRealtimeTranscriptionProvider,
resolveTalkRealtimeProviderInstructions,
resolveTalkRealtimeGatewayRelayLaunch,
talkHandoffErrorCode,
} from "./talk-shared.js";
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
/** Gateway-managed Talk sessions resolve public ids through connection-owned unified records. */
type ManagedRoomTalkSession = Extract<UnifiedTalkSessionRecord, { kind: "managed-room" }>;
function isActiveManagedRoomClient(
session: { handoffId: string },
connId: string | undefined,
@@ -135,44 +117,6 @@ function respondOk(respond: RespondFn, payload: unknown = { ok: true }) {
respond(true, payload, undefined);
}
function respondManagedRoomTurn(params: {
session: UnifiedTalkSessionRecord;
connId?: string;
context: GatewayRequestContext;
respond: RespondFn;
method: "talk.session.startTurn" | "talk.session.endTurn" | "talk.session.cancelTurn";
ownershipAction: "startTurn" | "endTurn" | "cancelTurn";
failureVerb: "start" | "end" | "cancel";
run: (session: ManagedRoomTalkSession) => TalkHandoffTurnResult;
}) {
if (params.session.kind !== "managed-room") {
respondInvalidRequest(params.respond, `${params.method} requires managed-room`);
return;
}
if (!isActiveManagedRoomClient(params.session, params.connId)) {
params.respond(false, undefined, managedRoomOwnershipError(params.ownershipAction));
return;
}
const result = params.run(params.session);
if (!result.ok) {
params.respond(
false,
undefined,
errorShape(
talkHandoffErrorCode(result.reason),
`talk turn ${params.failureVerb} failed: ${result.reason}`,
),
);
return;
}
broadcastTalkRoomEvents(params.context, result.record.room.activeClientId, {
handoffId: result.record.id,
roomId: result.record.roomId,
events: result.events,
});
respondOk(params.respond, { ok: true, turnId: result.turnId, events: result.events });
}
/** RPC handlers for gateway-managed Talk sessions and room lifecycle. */
export const talkSessionHandlers: GatewayRequestHandlers = {
"talk.session.create": async ({ params, respond, context, client }) => {
@@ -389,43 +333,6 @@ export const talkSessionHandlers: GatewayRequestHandlers = {
respondUnavailable(respond, err);
}
},
"talk.session.join": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateTalkSessionJoinParams, "talk.session.join", respond)) {
return;
}
try {
const session = getUnifiedTalkSession(params.sessionId);
if (session.kind !== "managed-room") {
respondInvalidRequest(respond, "talk.session.join requires a managed-room session");
return;
}
const result = joinTalkHandoff(session.handoffId, params.token, { clientId: client?.connId });
if (!result.ok) {
respond(
false,
undefined,
errorShape(
result.reason === "invalid_token" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
`talk session join failed: ${result.reason}`,
),
);
return;
}
broadcastTalkRoomEvents(context, result.replacedClientId, {
handoffId: result.record.id,
roomId: result.record.roomId,
events: result.replacementEvents,
});
broadcastTalkRoomEvents(context, client?.connId, {
handoffId: result.record.id,
roomId: result.record.roomId,
events: result.activeClientEvents,
});
respondOk(respond, result.record);
} catch (err) {
respondUnavailable(respond, err);
}
},
"talk.session.appendAudio": async ({ params, respond, client }) => {
if (
!assertValidParams(
@@ -468,108 +375,6 @@ export const talkSessionHandlers: GatewayRequestHandlers = {
respondUnavailable(respond, err);
}
},
"talk.session.startTurn": async ({ params, respond, client, context }) => {
if (
!assertValidParams(params, validateTalkSessionTurnParams, "talk.session.startTurn", respond)
) {
return;
}
try {
const session = getUnifiedTalkSession(params.sessionId);
respondManagedRoomTurn({
session,
connId: client?.connId,
context,
respond,
method: "talk.session.startTurn",
ownershipAction: "startTurn",
failureVerb: "start",
run: (managedSession) =>
startTalkHandoffTurn(managedSession.handoffId, managedSession.token, {
turnId: params.turnId,
clientId: client?.connId,
}),
});
} catch (err) {
respondUnavailable(respond, err);
}
},
"talk.session.endTurn": async ({ params, respond, client, context }) => {
if (
!assertValidParams(params, validateTalkSessionTurnParams, "talk.session.endTurn", respond)
) {
return;
}
try {
const session = getUnifiedTalkSession(params.sessionId);
respondManagedRoomTurn({
session,
connId: client?.connId,
context,
respond,
method: "talk.session.endTurn",
ownershipAction: "endTurn",
failureVerb: "end",
run: (managedSession) =>
endTalkHandoffTurn(managedSession.handoffId, managedSession.token, {
turnId: params.turnId,
}),
});
} catch (err) {
respondUnavailable(respond, err);
}
},
"talk.session.cancelTurn": async ({ params, respond, client, context }) => {
if (
!assertValidParams(
params,
validateTalkSessionCancelTurnParams,
"talk.session.cancelTurn",
respond,
)
) {
return;
}
try {
const session = getUnifiedTalkSession(params.sessionId);
if (session.kind === "realtime-relay") {
const connId = requireUnifiedTalkSessionConn(session, client?.connId);
cancelTalkRealtimeRelayTurn({
relaySessionId: session.relaySessionId,
connId,
reason: normalizeOptionalString(params.reason),
});
respondOk(respond);
return;
}
if (session.kind === "transcription-relay") {
const connId = requireUnifiedTalkSessionConn(session, client?.connId);
cancelTalkTranscriptionRelayTurn({
transcriptionSessionId: session.transcriptionSessionId,
connId,
reason: normalizeOptionalString(params.reason),
});
respondOk(respond);
return;
}
respondManagedRoomTurn({
session,
connId: client?.connId,
context,
respond,
method: "talk.session.cancelTurn",
ownershipAction: "cancelTurn",
failureVerb: "cancel",
run: (managedSession) =>
cancelTalkHandoffTurn(managedSession.handoffId, managedSession.token, {
turnId: params.turnId,
reason: params.reason,
}),
});
} catch (err) {
respondUnavailable(respond, err);
}
},
"talk.session.cancelOutput": async ({ params, respond, client }) => {
if (
!assertValidParams(
-10
View File
@@ -6,7 +6,6 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
import { resolveRealtimeBootstrapContextInstructions } from "../../agents/realtime-bootstrap-context.js";
import type { TalkRealtimeConfig } from "../../config/types.gateway.js";
import type { OpenClawConfig } from "../../config/types.js";
@@ -33,7 +32,6 @@ import {
type VoiceModelProvider,
} from "../../tts/voice-models.js";
import { ADMIN_SCOPE } from "../operator-scopes.js";
import type { TalkHandoffTurnResult } from "../talk-handoff.js";
/** Resolve the Talk session mode, defaulting managed-room transports to stt-tts. */
export function normalizeTalkSessionMode(params: { mode?: string; transport?: string }): TalkMode {
@@ -130,14 +128,6 @@ export function broadcastTalkRoomEvents(
}
}
type TalkHandoffFailureReason = Extract<TalkHandoffTurnResult, { ok: false }>["reason"];
export function talkHandoffErrorCode(reason: TalkHandoffFailureReason) {
return reason === "invalid_token" || reason === "no_active_turn" || reason === "stale_turn"
? ErrorCodes.INVALID_REQUEST
: ErrorCodes.UNAVAILABLE;
}
function getRecord(value: unknown): Record<string, unknown> | undefined {
return asOptionalRecord(value) ?? undefined;
}
-254
View File
@@ -60,7 +60,6 @@ const mocks = vi.hoisted(() => ({
submitTalkRealtimeRelayToolResult: vi.fn(),
createTalkTranscriptionRelaySession: vi.fn(),
sendTalkTranscriptionRelayAudio: vi.fn(),
cancelTalkTranscriptionRelayTurn: vi.fn(),
stopTalkTranscriptionRelaySession: vi.fn(),
chatSend: vi.fn(),
controlRealtimeVoiceAgentRun: vi.fn(),
@@ -222,7 +221,6 @@ vi.mock("../talk-transcription-relay.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../talk-transcription-relay.js")>();
return {
...actual,
cancelTalkTranscriptionRelayTurn: mocks.cancelTalkTranscriptionRelayTurn,
createTalkTranscriptionRelaySession: mocks.createTalkTranscriptionRelaySession,
sendTalkTranscriptionRelayAudio: mocks.sendTalkTranscriptionRelayAudio,
stopTalkTranscriptionRelaySession: mocks.stopTalkTranscriptionRelaySession,
@@ -2183,154 +2181,6 @@ describe("talk.session unified handlers", () => {
});
});
it("creates and controls managed-room sessions through the unified API", async () => {
const broadcastToConnIds = vi.fn();
const createRespond = vi.fn();
await callTalkHandler("talk.session.create", {
params: {
mode: "stt-tts",
transport: "managed-room",
sessionKey: "session:main",
ttlMs: 5000,
},
client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } },
respond: createRespond,
context: {
getRuntimeConfig: () => ({}) as OpenClawConfig,
},
});
const session = mockCallArg(createRespond, 0, 1) as { sessionId: string; token: string };
const createResult = expectRespondOk(createRespond, {
transport: "managed-room",
brain: "agent-consult",
}) as Record<string, unknown>;
expect(createResult.sessionId).toBeTypeOf("string");
expect(createResult.handoffId).toBeTypeOf("string");
expect(createResult.roomId).toMatch(/^talk_/);
expect(createResult.token).toBeTypeOf("string");
expect(mocks.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith({
cfg: {},
client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } },
p: {
key: "session:main",
includeGlobal: true,
includeUnknown: true,
},
});
const joinRespond = vi.fn();
await callTalkHandler("talk.session.join", {
params: { sessionId: session.sessionId, token: session.token },
id: "2",
respond: joinRespond,
context: {
broadcastToConnIds,
},
});
const joinResult = expectRespondOk(joinRespond, { id: session.sessionId }) as {
room?: Record<string, unknown>;
};
expectRecordFields(joinResult.room, { activeClientId: "conn-1" });
expect(mockCallArg(broadcastToConnIds)).toBe("talk.event");
const readyEventPayload = expectRecordFields(mockCallArg(broadcastToConnIds, 0, 1), {
handoffId: session.sessionId,
});
expectRecordFields(readyEventPayload.talkEvent, { type: "session.ready" });
expect(mockCallArg(broadcastToConnIds, 0, 2)).toEqual(new Set(["conn-1"]));
expect(mockCallArg(broadcastToConnIds, 0, 3)).toEqual({ dropIfSlow: true });
const startRespond = vi.fn();
await callTalkHandler("talk.session.startTurn", {
params: { sessionId: session.sessionId, turnId: "turn-1" },
id: "3",
respond: startRespond,
context: {
getRuntimeConfig: () => ({}) as OpenClawConfig,
broadcastToConnIds,
},
});
const startResult = expectRespondOk(startRespond, { ok: true, turnId: "turn-1" }) as {
events?: unknown[];
};
expect(startResult.events).toHaveLength(1);
expectRecordFields(startResult.events?.[0], { type: "turn.started", turnId: "turn-1" });
expect(mockCallArg(broadcastToConnIds, 1)).toBe("talk.event");
const startEventPayload = expectRecordFields(mockCallArg(broadcastToConnIds, 1, 1), {
handoffId: session.sessionId,
});
expectRecordFields(startEventPayload.talkEvent, {
type: "turn.started",
turnId: "turn-1",
});
expect(mockCallArg(broadcastToConnIds, 1, 2)).toEqual(new Set(["conn-1"]));
expect(mockCallArg(broadcastToConnIds, 1, 3)).toEqual({ dropIfSlow: true });
const mismatchedSteerRespond = vi.fn();
await callTalkHandler("talk.session.steer", {
params: {
sessionId: session.sessionId,
sessionKey: "session:other",
text: "use the safer plan",
mode: "steer",
},
id: "4",
respond: mismatchedSteerRespond,
context: {
broadcastToConnIds,
},
});
expectRespondError(mismatchedSteerRespond, {
code: ErrorCodes.INVALID_REQUEST,
message: "talk.session.steer sessionKey does not match the managed-room session",
});
expect(mocks.controlRealtimeVoiceAgentRun).not.toHaveBeenCalled();
const steerRespond = vi.fn();
await callTalkHandler("talk.session.steer", {
params: {
sessionId: session.sessionId,
text: "use the safer plan",
mode: "steer",
},
id: "5",
respond: steerRespond,
context: {
broadcastToConnIds,
},
});
expect(mocks.controlRealtimeVoiceAgentRun).toHaveBeenCalledWith({
sessionKey: "session:main",
text: "use the safer plan",
mode: "steer",
recentEvents: expect.any(Array),
});
expectRespondOk(steerRespond, {
ok: true,
mode: "steer",
sessionKey: "session:main",
});
const closeRespond = vi.fn();
await callTalkHandler("talk.session.close", {
params: { sessionId: session.sessionId },
id: "6",
respond: closeRespond,
context: {
broadcastToConnIds,
},
});
expect(closeRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(mockCallArg(broadcastToConnIds, 2)).toBe("talk.event");
const closedEventPayload = expectRecordFields(mockCallArg(broadcastToConnIds, 2, 1), {
handoffId: session.sessionId,
});
expectRecordFields(closedEventPayload.talkEvent, { type: "session.closed", final: true });
expect(mockCallArg(broadcastToConnIds, 2, 2)).toEqual(new Set(["conn-1"]));
expect(mockCallArg(broadcastToConnIds, 2, 3)).toEqual({ dropIfSlow: true });
});
it("passes managed-room spawnedBy visibility scope to session resolution", async () => {
const createRespond = vi.fn();
await callTalkHandler("talk.session.create", {
@@ -2386,110 +2236,6 @@ describe("talk.session unified handlers", () => {
expect(mocks.resolveSessionKeyFromResolveParams).not.toHaveBeenCalled();
});
it("requires managed-room ownership before turn control", async () => {
const broadcastToConnIds = vi.fn();
const createRespond = vi.fn();
await callTalkHandler("talk.session.create", {
params: {
mode: "stt-tts",
transport: "managed-room",
sessionKey: "session:main",
},
client: { connId: "creator", connect: { scopes: ["operator.admin"] } },
respond: createRespond,
context: {
getRuntimeConfig: () => ({}) as OpenClawConfig,
},
});
const session = mockCallArg(createRespond, 0, 1) as { sessionId: string; token: string };
const unjoinedStartRespond = vi.fn();
await callTalkHandler("talk.session.startTurn", {
params: { sessionId: session.sessionId, turnId: "turn-1" },
id: "2",
client: { connId: "creator" },
respond: unjoinedStartRespond,
context: { broadcastToConnIds },
});
expectRespondError(unjoinedStartRespond, {
code: ErrorCodes.INVALID_REQUEST,
message: "talk.session.startTurn requires the active managed-room connection",
});
await callTalkHandler("talk.session.join", {
params: { sessionId: session.sessionId, token: session.token },
id: "3",
respond: vi.fn(),
context: { broadcastToConnIds },
});
const staleStartRespond = vi.fn();
await callTalkHandler("talk.session.startTurn", {
params: { sessionId: session.sessionId, turnId: "turn-1" },
id: "4",
client: { connId: "conn-2" },
respond: staleStartRespond,
context: { broadcastToConnIds },
});
expectRespondError(staleStartRespond, {
code: ErrorCodes.INVALID_REQUEST,
message: "talk.session.startTurn requires the active managed-room connection",
});
await callTalkHandler("talk.session.startTurn", {
params: { sessionId: session.sessionId, turnId: "turn-1" },
id: "5",
respond: vi.fn(),
context: { broadcastToConnIds },
});
const staleEndRespond = vi.fn();
await callTalkHandler("talk.session.endTurn", {
params: { sessionId: session.sessionId, turnId: "turn-1" },
id: "6",
client: { connId: "conn-2" },
respond: staleEndRespond,
context: { broadcastToConnIds },
});
expectRespondError(staleEndRespond, {
code: ErrorCodes.INVALID_REQUEST,
message: "talk.session.endTurn requires the active managed-room connection",
});
const staleCancelRespond = vi.fn();
await callTalkHandler("talk.session.cancelTurn", {
params: { sessionId: session.sessionId, turnId: "turn-1" },
id: "7",
client: { connId: "conn-2" },
respond: staleCancelRespond,
context: { broadcastToConnIds },
});
expectRespondError(staleCancelRespond, {
code: ErrorCodes.INVALID_REQUEST,
message: "talk.session.cancelTurn requires the active managed-room connection",
});
const staleCloseRespond = vi.fn();
await callTalkHandler("talk.session.close", {
params: { sessionId: session.sessionId },
id: "8",
client: { connId: "conn-2" },
respond: staleCloseRespond,
context: { broadcastToConnIds },
});
expectRespondError(staleCloseRespond, {
code: ErrorCodes.INVALID_REQUEST,
message: "talk.session.close requires the active managed-room connection",
});
await callTalkHandler("talk.session.close", {
params: { sessionId: session.sessionId },
id: "9",
respond: vi.fn(),
context: { broadcastToConnIds },
});
});
it("keeps direct-tools managed-room sessions behind admin scope", async () => {
const rejectedRespond = vi.fn();
await callTalkHandler("talk.session.create", {
@@ -861,22 +861,4 @@ describe("terminal gateway policy", () => {
});
expect(result).toEqual({ path: "/tmp/node/report.pdf", size: 4 });
});
it("sanitizes terminal snapshots before returning plain text", async () => {
const { opts, sessions, respond } = makeOpts({ sessionId: "s1" }, { enabled: true });
const finals = Array.from({ length: 0x7e - 0x40 + 1 }, (_, offset) =>
String.fromCharCode(0x40 + offset),
);
const sequences = ["\u001B[", "\u009B"]
.flatMap((introducer) => finals.map((finalByte) => introducer + finalByte))
.join("");
sessions.snapshot.mockReturnValue(`before${sequences}after`);
await expectDefined(
terminalHandlers["terminal.text"],
'terminalHandlers["terminal.text"] test invariant',
)(opts);
expect(respond).toHaveBeenCalledWith(true, { text: "beforeafter" });
});
});
-28
View File
@@ -17,7 +17,6 @@ import {
validateTerminalInputParams,
validateTerminalOpenParams,
validateTerminalResizeParams,
validateTerminalTextParams,
validateTerminalUploadResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { NODE_TERMINAL_UPLOAD_COMMAND } from "../../infra/node-commands.js";
@@ -25,7 +24,6 @@ import { mergeProcessEnv } from "../../infra/process-env.js";
import type { TerminalUploadFile } from "../../infra/terminal-file-upload.js";
import type { SessionCatalogTerminalPlan } from "../../plugins/session-catalog.js";
import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js";
import { renderTerminalBufferText } from "../terminal/buffer-text.js";
import { buildTerminalEnv, type TerminalLaunchResolution } from "../terminal/launch.js";
import { createNodeRelayBackend } from "../terminal/node-relay.js";
import {
@@ -618,30 +616,4 @@ export const terminalHandlers: GatewayRequestHandlers = {
: [];
respond(true, { sessions });
},
"terminal.text": async (opts) => {
const { params, respond, context } = opts;
if (!assertValidParams(params, validateTerminalTextParams, "terminal.text", respond)) {
return;
}
const connId = requireConnId(opts);
if (!connId) {
return;
}
const p = params as { sessionId: string };
if (!context.terminalSessions || !terminalEnabled(context)) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available"));
return;
}
const raw = context.terminalSessions.snapshot(p.sessionId);
if (raw === undefined) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown terminal session "${p.sessionId}"`),
);
return;
}
respond(true, { text: renderTerminalBufferText(raw) });
},
};
@@ -1,42 +1,10 @@
// Gateway RPC handlers for voice wake routing configuration.
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import {
loadVoiceWakeRoutingConfig,
normalizeVoiceWakeRoutingConfig,
setVoiceWakeRoutingConfig,
validateVoiceWakeRoutingConfigInput,
} from "../../infra/voicewake-routing.js";
import { loadVoiceWakeRoutingConfig } from "../../infra/voicewake-routing.js";
import type { GatewayRequestHandlers } from "./types.js";
/** Gateway request handlers for reading and updating voice wake routing. */
/** Gateway request handlers for reading voice wake routing. */
export const voicewakeRoutingHandlers: GatewayRequestHandlers = {
"voicewake.routing.get": async ({ respond }) => {
respond(true, { config: await loadVoiceWakeRoutingConfig() });
},
"voicewake.routing.set": async ({ params, respond, context }) => {
if (
!params ||
params.config === null ||
typeof params.config !== "object" ||
Array.isArray(params.config)
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "voicewake.routing.set requires config: object"),
);
return;
}
const validated = validateVoiceWakeRoutingConfigInput(params.config);
if (!validated.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, validated.message));
return;
}
// Validate first for caller-friendly errors, then normalize before
// persistence so broadcasts carry the canonical routing shape.
const normalized = normalizeVoiceWakeRoutingConfig(params.config);
const config = await setVoiceWakeRoutingConfig(normalized);
context.broadcastVoiceWakeRoutingChanged(config);
respond(true, { config });
},
};
@@ -2,7 +2,6 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { createSafeGatewayRestartPreflight } from "../../infra/restart-coordinator.js";
import {
getActiveGatewayRootWorkCount,
resetGatewayWorkAdmission,
@@ -167,15 +166,10 @@ describe("wizard setup ownership", () => {
});
expect(getActiveGatewayRootWorkCount()).toBe(1);
expect(createSafeGatewayRestartPreflight()).toMatchObject({
safe: false,
blockers: [expect.objectContaining({ kind: "root-request", count: 1 })],
});
runnerSettled.resolve();
await vi.waitFor(() => {
expect(getActiveGatewayRootWorkCount()).toBe(0);
});
expect(createSafeGatewayRestartPreflight().safe).toBe(true);
} finally {
runnerSettled.resolve();
resetGatewayWorkAdmission();
+11 -120
View File
@@ -438,134 +438,25 @@ describe("gateway server models + voicewake", () => {
});
});
test("voicewake.routing.get/set persists and broadcasts", { timeout: 60_000 }, async () => {
await withTempHome(async (homeDir) => {
const initial = await rpcReq<{
config?: { version?: number; defaultTarget?: unknown; routes?: unknown[] };
}>(ws, "voicewake.routing.get");
expect(initial.ok).toBe(true);
expect(initial.payload?.config?.version).toBe(1);
expect(initial.payload?.config?.defaultTarget).toEqual({ mode: "current" });
expect(initial.payload?.config?.routes).toStrictEqual([]);
test("voicewake.routing.get returns the default routing", async () => {
const result = await rpcReq<{
config?: { version?: number; defaultTarget?: unknown; routes?: unknown[] };
}>(ws, "voicewake.routing.get");
const changedP = onceMessage<{
type: "event";
event: string;
payload?: Record<string, unknown> | null;
}>(ws, (o) => o.type === "event" && o.event === "voicewake.routing.changed");
const setRes = await rpcReq<{
config?: { routes?: Array<{ trigger?: string; target?: unknown }>; updatedAtMs?: number };
}>(ws, "voicewake.routing.set", {
config: {
defaultTarget: { mode: "current" },
routes: [{ trigger: " Robot Wake ", target: { agentId: "main" } }],
},
});
expect(setRes.ok).toBe(true);
expect(setRes.payload?.config?.routes).toEqual([
{ trigger: "robot wake", target: { agentId: "main" } },
]);
expect(typeof setRes.payload?.config?.updatedAtMs).toBe("number");
const changed = await changedP;
expect(changed.event).toBe("voicewake.routing.changed");
expect(
(changed.payload as { config?: { routes?: unknown } } | undefined)?.config?.routes,
).toEqual([{ trigger: "robot wake", target: { agentId: "main" } }]);
const after = await rpcReq<{
config?: { routes?: Array<{ trigger?: string; target?: unknown }> };
}>(ws, "voicewake.routing.get");
expect(after.ok).toBe(true);
expect(after.payload?.config?.routes).toEqual([
{ trigger: "robot wake", target: { agentId: "main" } },
]);
await expect(
fs.readFile(path.join(homeDir, ".openclaw", "settings", "voicewake-routing.json"), "utf8"),
).rejects.toThrow(/ENOENT/u);
const invalid = await rpcReq(ws, "voicewake.routing.set", { config: null });
expect(invalid.ok).toBe(false);
expect(invalid.error?.message ?? "").toMatch(
/voicewake\.routing\.set requires config: object/i,
);
const badRoutes = await rpcReq(ws, "voicewake.routing.set", {
config: { routes: "oops" },
});
expect(badRoutes.ok).toBe(false);
expect(badRoutes.error?.message ?? "").toMatch(/config\.routes must be an array/i);
const badTarget = await rpcReq(ws, "voicewake.routing.set", {
config: {
routes: [
{ trigger: "robot wake", target: { agentId: "main", sessionKey: "agent:main:main" } },
],
},
});
expect(badTarget.ok).toBe(false);
expect(badTarget.error?.message ?? "").toMatch(
/config\.routes\[0\]\.target cannot include both agentId and sessionKey/i,
);
const badAgentId = await rpcReq(ws, "voicewake.routing.set", {
config: {
routes: [{ trigger: "robot wake", target: { agentId: "!!!" } }],
},
});
expect(badAgentId.ok).toBe(false);
expect(badAgentId.error?.message ?? "").toMatch(
/config\.routes\[0\]\.target\.agentId must be a valid agent id/i,
);
const badSessionKey = await rpcReq(ws, "voicewake.routing.set", {
config: {
routes: [{ trigger: "robot wake", target: { sessionKey: "agent::main" } }],
},
});
expect(badSessionKey.ok).toBe(false);
expect(badSessionKey.error?.message ?? "").toMatch(
/config\.routes\[0\]\.target\.sessionKey must be a canonical agent session key/i,
);
const stillStored = await rpcReq<{
config?: { routes?: Array<{ trigger?: string; target?: unknown }> };
}>(ws, "voicewake.routing.get");
expect(stillStored.ok).toBe(true);
expect(stillStored.payload?.config?.routes).toEqual([
{ trigger: "robot wake", target: { agentId: "main" } },
]);
expect(result.ok).toBe(true);
expect(result.payload?.config).toMatchObject({
version: 1,
defaultTarget: { mode: "current" },
routes: [],
});
});
test("pushes voicewake.routing.changed to nodes on connect and on updates", async () => {
await withConnectedNodeEvent("voicewake.routing.changed", async (nodeWs, first) => {
test("pushes voicewake.routing.changed to nodes on connect", async () => {
await withConnectedNodeEvent("voicewake.routing.changed", async (_nodeWs, first) => {
expect(first.event).toBe("voicewake.routing.changed");
expect(
(first.payload as { config?: { routes?: unknown[] } } | undefined)?.config?.routes,
).toStrictEqual([]);
const broadcastP = onceMessage<{
type: "event";
event: string;
payload?: Record<string, unknown> | null;
}>(nodeWs, (o) => o.type === "event" && o.event === "voicewake.routing.changed");
const setRes = await rpcReq(ws, "voicewake.routing.set", {
config: {
defaultTarget: { mode: "current" },
routes: [{ trigger: "hello", target: { sessionKey: "agent:main:main" } }],
},
});
expect(setRes.ok).toBe(true);
const broadcast = await broadcastP;
expect(broadcast.event).toBe("voicewake.routing.changed");
expect(
(broadcast.payload as { config?: { routes?: unknown } } | undefined)?.config?.routes,
).toEqual([{ trigger: "hello", target: { sessionKey: "agent:main:main" } }]);
});
});
@@ -324,18 +324,6 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp
sessionKey: checkpointEntry.sessionKey,
});
const checkpoint = await rpcReq<{
ok: true;
key: string;
checkpoint: { checkpointId: string; preCompaction: { sessionFile?: string } };
}>(ws, "sessions.compaction.get", {
key: "main",
checkpointId: "checkpoint-1",
});
expect(checkpoint.ok).toBe(true);
expect(checkpoint.payload?.checkpoint.checkpointId).toBe("checkpoint-1");
expect(checkpoint.payload?.checkpoint.preCompaction.sessionFile).toBeUndefined();
const sessionManagerOpenSpy = vi.spyOn(SessionManager, "open");
let branched: Awaited<
ReturnType<
@@ -553,18 +541,6 @@ test("sessions.compaction list/get scopes selected global checkpoints to the req
summary: "work checkpoint",
});
const got = await directSessionReq<{
checkpoint?: { checkpointId?: string; summary?: string };
}>(
"sessions.compaction.get",
{ key: "global", agentId: "work", checkpointId: "checkpoint-work" },
{ context: { getRuntimeConfig: () => runtimeConfig } },
);
expect(got.ok).toBe(true);
expect(got.payload?.checkpoint).toMatchObject({
checkpointId: "checkpoint-work",
summary: "work checkpoint",
});
expect(
loadSessionEntry({ agentId: "main", sessionKey: "global", storePath: mainStorePath })
?.sessionId,
+25 -199
View File
@@ -3,15 +3,7 @@
*/
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it, vi } from "vitest";
import {
cancelTalkHandoffTurn,
createTalkHandoff,
endTalkHandoffTurn,
getTalkHandoff,
joinTalkHandoff,
revokeTalkHandoff,
startTalkHandoffTurn,
} from "./talk-handoff.js";
import { createTalkHandoff, getTalkHandoff, revokeTalkHandoff } from "./talk-handoff.js";
const requireRecord = createRequireRecord("record", "expected-label-capitalized");
@@ -34,10 +26,6 @@ function expectFields(
return record;
}
function requireRoom(value: unknown, label = "handoff room"): Record<string, unknown> {
return requireRecord(requireRecord(value, label).room, `${label} room`);
}
function requireEvents(value: unknown, label = "handoff result"): unknown[] {
return requireArray(requireRecord(value, label).events, `${label} events`);
}
@@ -103,22 +91,22 @@ describe("talk handoff store", () => {
});
it("expires handoffs immediately when the creation clock is invalid", () => {
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
const handoff = createTalkHandoff({
sessionKey: "session:main",
ttlMs: 5000,
});
const handoff = (() => {
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
return createTalkHandoff({
sessionKey: "session:main",
ttlMs: 5000,
});
} finally {
dateNow.mockRestore();
}
})();
expect(handoff.createdAt).toBe(0);
expect(handoff.expiresAt).toBe(0);
expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({
ok: false,
reason: "expired",
});
} finally {
dateNow.mockRestore();
}
expect(handoff.createdAt).toBe(0);
expect(handoff.expiresAt).toBe(0);
expect(getTalkHandoff(handoff.id)).toBeUndefined();
expect(revokeTalkHandoff(handoff.id)).toEqual({ revoked: false, events: [] });
});
it("expires handoffs immediately when expiry would exceed Date bounds", () => {
@@ -131,83 +119,23 @@ describe("talk handoff store", () => {
});
expect(handoff.expiresAt).toBe(0);
expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({
ok: false,
reason: "expired",
});
expect(getTalkHandoff(handoff.id)).toBeUndefined();
expect(revokeTalkHandoff(handoff.id)).toEqual({ revoked: false, events: [] });
vi.useRealTimers();
});
it("joins and revokes handoffs with only the bearer token", () => {
it("revokes a handoff and records its final close event", () => {
const handoff = createTalkHandoff({ sessionKey: "session:main" });
expect(joinTalkHandoff(handoff.id, "wrong")).toEqual({
ok: false,
reason: "invalid_token",
});
const join = joinTalkHandoff(handoff.id, handoff.token);
const joinRecord = expectFields(join, "join result", {
ok: true,
});
expectEventFields(requireEvents(joinRecord), 0, { type: "session.ready" });
expectFields(joinRecord.record, "joined record", {
id: handoff.id,
expectFields(getTalkHandoff(handoff.id), "stored handoff", {
roomId: handoff.roomId,
sessionKey: "session:main",
});
expectFields(revokeTalkHandoff(handoff.id), "revoke result", { revoked: true });
expect(joinTalkHandoff(handoff.id, handoff.token)).toEqual({
ok: false,
reason: "not_found",
});
});
it("records managed-room ready, replacement, and close lifecycle events", () => {
const handoff = createTalkHandoff({ sessionKey: "session:main" });
const firstJoin = joinTalkHandoff(handoff.id, handoff.token, { clientId: "conn-1" });
expectFields(firstJoin, "first join", {
ok: true,
});
const firstReady = expectEventFields(requireEvents(firstJoin, "first join"), 0, {
type: "session.ready",
sessionId: handoff.roomId,
});
expect(requireRecord(firstReady.payload, "first ready payload").clientId).toBe("conn-1");
expect(
requireRoom(requireRecord(firstJoin, "first join").record, "first join record")
.activeClientId,
).toBe("conn-1");
const secondJoin = joinTalkHandoff(handoff.id, handoff.token, { clientId: "conn-2" });
expectFields(secondJoin, "second join", {
ok: true,
});
const secondEvents = requireEvents(secondJoin, "second join");
const replaced = expectEventFields(secondEvents, 0, {
type: "session.replaced",
sessionId: handoff.roomId,
});
expectFields(requireRecord(replaced.payload, "replaced payload"), "replaced payload", {
previousClientId: "conn-1",
nextClientId: "conn-2",
});
const ready = expectEventFields(secondEvents, 1, {
type: "session.ready",
sessionId: handoff.roomId,
});
expect(requireRecord(ready.payload, "ready payload").clientId).toBe("conn-2");
expect(
requireRoom(requireRecord(secondJoin, "second join").record, "second join record")
.activeClientId,
).toBe("conn-2");
const revoked = revokeTalkHandoff(handoff.id);
expectFields(revoked, "revoke result", {
revoked: true,
activeClientId: "conn-2",
roomId: handoff.roomId,
});
const closed = expectEventFields(requireEvents(revoked, "revoke result"), 0, {
type: "session.closed",
@@ -215,92 +143,8 @@ describe("talk handoff store", () => {
final: true,
});
expect(requireRecord(closed.payload, "closed payload").reason).toBe("revoked");
});
it("records managed-room turn start, end, and cancellation events", () => {
const handoff = createTalkHandoff({ sessionKey: "session:main" });
joinTalkHandoff(handoff.id, handoff.token, { clientId: "conn-1" });
const start = startTalkHandoffTurn(handoff.id, handoff.token, {
clientId: "conn-1",
turnId: "turn-1",
});
expectFields(start, "turn start", {
ok: true,
turnId: "turn-1",
});
expectEventFields(requireEvents(start, "turn start"), 0, {
type: "turn.started",
turnId: "turn-1",
});
expectFields(
requireRoom(requireRecord(start, "turn start").record, "turn start record"),
"turn room",
{
activeClientId: "conn-1",
activeTurnId: "turn-1",
},
);
const ended = endTalkHandoffTurn(handoff.id, handoff.token);
expectFields(ended, "turn end", {
ok: true,
turnId: "turn-1",
});
expectEventFields(requireEvents(ended, "turn end"), 0, {
type: "turn.ended",
turnId: "turn-1",
final: true,
});
expect(
requireRoom(requireRecord(ended, "turn end").record, "turn end record").activeTurnId,
).toBeUndefined();
expect(cancelTalkHandoffTurn(handoff.id, handoff.token)).toEqual({
ok: false,
reason: "no_active_turn",
});
startTalkHandoffTurn(handoff.id, handoff.token, { turnId: "turn-2" });
const cancelled = cancelTalkHandoffTurn(handoff.id, handoff.token, { reason: "barge-in" });
expectFields(cancelled, "turn cancellation", {
ok: true,
turnId: "turn-2",
});
const cancelledEvent = expectEventFields(requireEvents(cancelled, "turn cancellation"), 0, {
type: "turn.cancelled",
turnId: "turn-2",
final: true,
});
expect(requireRecord(cancelledEvent.payload, "cancelled payload").reason).toBe("barge-in");
});
it("rejects stale managed-room turn completion without clearing the active turn", () => {
const handoff = createTalkHandoff({ sessionKey: "session:main" });
startTalkHandoffTurn(handoff.id, handoff.token, { turnId: "turn-old" });
startTalkHandoffTurn(handoff.id, handoff.token, { turnId: "turn-current" });
expect(endTalkHandoffTurn(handoff.id, handoff.token, { turnId: "turn-old" })).toEqual({
ok: false,
reason: "stale_turn",
});
expect(getTalkHandoff(handoff.id)?.room.talk.activeTurnId).toBe("turn-current");
expect(cancelTalkHandoffTurn(handoff.id, handoff.token, { turnId: "turn-old" })).toEqual({
ok: false,
reason: "stale_turn",
});
expect(getTalkHandoff(handoff.id)?.room.talk.activeTurnId).toBe("turn-current");
expectFields(
endTalkHandoffTurn(handoff.id, handoff.token, { turnId: "turn-current" }),
"current turn end",
{
ok: true,
turnId: "turn-current",
},
);
expect(getTalkHandoff(handoff.id)).toBeUndefined();
expect(revokeTalkHandoff(handoff.id)).toEqual({ revoked: false, events: [] });
});
it("isolates simultaneous handoffs for different sessions on the same host", () => {
@@ -319,32 +163,14 @@ describe("talk handoff store", () => {
expect(first.id).not.toBe(second.id);
expect(first.roomId).not.toBe(second.roomId);
expect(first.token).not.toBe(second.token);
expect(joinTalkHandoff(first.id, second.token)).toEqual({
ok: false,
reason: "invalid_token",
});
expect(joinTalkHandoff(second.id, first.token)).toEqual({
ok: false,
reason: "invalid_token",
});
const firstJoin = joinTalkHandoff(first.id, first.token);
const firstJoinRecord = expectFields(firstJoin, "first join", {
ok: true,
});
expectEventFields(requireEvents(firstJoin), 0, { type: "session.ready" });
expectFields(firstJoinRecord.record, "first joined record", {
expectFields(getTalkHandoff(first.id), "first stored handoff", {
roomId: first.roomId,
sessionKey: "agent:main:first",
channel: "browser",
target: "host:local",
provider: "openai",
});
const secondJoin = joinTalkHandoff(second.id, second.token);
const secondJoinRecord = expectFields(secondJoin, "second join", {
ok: true,
});
expectEventFields(requireEvents(secondJoin), 0, { type: "session.ready" });
expectFields(secondJoinRecord.record, "second joined record", {
expectFields(getTalkHandoff(second.id), "second stored handoff", {
roomId: second.roomId,
sessionKey: "agent:main:second",
channel: "browser",
-191
View File
@@ -7,7 +7,6 @@ import {
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sha256Base64Url } from "../infra/crypto-digest.js";
import { resolveGlobalMap } from "../shared/global-singleton.js";
import { recordTalkObservabilityEvent } from "../talk/observability.js";
@@ -73,17 +72,6 @@ type TalkHandoffCreateResult = TalkHandoffPublicRecord & {
token: string;
};
type TalkHandoffJoinResult =
| {
ok: true;
record: TalkHandoffPublicRecord;
events: TalkEvent[];
replacedClientId?: string;
replacementEvents: TalkEvent[];
activeClientEvents: TalkEvent[];
}
| { ok: false; reason: "not_found" | "expired" | "invalid_token" };
type TalkHandoffRevokeResult = {
revoked: boolean;
roomId?: string;
@@ -91,18 +79,6 @@ type TalkHandoffRevokeResult = {
events: TalkEvent[];
};
export type TalkHandoffTurnResult =
| {
ok: true;
record: TalkHandoffPublicRecord;
turnId: string;
events: TalkEvent[];
}
| {
ok: false;
reason: "not_found" | "expired" | "invalid_token" | "no_active_turn" | "stale_turn";
};
type TalkHandoffRoomState = {
activeClientId?: string;
talk: TalkSessionController;
@@ -163,116 +139,6 @@ export function getTalkHandoff(id: string): TalkHandoffRecord | undefined {
return handoffs.get(id);
}
/** Joins a managed room, replacing any previous active client for that room. */
export function joinTalkHandoff(
id: string,
token: string,
opts: { clientId?: string } = {},
): TalkHandoffJoinResult {
const access = resolveTalkHandoffAccess(id, token);
if (!access.ok) {
return access;
}
const record = access.record;
const previousClientId = record.room.activeClientId;
const events = joinTalkHandoffRoom(record, opts.clientId);
const replacedClientId =
previousClientId && previousClientId !== opts.clientId ? previousClientId : undefined;
const replacementEvents = replacedClientId
? events.filter((event) => event.type === "session.replaced")
: [];
const activeClientEvents = replacedClientId
? events.filter((event) => event.type !== "session.replaced")
: events;
return {
ok: true,
record: toPublicTalkHandoffRecord(record),
events,
replacedClientId,
replacementEvents,
activeClientEvents,
};
}
/** Starts a client turn in a joined managed room. */
export function startTalkHandoffTurn(
id: string,
token: string,
opts: { turnId?: string; clientId?: string } = {},
): TalkHandoffTurnResult {
const access = resolveTalkHandoffAccess(id, token);
if (!access.ok) {
return access;
}
const record = access.record;
if (opts.clientId) {
record.room.activeClientId = opts.clientId;
}
const turnId = normalizeOptionalString(opts.turnId) ?? randomUUID();
const turn = record.room.talk.startTurn({
turnId,
payload: { handoffId: id, roomId: record.roomId, clientId: record.room.activeClientId },
});
return {
ok: true,
record: toPublicTalkHandoffRecord(record),
turnId,
events: turn.event ? [turn.event] : [],
};
}
/** Ends the active managed-room turn and returns the emitted Talk event. */
export function endTalkHandoffTurn(
id: string,
token: string,
opts: { turnId?: string } = {},
): TalkHandoffTurnResult {
const access = resolveTalkHandoffAccess(id, token);
if (!access.ok) {
return access;
}
const record = access.record;
const result = record.room.talk.endTurn({
turnId: normalizeOptionalString(opts.turnId),
payload: { handoffId: id, roomId: record.roomId },
});
if (!result.ok) {
return result;
}
return {
ok: true,
record: toPublicTalkHandoffRecord(record),
turnId: result.turnId,
events: [result.event],
};
}
/** Cancels the active managed-room turn with a client-visible reason. */
export function cancelTalkHandoffTurn(
id: string,
token: string,
opts: { reason?: string; turnId?: string } = {},
): TalkHandoffTurnResult {
const access = resolveTalkHandoffAccess(id, token);
if (!access.ok) {
return access;
}
const record = access.record;
const result = record.room.talk.cancelTurn({
turnId: normalizeOptionalString(opts.turnId),
payload: { handoffId: id, roomId: record.roomId, reason: opts.reason ?? "client-cancelled" },
});
if (!result.ok) {
return result;
}
return {
ok: true,
record: toPublicTalkHandoffRecord(record),
turnId: result.turnId,
events: [result.event],
};
}
/** Revokes a handoff and emits the final room-close event if it existed. */
export function revokeTalkHandoff(id: string): TalkHandoffRevokeResult {
pruneExpiredTalkHandoffs();
@@ -294,11 +160,6 @@ export function revokeTalkHandoff(id: string): TalkHandoffRevokeResult {
};
}
/** Verifies the caller token without exposing the stored token hash. */
function verifyTalkHandoffToken(record: TalkHandoffRecord, token: string): boolean {
return record.tokenHash === hashTalkHandoffToken(token);
}
function normalizeTtlMs(value: number | undefined): number {
if (!Number.isFinite(value) || value === undefined) {
return DEFAULT_TALK_HANDOFF_TTL_MS;
@@ -360,58 +221,6 @@ function createTalkHandoffRoom(params: {
};
}
function resolveTalkHandoffAccess(
id: string,
token: string,
):
| { ok: true; record: TalkHandoffRecord }
| { ok: false; reason: "not_found" | "expired" | "invalid_token" } {
const record = handoffs.get(id);
if (!record) {
return { ok: false, reason: "not_found" };
}
if (!isFutureDateTimestampMs(record.expiresAt)) {
// Expiry emits the same close event as explicit revocation so room clients
// can reconcile state without knowing which cleanup path won the race.
appendTalkHandoffRoomEvent(record, {
type: "session.closed",
payload: { reason: "expired", handoffId: id, roomId: record.roomId },
final: true,
});
handoffs.delete(id);
return { ok: false, reason: "expired" };
}
if (!verifyTalkHandoffToken(record, token)) {
return { ok: false, reason: "invalid_token" };
}
return { ok: true, record };
}
function appendTalkHandoffRoomEvent(record: TalkHandoffRecord, input: TalkEventInput): TalkEvent {
return record.room.talk.emit(input);
}
function joinTalkHandoffRoom(record: TalkHandoffRecord, clientId: string | undefined): TalkEvent[] {
const events: TalkEvent[] = [];
if (record.room.activeClientId && record.room.activeClientId !== clientId) {
events.push(
appendTalkHandoffRoomEvent(record, {
type: "session.replaced",
payload: {
handoffId: record.id,
roomId: record.roomId,
previousClientId: record.room.activeClientId,
nextClientId: clientId,
},
}),
);
}
record.room.activeClientId = clientId;
events.push(
appendTalkHandoffRoomEvent(record, {
type: "session.ready",
payload: { handoffId: record.id, roomId: record.roomId, clientId },
}),
);
return events;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { formatError } from "./server-utils.js";
type TalkConnectionCleanupKind = "browser-control" | "realtime-relay" | "transcription-relay";
export type UnifiedTalkSessionRecord =
type UnifiedTalkSessionRecord =
| {
kind: "realtime-relay";
connId: string;
@@ -13,7 +13,6 @@ import {
rememberUnifiedTalkSession,
} from "./talk-session-registry.js";
import {
cancelTalkTranscriptionRelayTurn,
createTalkTranscriptionRelaySession,
sendTalkTranscriptionRelayAudio,
stopTalkTranscriptionRelaySession,
@@ -775,48 +774,4 @@ describe("talk transcription gateway relay", () => {
).toThrow("Transcription relay session expiry is outside the supported Date range");
expect(provider.createSession).not.toHaveBeenCalled();
});
it("cancels an active transcription turn and closes the provider session", async () => {
let sttRequest: RealtimeTranscriptionSessionCreateRequest | undefined;
const sttSession = createSttSessionMock(async () => {
sttRequest?.onSpeechStart?.();
});
const { events, session } = await createStartedRelaySession(sttSession, {}, (req) => {
sttRequest = req;
});
sttSession.close.mockImplementationOnce(() => {
sttRequest?.onTranscript?.("cancelled provider transcript");
});
cancelTalkTranscriptionRelayTurn({
transcriptionSessionId: session.transcriptionSessionId,
connId: "conn-1",
reason: "barge-in",
});
expect(sttSession.close).toHaveBeenCalledOnce();
const cancelledPayload = findPayloadByTalkEventType(events, "turn.cancelled");
expectRecordFields(cancelledPayload, "cancelled payload", {
transcriptionSessionId: session.transcriptionSessionId,
});
expectTalkEventFields(cancelledPayload, {
type: "turn.cancelled",
turnId: "turn-1",
payload: { reason: "barge-in" },
final: true,
});
const closePayload = findPayloadByType(events, "close");
expectRecordFields(closePayload, "close payload", {
transcriptionSessionId: session.transcriptionSessionId,
type: "close",
reason: "completed",
});
expect(
events.some(
(event) =>
isRecord(event.payload) && event.payload.text === "cancelled provider transcript",
),
).toBe(false);
});
});
-22
View File
@@ -507,25 +507,3 @@ export function stopTalkTranscriptionRelaySession(params: {
throw error;
}
}
/** Cancels the active transcription turn and closes the relay. */
export function cancelTalkTranscriptionRelayTurn(params: {
transcriptionSessionId: string;
connId: string;
reason?: string;
}): void {
const session = getTranscriptionSession(params.transcriptionSessionId, params.connId);
const turnId = ensureTranscriptionTurn(session);
const cancelled = session.talk.cancelTurn({
turnId,
payload: { reason: params.reason ?? "client-cancelled" },
});
broadcastToOwner(session.context, session.connId, {
transcriptionSessionId: session.id,
type: "transcript",
text: "",
final: true,
talkEvent: cancelled.ok ? cancelled.event : undefined,
});
closeTranscriptionSession(session, "completed");
}
+2 -2
View File
@@ -1,5 +1,5 @@
// Renders a session's raw output ring as plain text for terminal.text — an
// agent/LLM affordance that wants readable output, not escape sequences.
// Renders a session's raw output ring as plain text for the agent terminal tool,
// which needs readable output rather than escape sequences.
import { stripAnsiSequences } from "../../../packages/terminal-core/src/ansi.js";
// Built at runtime so the source stays free of literal control characters and
+1 -1
View File
@@ -1,7 +1,7 @@
/** Bounds concurrent shells so a client cannot exhaust host processes. */
export const DEFAULT_MAX_SESSIONS = 24;
/**
* Rolling output retained per session for reattach replay and terminal.text,
* Rolling output retained per session for reattach replay and agent tool reads,
* in UTF-16 code units. The session cap keeps worst-case memory bounded.
*/
export const DEFAULT_SCROLLBACK_CHARS = 256 * 1024;
+1 -25
View File
@@ -1,6 +1,5 @@
// Tests infra store file persistence and recovery.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-utils/temp-dir.js";
@@ -16,11 +15,7 @@ import {
resetDiagnosticEventsForTest,
} from "./diagnostic-events.js";
import { readSessionStoreJson5 } from "./state-migrations.fs.js";
import {
loadVoiceWakeRoutingConfig,
resolveVoiceWakeRouteByTrigger,
setVoiceWakeRoutingConfig,
} from "./voicewake-routing.js";
import { loadVoiceWakeRoutingConfig, resolveVoiceWakeRouteByTrigger } from "./voicewake-routing.js";
import {
defaultVoiceWakeTriggers,
loadVoiceWakeConfig,
@@ -129,25 +124,6 @@ describe("infra store", () => {
});
describe("voicewake routing store", () => {
it("normalizes and persists routing config", async () => {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-routing-"));
const saved = await setVoiceWakeRoutingConfig(
{
defaultTarget: { mode: "current" },
routes: [
{ trigger: " Hello Bot ", target: { agentId: "main" } },
{ trigger: "", target: { sessionKey: "agent:main:main" } },
],
},
baseDir,
);
expect(saved.routes).toEqual([{ trigger: "hello bot", target: { agentId: "main" } }]);
expect(saved.updatedAtMs).toBeGreaterThan(0);
const loaded = await loadVoiceWakeRoutingConfig(baseDir);
expect(loaded.routes).toEqual([{ trigger: "hello bot", target: { agentId: "main" } }]);
});
it("resolves routes by normalized trigger", () => {
expect(
resolveVoiceWakeRouteByTrigger({
+19 -9
View File
@@ -4,10 +4,7 @@ import {
resetGatewayWorkAdmission,
tryBeginGatewayRootWorkAdmission,
} from "../process/gateway-work-admission.js";
import {
createSafeGatewayRestartPreflight,
requestSafeGatewayRestart,
} from "./restart-coordinator.js";
import { requestSafeGatewayRestart } from "./restart-coordinator.js";
const scheduleGatewaySigusr1Restart = vi.hoisted(() => vi.fn());
@@ -17,6 +14,15 @@ vi.mock("./restart.js", () => ({
beforeEach(() => {
resetGatewayWorkAdmission();
scheduleGatewaySigusr1Restart.mockReset().mockReturnValue({
ok: true,
pid: 123,
signal: "SIGUSR1",
delayMs: 0,
mode: "emit",
coalesced: false,
cooldownMsApplied: 0,
});
});
afterEach(() => {
@@ -24,8 +30,12 @@ afterEach(() => {
});
describe("safe gateway restart coordinator", () => {
const requestPreflight = (
inspect: NonNullable<Parameters<typeof requestSafeGatewayRestart>[0]>["inspect"],
) => requestSafeGatewayRestart({ inspect }).preflight;
it("reports safe when no restart blockers are active", () => {
const preflight = createSafeGatewayRestartPreflight({
const preflight = requestPreflight({
getQueueSize: () => 0,
getPendingReplies: () => 0,
getEmbeddedRuns: () => 0,
@@ -54,7 +64,7 @@ describe("safe gateway restart coordinator", () => {
});
it("returns structured blockers for active work", () => {
const preflight = createSafeGatewayRestartPreflight({
const preflight = requestPreflight({
getQueueSize: () => 2,
getPendingReplies: () => 1,
getEmbeddedRuns: () => 1,
@@ -89,7 +99,7 @@ describe("safe gateway restart coordinator", () => {
});
it("defers restart for aggregate background exec sessions", () => {
const preflight = createSafeGatewayRestartPreflight({
const preflight = requestPreflight({
getQueueSize: () => 0,
getPendingReplies: () => 0,
getEmbeddedRuns: () => 0,
@@ -123,7 +133,7 @@ describe("safe gateway restart coordinator", () => {
try {
await request?.run(async () => {
const preflight = createSafeGatewayRestartPreflight({
const preflight = requestPreflight({
getQueueSize: () => 0,
getPendingReplies: () => 0,
getEmbeddedRuns: () => 0,
@@ -149,7 +159,7 @@ describe("safe gateway restart coordinator", () => {
});
it("keeps truncated task titles on complete UTF-16 code points", () => {
const preflight = createSafeGatewayRestartPreflight({
const preflight = requestPreflight({
getQueueSize: () => 0,
getPendingReplies: () => 0,
getEmbeddedRuns: () => 0,
+1 -1
View File
@@ -55,7 +55,7 @@ export type SafeGatewayRestartRequestResult = {
restart: ScheduledRestart;
};
export function createSafeGatewayRestartPreflight(
function createSafeGatewayRestartPreflight(
inspectors: Partial<SafeRestartInspectors> = {},
): SafeGatewayRestartPreflight {
const snapshot = createGatewayActiveWorkSnapshot({
+43 -22
View File
@@ -20,6 +20,7 @@ import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
OPENCLAW_STATE_SCHEMA_VERSION,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
@@ -51,7 +52,7 @@ import {
migrateLegacyCurrentConversationBindings,
migrateLegacyPluginBindingApprovals,
} from "./state-migrations.runtime-state.js";
import { loadVoiceWakeRoutingConfig, setVoiceWakeRoutingConfig } from "./voicewake-routing.js";
import { loadVoiceWakeRoutingConfig } from "./voicewake-routing.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "./voicewake.js";
type DetectLegacyStateParams = Parameters<typeof detectLegacyStateMigrationsWithSurfaces>[0];
@@ -449,6 +450,44 @@ function createEnv(stateDir: string): NodeJS.ProcessEnv {
};
}
type VoiceWakeRoutingTestDatabase = Pick<
OpenClawStateKyselyDatabase,
"voicewake_routing_config" | "voicewake_routing_routes"
>;
function seedCanonicalVoiceWakeRouting(stateDir: string, trigger: string): void {
const updatedAtMs = Date.now();
runOpenClawStateWriteTransaction(
({ db }) => {
const routingDb = getNodeSqliteKysely<VoiceWakeRoutingTestDatabase>(db);
executeSqliteQuerySync(
db,
routingDb.insertInto("voicewake_routing_config").values({
config_key: "default",
version: 1,
default_target_mode: "current",
default_target_agent_id: null,
default_target_session_key: null,
updated_at_ms: updatedAtMs,
}),
);
executeSqliteQuerySync(
db,
routingDb.insertInto("voicewake_routing_routes").values({
config_key: "default",
position: 0,
trigger,
target_mode: "agent",
target_agent_id: "main",
target_session_key: null,
updated_at_ms: updatedAtMs,
}),
);
},
{ env: createEnv(stateDir) },
);
}
type MixedCommitFailureFixture = {
env: NodeJS.ProcessEnv;
expectedWarning: string;
@@ -2327,13 +2366,7 @@ describe("state migrations", () => {
const triggersPath = path.join(settingsDir, "voicewake.json");
const routingPath = path.join(settingsDir, "voicewake-routing.json");
await setVoiceWakeTriggers(["wake"], stateDir);
await setVoiceWakeRoutingConfig(
{
defaultTarget: { mode: "current" },
routes: [{ trigger: "robot wake", target: { agentId: "main" } }],
},
stateDir,
);
seedCanonicalVoiceWakeRouting(stateDir, "robot wake");
await fs.mkdir(settingsDir, { recursive: true });
await fs.writeFile(triggersPath, JSON.stringify({ triggers: ["wake"] }), "utf8");
await fs.writeFile(
@@ -2446,13 +2479,7 @@ describe("state migrations", () => {
const stateDir = path.join(root, ".openclaw");
const cfg = createConfig();
const routingPath = path.join(stateDir, "settings", "voicewake-routing.json");
await setVoiceWakeRoutingConfig(
{
defaultTarget: { mode: "current" },
routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }],
},
stateDir,
);
seedCanonicalVoiceWakeRouting(stateDir, "sqlite wake");
await fs.mkdir(path.dirname(routingPath), { recursive: true });
await fs.writeFile(
routingPath,
@@ -2486,13 +2513,7 @@ describe("state migrations", () => {
const stateDir = path.join(root, ".openclaw");
const cfg = createConfig();
const routingPath = path.join(stateDir, "settings", "voicewake-routing.json");
await setVoiceWakeRoutingConfig(
{
defaultTarget: { mode: "current" },
routes: [{ trigger: "sqlite wake", target: { agentId: "main" } }],
},
stateDir,
);
seedCanonicalVoiceWakeRouting(stateDir, "sqlite wake");
await fs.mkdir(path.dirname(routingPath), { recursive: true });
await fs.writeFile(
routingPath,
+1 -79
View File
@@ -1,9 +1,8 @@
// Covers voice wake routing normalization and validation.
// Covers voice wake routing normalization and resolution.
import { describe, expect, it } from "vitest";
import {
normalizeVoiceWakeRoutingConfig,
resolveVoiceWakeRouteByTrigger,
validateVoiceWakeRoutingConfigInput,
} from "./voicewake-routing.js";
describe("voicewake routing normalization", () => {
@@ -25,81 +24,4 @@ describe("voicewake routing normalization", () => {
sessionKey: "agent:main:voice",
});
});
it("rejects invalid route agent ids instead of normalizing them to main", () => {
expect(
validateVoiceWakeRoutingConfigInput({
routes: [{ trigger: "wake", target: { agentId: "!!!" } }],
}),
).toEqual({
ok: false,
message: "config.routes[0].target.agentId must be a valid agent id",
});
});
it("rejects malformed session keys instead of persisting dead routes", () => {
expect(
validateVoiceWakeRoutingConfigInput({
routes: [{ trigger: "wake", target: { sessionKey: "agent::main" } }],
}),
).toEqual({
ok: false,
message: "config.routes[0].target.sessionKey must be a canonical agent session key",
});
});
it("rejects session keys with empty path segments", () => {
expect(
validateVoiceWakeRoutingConfigInput({
routes: [{ trigger: "wake", target: { sessionKey: "agent:main:main:" } }],
}),
).toEqual({
ok: false,
message: "config.routes[0].target.sessionKey must be a canonical agent session key",
});
});
it("rejects duplicate triggers after normalization", () => {
expect(
validateVoiceWakeRoutingConfigInput({
routes: [
{ trigger: "Hey Bot", target: { mode: "current" } },
{ trigger: "hey, bot", target: { agentId: "main" } },
],
}),
).toEqual({
ok: false,
message: "config.routes[1].trigger duplicates config.routes[0].trigger after normalization",
});
});
it("rejects oversized route lists", () => {
expect(
validateVoiceWakeRoutingConfigInput({
routes: Array.from({ length: 33 }, (_, index) => ({
trigger: `wake ${index}`,
target: { mode: "current" as const },
})),
}),
).toEqual({
ok: false,
message: "config.routes must contain at most 32 entries",
});
});
it("rejects oversized triggers", () => {
expect(
validateVoiceWakeRoutingConfigInput({
routes: [
{
trigger: "x".repeat(65),
target: { mode: "current" as const },
},
],
}),
).toEqual({
ok: false,
message: "config.routes[0].trigger must be at most 64 characters",
});
});
});
+2 -222
View File
@@ -1,16 +1,8 @@
// Persists and resolves voice wake routing rules.
import { isRecord as isPlainObject } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
classifySessionKeyShape,
isValidAgentId,
normalizeAgentId,
} from "../routing/session-key.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@@ -36,8 +28,6 @@ export type VoiceWakeRoutingConfig = {
updatedAtMs: number;
};
const MAX_VOICEWAKE_ROUTES = 32;
const MAX_VOICEWAKE_TRIGGER_LENGTH = 64;
const VOICEWAKE_ROUTING_CONFIG_KEY = "default";
const DEFAULT_ROUTING: VoiceWakeRoutingConfig = {
@@ -108,139 +98,6 @@ function normalizeRouteRule(value: unknown): VoiceWakeRouteRule | null {
return { trigger, target };
}
function isCanonicalAgentSessionKey(value: string): boolean {
const trimmed = value.trim();
if (classifySessionKeyShape(trimmed) !== "agent") {
return false;
}
return !trimmed.split(":").some((part) => part.length === 0);
}
function validateRouteTargetInput(
value: unknown,
label: string,
): { ok: true } | { ok: false; message: string } {
if (!isPlainObject(value)) {
return { ok: false, message: `${label} must be an object` };
}
const rec = value as { mode?: unknown; agentId?: unknown; sessionKey?: unknown };
const mode = normalizeOptionalString(rec.mode);
const agentId = normalizeOptionalString(rec.agentId);
const sessionKey = normalizeOptionalString(rec.sessionKey);
if (mode !== undefined) {
if (mode !== "current") {
return {
ok: false,
message: `${label}.mode must be "current" when provided`,
};
}
if (agentId !== undefined || sessionKey !== undefined) {
return {
ok: false,
message: `${label} cannot mix mode with agentId or sessionKey`,
};
}
return { ok: true };
}
if (agentId !== undefined && sessionKey !== undefined) {
return {
ok: false,
message: `${label} cannot include both agentId and sessionKey`,
};
}
if (agentId !== undefined) {
if (!isValidAgentId(agentId)) {
return {
ok: false,
message: `${label}.agentId must be a valid agent id`,
};
}
return { ok: true };
}
if (sessionKey !== undefined) {
if (!isCanonicalAgentSessionKey(sessionKey)) {
return {
ok: false,
message: `${label}.sessionKey must be a canonical agent session key`,
};
}
return { ok: true };
}
return {
ok: false,
message: `${label} must include mode, agentId, or sessionKey`,
};
}
/** Validate user-provided voice wake routing config before persistence. */
export function validateVoiceWakeRoutingConfigInput(
input: unknown,
): { ok: true } | { ok: false; message: string } {
if (!isPlainObject(input)) {
return { ok: false, message: "config must be an object" };
}
const rec = input as {
defaultTarget?: unknown;
routes?: unknown;
};
if (rec.defaultTarget !== undefined) {
const validatedDefaultTarget = validateRouteTargetInput(
rec.defaultTarget,
"config.defaultTarget",
);
if (!validatedDefaultTarget.ok) {
return validatedDefaultTarget;
}
}
if (rec.routes !== undefined && !Array.isArray(rec.routes)) {
return { ok: false, message: "config.routes must be an array" };
}
if (Array.isArray(rec.routes)) {
if (rec.routes.length > MAX_VOICEWAKE_ROUTES) {
return {
ok: false,
message: `config.routes must contain at most ${MAX_VOICEWAKE_ROUTES} entries`,
};
}
const normalizedTriggers = new Map<string, number>();
for (const [index, route] of rec.routes.entries()) {
if (!isPlainObject(route)) {
return { ok: false, message: `config.routes[${index}] must be an object` };
}
const trigger = normalizeOptionalString(route.trigger);
const normalizedTrigger = trigger ? normalizeVoiceWakeTriggerWord(trigger) : "";
if (!trigger || !normalizedTrigger) {
return {
ok: false,
message: `config.routes[${index}].trigger must be a non-empty string`,
};
}
if (trigger.length > MAX_VOICEWAKE_TRIGGER_LENGTH) {
return {
ok: false,
message: `config.routes[${index}].trigger must be at most ${MAX_VOICEWAKE_TRIGGER_LENGTH} characters`,
};
}
const duplicateIndex = normalizedTriggers.get(normalizedTrigger);
if (duplicateIndex !== undefined) {
return {
ok: false,
message: `config.routes[${index}].trigger duplicates config.routes[${duplicateIndex}].trigger after normalization`,
};
}
normalizedTriggers.set(normalizedTrigger, index);
const validatedTarget = validateRouteTargetInput(
route.target,
`config.routes[${index}].target`,
);
if (!validatedTarget.ok) {
return validatedTarget;
}
}
}
return { ok: true };
}
/** Normalize persisted or user-provided voice wake routing config. */
export function normalizeVoiceWakeRoutingConfig(input: unknown): VoiceWakeRoutingConfig {
if (!input || typeof input !== "object") {
@@ -270,20 +127,6 @@ export function normalizeVoiceWakeRoutingConfig(input: unknown): VoiceWakeRoutin
};
}
function targetColumns(target: VoiceWakeRouteTarget): {
targetAgentId: string | null;
targetMode: string;
targetSessionKey: string | null;
} {
if ("agentId" in target && target.agentId) {
return { targetAgentId: target.agentId, targetMode: "agent", targetSessionKey: null };
}
if ("sessionKey" in target && target.sessionKey) {
return { targetAgentId: null, targetMode: "session", targetSessionKey: target.sessionKey };
}
return { targetAgentId: null, targetMode: "current", targetSessionKey: null };
}
function targetFromColumns(params: {
agentId: string | null;
mode: string;
@@ -341,69 +184,6 @@ export async function loadVoiceWakeRoutingConfig(
};
}
/** Persist normalized voice wake routing config. */
export async function setVoiceWakeRoutingConfig(
config: unknown,
baseDir?: string,
): Promise<VoiceWakeRoutingConfig> {
const normalized = normalizeVoiceWakeRoutingConfig(config);
const updatedAtMs = Date.now();
const next: VoiceWakeRoutingConfig = {
...normalized,
updatedAtMs,
};
runOpenClawStateWriteTransaction(
({ db }) => {
const routingDb = getNodeSqliteKysely<VoiceWakeRoutingDatabase>(db);
executeSqliteQuerySync(
db,
routingDb
.deleteFrom("voicewake_routing_routes")
.where("config_key", "=", VOICEWAKE_ROUTING_CONFIG_KEY),
);
executeSqliteQuerySync(
db,
routingDb
.deleteFrom("voicewake_routing_config")
.where("config_key", "=", VOICEWAKE_ROUTING_CONFIG_KEY),
);
const defaultTarget = targetColumns(next.defaultTarget);
executeSqliteQuerySync(
db,
routingDb.insertInto("voicewake_routing_config").values({
config_key: VOICEWAKE_ROUTING_CONFIG_KEY,
version: 1,
default_target_mode: defaultTarget.targetMode,
default_target_agent_id: defaultTarget.targetAgentId,
default_target_session_key: defaultTarget.targetSessionKey,
updated_at_ms: updatedAtMs,
}),
);
if (next.routes.length > 0) {
executeSqliteQuerySync(
db,
routingDb.insertInto("voicewake_routing_routes").values(
next.routes.map((route, position) => {
const target = targetColumns(route.target);
return {
config_key: VOICEWAKE_ROUTING_CONFIG_KEY,
position,
trigger: route.trigger,
target_mode: target.targetMode,
target_agent_id: target.targetAgentId,
target_session_key: target.targetSessionKey,
updated_at_ms: updatedAtMs,
};
}),
),
);
}
},
baseDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: baseDir } } : {},
);
return next;
}
type VoiceWakeResolvedRoute = { mode: "current" } | { agentId: string } | { sessionKey: string };
function resolveVoiceWakeRouteTarget(