diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 9cb8ee208ffa..f41b6f6a72a4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -3847,6 +3847,12 @@ class ChatController internal constructor( } } + "session.narration" -> { + // Native foreground subscriptions use full streams; bounded narration + // tails cannot replace transcript messages. + return + } + else -> { if (payloadJson.isNullOrBlank()) return when (event) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index d5a6b002adb0..94bf59f76818 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -991,6 +991,7 @@ enum class GatewayEvent( UiCommand("ui.command"), SessionApproval("session.approval"), SessionMessage("session.message"), + SessionNarration("session.narration"), SessionObserver("session.observer"), SessionOperation("session.operation"), SessionSharing("session.sharing"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 77a5965bd31c..7ffcbc8c2155 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -199,6 +199,10 @@ public enum OpenClawChatGatewayPayloadCodec { return .modelSelectionChanged case "sessions.changed": return decode(OpenClawChatSessionsChangedEvent.self).map(OpenClawChatTransportEvent.sessionsChanged) + case "session.narration": + // Native foreground subscriptions use full streams; bounded narration + // tails cannot replace transcript messages. + return nil case "session.observer": return decode(SessionObserverDigest.self).map(OpenClawChatTransportEvent.sessionObserver) case "seqGap": diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 8859230db99e..5f55399f5a26 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -15147,6 +15147,32 @@ public struct SessionMembersListResult: Codable, Sendable { } } +public struct SessionNarrationEvent: Codable, Sendable { + public let sessionkey: String + public let agentid: String? + public let runid: String + public let text: String + + public init( + sessionkey: String, + agentid: String? = nil, + runid: String, + text: String) + { + self.sessionkey = sessionkey + self.agentid = agentid + self.runid = runid + self.text = text + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + case runid = "runId" + case text + } +} + public struct SessionObserverDigest: Codable, Sendable { public let sessionkey: String public let agentid: String? @@ -17875,21 +17901,29 @@ public struct SessionsListParams: Codable, Sendable { public struct SessionsMessagesSubscribeParams: Codable, Sendable { public let key: String public let agentid: String? + public let subscriptionid: String? + public let mode: String? public let includeapprovals: Bool? public init( key: String, agentid: String? = nil, + subscriptionid: String? = nil, + mode: String? = nil, includeapprovals: Bool? = nil) { self.key = key self.agentid = agentid + self.subscriptionid = subscriptionid + self.mode = mode self.includeapprovals = includeapprovals } private enum CodingKeys: String, CodingKey { case key case agentid = "agentId" + case subscriptionid = "subscriptionId" + case mode case includeapprovals = "includeApprovals" } } @@ -17897,18 +17931,22 @@ public struct SessionsMessagesSubscribeParams: Codable, Sendable { public struct SessionsMessagesUnsubscribeParams: Codable, Sendable { public let key: String public let agentid: String? + public let subscriptionid: String? public init( key: String, - agentid: String? = nil) + agentid: String? = nil, + subscriptionid: String? = nil) { self.key = key self.agentid = agentid + self.subscriptionid = subscriptionid } private enum CodingKeys: String, CodingKey { case key case agentid = "agentId" + case subscriptionid = "subscriptionId" } } diff --git a/docs/gateway/protocol/rpc-bootstrap-and-events.md b/docs/gateway/protocol/rpc-bootstrap-and-events.md index 46a4ed796cf3..c5a6c91942cf 100644 --- a/docs/gateway/protocol/rpc-bootstrap-and-events.md +++ b/docs/gateway/protocol/rpc-bootstrap-and-events.md @@ -62,6 +62,64 @@ exceed the shared page size. Use `nextOffset` to advance and deduplicate rows by session key across pages; do not derive the next offset from the displayed row count. +## Session message subscriptions and narration + +`sessions.messages.subscribe` subscribes one connection to a session's live +messages. Its `key` and optional `agentId` select the session; this is separate +from the broad roster subscription above. Omit `mode` for full `chat` and `agent` +streams, including foreground transcripts and passive views of runs started by +another client. Repeating a request replaces that observer's subscription mode. +`sessions.messages.unsubscribe` removes the observer identified by the same +optional `subscriptionId`; omission selects the legacy observer. +The subscribe acknowledgment includes the canonical `key` and resolved `agentId`. +Clients retain the resolved owner for later `global` requests, whose key alone +does not identify an agent. The SDK sends a stable opaque `subscriptionId` for +each wire observer and includes it in resubscriptions and unsubscribe requests. +For multiple IDs on one connection and session, full-stream interest takes +precedence until its last owner releases; approvals remain enabled while any +owner requests them. Omitting the ID retains the legacy single-observer behavior. +Older clients remain compatible with the updated Gateway; the updated SDK's +ownership fields require an updated Gateway. + +Background narration consumers declare `mode: "narration"`. The Gateway replaces +their token-level `chat` deltas and raw `agent` assistant events with +`session.narration` snapshots. Each snapshot contains `sessionKey`, optional +`agentId`, `runId`, and `text`: at most 16,384 characters of the current visible +assistant tail. Hidden reasoning and internal context are removed before the +tail is bounded. An empty `text` retracts the previous narration. Consumers can +derive a compact line from this text without reconstructing token deltas. + +The first text update can arrive immediately. Subsequent snapshots arrive at +most once every two seconds per session per connection, using the latest text +without postponing the pending deadline. Terminal chat events flush the last +snapshot immediately before the terminal event, including final text corrections +or retractions; this final flush is exempt from the two-second interval. +Newer tool activity or a change of run discards pending older text, so a delayed +snapshot cannot replace a newer tool line or switch the sidebar back to an older run. +Lifecycle, status, tool, final, abort, and error events retain their existing +delivery. Raw thinking streams and in-progress preamble or answer-candidate text +are omitted; item completion and answer selection still arrive. Approval events +still require `includeApprovals: true` and the normal +approval authority. Queued narration is discarded on unsubscribe, mode changes, +connection retirement, or run retirement, and delivery rechecks current access. + +The Gateway client SDK shares matching session addresses among local owners. +The Gateway also combines independently identified observers that resolve to the +same subscription key. If any owner requires full streams, delivery remains +full; it returns to narration only after the last full owner releases it. +Narration consumers sharing a foreground subscription must also accept full +stream events. The Control UI waits for foreground admission before fetching +history, so the snapshot covers activity emitted before full streams were enabled. + +The bundled Control UI declares narration intent for sidebar interests. It is +version-locked to its Gateway and reloads on upgrade. Shared Apple chat clients +use the default full mode for foreground sessions; Android and the TUI retain +their broad event delivery. Existing SDK callers and older clients that omit +`mode` retain full streams. Custom UI roots, development UIs, and cross-origin +UIs exempt from build admission can therefore retain full-stream narration until +updated. This is an additive protocol-v4 contract, with no capability negotiation +or protocol-version change. + ## Common event families - `chat`: UI chat updates such as `chat.inject` and other transcript-only chat @@ -107,6 +165,8 @@ count. agent text separately from cumulative chat text should not advertise it. - `session.message`, `session.operation`, `session.tool`: transcript, in-flight session operation, and event-stream updates for a subscribed session. +- `session.narration`: bounded assistant-text snapshots for subscriptions with + narration intent, paced and settled as described above. - `session.approval`: sanitized pending and terminal approval truth for an explicitly opted-in exact-session subscriber. Child approvals use the persisted ancestor audience; events never mutate transcripts or wake agents. diff --git a/packages/gateway-client/src/session-subscriptions.narration.test.ts b/packages/gateway-client/src/session-subscriptions.narration.test.ts new file mode 100644 index 000000000000..a2fe97be6915 --- /dev/null +++ b/packages/gateway-client/src/session-subscriptions.narration.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import { GatewayProtocolRequestTimeoutError } from "./protocol-request.js"; +import { + GatewaySessionMessageSubscriptionCoordinator, + type GatewaySessionMessageSubscriptionOptions, +} from "./session-subscriptions.js"; +import { createClient } from "./session-subscriptions.test-support.js"; +import { DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS } from "./timeouts.js"; + +describe("session narration subscription ownership", () => { + it.each([ + { key: "agent:main:foo", firstAgent: undefined, secondAgent: "main", owners: 2 }, + { key: "agent:main:foo", firstAgent: "main", secondAgent: undefined, owners: 2 }, + { key: "agent:main:foo", firstAgent: "MAIN", secondAgent: "main", owners: 1 }, + { key: "global", firstAgent: undefined, secondAgent: "main", owners: 2 }, + { key: "global", firstAgent: "main", secondAgent: undefined, owners: 2 }, + ])( + "retains independent wire ownership for $key ($firstAgent → $secondAgent)", + async ({ key, firstAgent, secondAgent, owners }) => { + const acknowledged = createDeferred(); + const { client, request } = createClient(async (method) => { + if (method === "sessions.messages.subscribe") { + await acknowledged.promise; + return { key, agentId: "main" }; + } + return {}; + }); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const first = coordinator.acquire(key, { agentId: firstAgent }); + const second = coordinator.acquire(key, { agentId: secondAgent, mode: "narration" }); + expect(request).toHaveBeenCalledTimes(owners); + const ids = request.mock.calls.map(([, params]) => params.subscriptionId); + expect(new Set(ids).size).toBe(owners); + expect(ids.every((id) => typeof id === "string" && id.length > 0)).toBe(true); + acknowledged.resolve(); + const [foreground, narration] = await Promise.all([first, second]); + await coordinator.release(foreground); + expect(request.mock.lastCall?.[0]).toBe( + owners === 1 ? "sessions.messages.subscribe" : "sessions.messages.unsubscribe", + ); + expect(request.mock.lastCall?.[1].subscriptionId).toBe(ids[0]); + if (owners === 1) { + expect(request.mock.lastCall?.[1].mode).toBe("narration"); + } + await coordinator.release(narration); + expect(request.mock.lastCall?.[0]).toBe("sessions.messages.unsubscribe"); + expect(request.mock.lastCall?.[1].subscriptionId).toBe(ids.at(-1)); + }, + ); + + it("keeps an implicit global owner separate from another agent's acknowledged global alias", async () => { + const { client, request } = createClient(async (_method, params) => ({ + key: "global", + agentId: params.key === "agent:research:main" ? "research" : "main", + })); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const research = await coordinator.acquire("agent:research:main"); + const main = await coordinator.acquire("global", { mode: "narration" }); + expect( + request.mock.calls.filter(([method]) => method === "sessions.messages.subscribe"), + ).toEqual([ + [ + "sessions.messages.subscribe", + { subscriptionId: expect.any(String), key: "agent:research:main" }, + ], + [ + "sessions.messages.subscribe", + { subscriptionId: expect.any(String), key: "global", mode: "narration" }, + ], + ]); + await coordinator.release(main); + await coordinator.release(research); + expect( + request.mock.calls.filter(([method]) => method === "sessions.messages.unsubscribe"), + ).toEqual([ + [ + "sessions.messages.unsubscribe", + { subscriptionId: expect.any(String), key: "global", agentId: "main" }, + ], + [ + "sessions.messages.unsubscribe", + { subscriptionId: expect.any(String), key: "global", agentId: "research" }, + ], + ]); + }); + + it("does not reuse a qualified observer for a conflicting explicit agent", async () => { + const { client, request } = createClient(async (_method, params) => { + if (params.agentId === "other") { + throw new Error("agent does not match session key agent"); + } + return { key: "agent:main:foo", agentId: "main" }; + }); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const foreground = await coordinator.acquire("agent:main:foo"); + await expect( + coordinator.acquire("agent:main:foo", { agentId: "other", mode: "narration" }), + ).rejects.toThrow("does not match"); + await coordinator.release(foreground); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "agent:main:foo", + }); + }); + + it("uses distinct stable IDs for raw and literal global sessions across coordinators", async () => { + const { client, request } = createClient(async (_method, params) => ({ + key: params.key, + agentId: "main", + })); + const first = new GatewaySessionMessageSubscriptionCoordinator(client); + const second = new GatewaySessionMessageSubscriptionCoordinator({ + request: client.request.bind(client), + }); + const foreground = await first.acquire("global", { agentId: "main" }); + const narration = await first.acquire("agent:main:global", { mode: "narration" }); + const independent = await second.acquire("global", { agentId: "main", mode: "narration" }); + const ids = request.mock.calls.map(([, params]) => params.subscriptionId); + expect(new Set(ids).size).toBe(3); + await first.release(narration); + expect(request.mock.lastCall?.[1].subscriptionId).toBe(ids[1]); + await second.release(independent); + expect(request.mock.lastCall?.[1].subscriptionId).toBe(ids[2]); + await first.release(foreground); + expect(request.mock.lastCall?.[1].subscriptionId).toBe(ids[0]); + }); + + it("retains acquisition intent when the caller mutates options before acknowledgment", async () => { + const acknowledged = createDeferred(); + const { client, request } = createClient(); + request.mockImplementationOnce(async () => acknowledged.promise); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const options: GatewaySessionMessageSubscriptionOptions = {}; + const pending = coordinator.acquire("main", options); + options.mode = "narration"; + options.includeApprovals = true; + acknowledged.resolve({ key: "main" }); + const foreground = await pending; + expect(foreground).toEqual({ key: "main", agentId: null }); + expect(request).toHaveBeenCalledExactlyOnceWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + }); + + const narration = await coordinator.acquire("main", { mode: "narration" }); + await coordinator.release(foreground); + expect(request).toHaveBeenLastCalledWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + mode: "narration", + }); + await coordinator.release(narration); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); + }); + + it.each([false, true])( + "keeps full streams until the last foreground owner releases (narration first: %s)", + async (narrationFirst) => { + const { client, request } = createClient(); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const narration = narrationFirst + ? await coordinator.acquire("main", { mode: "narration" }) + : null; + if (narrationFirst) { + expect(request).toHaveBeenLastCalledWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + mode: "narration", + }); + } + const foreground = await coordinator.acquire("main", { includeApprovals: true }); + const background = narration ?? (await coordinator.acquire("main", { mode: "narration" })); + const secondForeground = await coordinator.acquire("main"); + expect(request).toHaveBeenLastCalledWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + includeApprovals: true, + }); + const fullRequests = request.mock.calls.length; + await coordinator.release(foreground); + expect(request).toHaveBeenCalledTimes(fullRequests); + await coordinator.release(secondForeground); + expect(request).toHaveBeenLastCalledWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + mode: "narration", + includeApprovals: true, + }); + await coordinator.release(background); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); + }, + ); + + it.each([false, true])( + "settles an in-flight foreground acquire before releasing the last full owner (reject: %s)", + async (reject) => { + const approval = createDeferred(); + const requested = createDeferred(); + const { client, request } = createClient(async (_method, params) => { + if (params.includeApprovals) { + requested.resolve(); + return approval.promise; + } + return { key: params.key }; + }); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const foreground = await coordinator.acquire("main"); + const narration = await coordinator.acquire("main", { mode: "narration" }); + const nextForeground = coordinator.acquire("main", { includeApprovals: true }); + const outcome = nextForeground.then( + (handle) => handle, + () => null, + ); + await requested.promise; + const released = coordinator.release(foreground); + expect(request).toHaveBeenCalledTimes(2); + if (reject) { + approval.reject(new Error("approval replay unavailable")); + } else { + approval.resolve({ key: "main", approvalReplay: { approvals: [] } }); + } + const next = await outcome; + await released; + if (next) { + expect(request).toHaveBeenCalledTimes(2); + await coordinator.release(next); + } + expect(request.mock.lastCall?.[1].mode).toBe("narration"); + await coordinator.release(narration); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); + }, + ); + + it("serializes narration approval replay before acknowledging a full-stream upgrade", async () => { + const approval = createDeferred(); + const requested = createDeferred(); + let holdReplay = true; + let wireMode: unknown; + let wireApprovals = false; + const { client } = createClient(async (_method, params) => { + if (params.includeApprovals && holdReplay) { + holdReplay = false; + requested.resolve(); + await approval.promise; + } + wireMode = params.mode; + wireApprovals = params.includeApprovals === true; + return { key: params.key, approvalReplay: { approvals: [] } }; + }); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const narration = await coordinator.acquire("main", { mode: "narration" }); + const approvalNarration = coordinator.acquire("main", { + mode: "narration", + includeApprovals: true, + }); + await requested.promise; + const foreground = coordinator.acquire("main"); + approval.resolve({}); + const [approvalOwner, fullOwner] = await Promise.all([approvalNarration, foreground]); + expect(wireMode).toBeUndefined(); + expect(wireApprovals).toBe(true); + await coordinator.release(fullOwner); + expect(wireMode).toBe("narration"); + expect(wireApprovals).toBe(true); + await coordinator.release(narration); + await coordinator.release(approvalOwner); + }); + + it("restores a timed-out downgrade and drains overlapping releases without orphaning narration", async () => { + const downgrade = createDeferred(); + let holdDowngrade = false; + const { client, request } = createClient(async (_method, params) => { + if (params.mode === "narration" && holdDowngrade) { + holdDowngrade = false; + return downgrade.promise; + } + return { key: params.key }; + }); + const coordinator = new GatewaySessionMessageSubscriptionCoordinator(client); + const foreground = await coordinator.acquire("main"); + const narration = await coordinator.acquire("main", { mode: "narration" }); + holdDowngrade = true; + const firstRelease = coordinator.release(foreground); + const failedRelease = expect(firstRelease).rejects.toBeInstanceOf( + GatewayProtocolRequestTimeoutError, + ); + downgrade.reject( + new GatewayProtocolRequestTimeoutError({ + method: "sessions.messages.subscribe", + timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, + requestSent: true, + }), + ); + await failedRelease; + expect(request).toHaveBeenLastCalledWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + }); + await Promise.all([coordinator.release(foreground), coordinator.release(narration)]); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); + const count = request.mock.calls.length; + await coordinator.release(foreground); + await coordinator.release(narration); + expect(request).toHaveBeenCalledTimes(count); + const subscriptionId = request.mock.calls[0]?.[1].subscriptionId; + expect(request.mock.calls.every(([, params]) => params.subscriptionId === subscriptionId)).toBe( + true, + ); + }); +}); diff --git a/packages/gateway-client/src/session-subscriptions.test-support.ts b/packages/gateway-client/src/session-subscriptions.test-support.ts new file mode 100644 index 000000000000..05ee3513dfcb --- /dev/null +++ b/packages/gateway-client/src/session-subscriptions.test-support.ts @@ -0,0 +1,57 @@ +import { vi } from "vitest"; +import { + GatewayProtocolRequestTimeoutError, + type GatewayProtocolRequestOptions, +} from "./protocol-request.js"; +import type { GatewaySessionMessageRequestClient } from "./session-subscriptions.js"; + +type SessionRequestHandler = (method: string, params: Record) => Promise; + +export function createClient( + handler: SessionRequestHandler = async (method, params) => + method === "sessions.messages.subscribe" ? { key: params.key } : {}, +) { + const request = vi.fn(handler); + return { + client: { + request: (method: string, params: Record) => request(method, params), + } as unknown as GatewaySessionMessageRequestClient, + request, + }; +} + +export function createStalledRequestClient(stalledMethod: string, stalledKey: string) { + let shouldStall = true; + const request = vi.fn( + ( + method: string, + params: Record, + options?: GatewayProtocolRequestOptions, + ): Promise => { + if (shouldStall && method === stalledMethod && params.key === stalledKey) { + shouldStall = false; + return new Promise((_, reject) => { + const timeoutMs = options?.timeoutMs; + if (typeof timeoutMs === "number") { + setTimeout( + () => + reject( + new GatewayProtocolRequestTimeoutError({ + method, + timeoutMs, + requestSent: true, + }), + ), + timeoutMs, + ); + } + }); + } + return Promise.resolve(method === "sessions.messages.subscribe" ? { key: params.key } : {}); + }, + ); + return { + client: { request } as unknown as GatewaySessionMessageRequestClient, + request, + }; +} diff --git a/packages/gateway-client/src/session-subscriptions.test.ts b/packages/gateway-client/src/session-subscriptions.test.ts index eeb9618034f0..0aa2004fffb8 100644 --- a/packages/gateway-client/src/session-subscriptions.test.ts +++ b/packages/gateway-client/src/session-subscriptions.test.ts @@ -3,68 +3,16 @@ import { createDeferred } from "../../../test/helpers/promise.js"; import { GatewayProtocolRequestError, GatewayProtocolRequestTimeoutError, - type GatewayProtocolRequestOptions, } from "./protocol-request.js"; import { GatewaySessionMessageSubscriptionCoordinator, getGatewaySessionMessageSubscriptionCoordinator, releaseGatewaySessionMessageSubscription, resetGatewaySessionMessageSubscriptionCoordinator, - type GatewaySessionMessageRequestClient, } from "./session-subscriptions.js"; +import { createClient, createStalledRequestClient } from "./session-subscriptions.test-support.js"; import { DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS } from "./timeouts.js"; -type SessionRequestHandler = (method: string, params: Record) => Promise; - -function createClient( - handler: SessionRequestHandler = async (method, params) => - method === "sessions.messages.subscribe" ? { key: params.key } : {}, -) { - const request = vi.fn(handler); - return { - client: { - request: (method: string, params: Record) => request(method, params), - } as unknown as GatewaySessionMessageRequestClient, - request, - }; -} - -function createStalledRequestClient(stalledMethod: string, stalledKey: string) { - let shouldStall = true; - const request = vi.fn( - ( - method: string, - params: Record, - options?: GatewayProtocolRequestOptions, - ): Promise => { - if (shouldStall && method === stalledMethod && params.key === stalledKey) { - shouldStall = false; - return new Promise((_, reject) => { - const timeoutMs = options?.timeoutMs; - if (typeof timeoutMs === "number") { - setTimeout( - () => - reject( - new GatewayProtocolRequestTimeoutError({ - method, - timeoutMs, - requestSent: true, - }), - ), - timeoutMs, - ); - } - }); - } - return Promise.resolve(method === "sessions.messages.subscribe" ? { key: params.key } : {}); - }, - ); - return { - client: { request } as unknown as GatewaySessionMessageRequestClient, - request, - }; -} - afterEach(() => { vi.useRealTimers(); }); @@ -90,7 +38,10 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await coordinator.release(first); expect(request).toHaveBeenCalledOnce(); await coordinator.release(second); - expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.unsubscribe", { key: "main" }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); }); it("retains the requested alias after the Gateway returns a canonical key", async () => { @@ -106,6 +57,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { expect(first).toEqual({ key: "agent:main:main", agentId: null }); expect(second).toEqual({ key: "agent:main:main", agentId: null }); expect(request).toHaveBeenCalledExactlyOnceWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "main", }); @@ -114,6 +66,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await coordinator.release(second); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), key: "agent:main:main", }); }); @@ -128,6 +81,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { const canonical = await coordinator.acquire("agent:main:main"); expect(request).toHaveBeenCalledExactlyOnceWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "main", }); expect(requested.key).toBe("agent:main:main"); @@ -138,6 +92,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await coordinator.release(canonical); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), key: "agent:main:main", }); }); @@ -149,7 +104,6 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { requestedKey: "agent:ops:main", canonicalKey: "agent:ops:work", }, - { name: "global main", requestedKey: "agent:ops:main", canonicalKey: "global" }, ])( "coalesces $name aliases before the first canonical acknowledgment", async ({ requestedKey, canonicalKey }) => { @@ -163,6 +117,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { const canonical = coordinator.acquire(canonicalKey); expect(request).toHaveBeenCalledExactlyOnceWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: requestedKey, }); @@ -172,13 +127,20 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { expect(first).not.toBe(second); expect(first).toEqual({ key: canonicalKey, agentId: null }); expect(second).toEqual({ key: canonicalKey, agentId: null }); - expect(request).toHaveBeenCalledOnce(); + expect( + request.mock.calls.filter(([method]) => method === "sessions.messages.subscribe"), + ).toHaveLength(1); await coordinator.release(first); - expect(request).toHaveBeenCalledOnce(); + expect( + request.mock.calls.filter(([method]) => method === "sessions.messages.unsubscribe"), + ).toHaveLength(0); await coordinator.release(second); - expect(request).toHaveBeenCalledTimes(2); + expect( + request.mock.calls.filter(([method]) => method === "sessions.messages.unsubscribe"), + ).toHaveLength(1); expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), key: canonicalKey, }); }, @@ -213,6 +175,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { }); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "agent:main:main", includeApprovals: true, }); @@ -222,6 +185,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await coordinator.release(upgraded); expect(request).toHaveBeenCalledTimes(3); expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), key: "agent:main:main", }); }); @@ -239,10 +203,12 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { expect(research).toEqual({ key: "global", agentId: "research" }); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "global", agentId: "main", }); expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "global", agentId: "research", }); @@ -267,7 +233,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { expect(request).toHaveBeenNthCalledWith( 2, "sessions.messages.subscribe", - { key: "healthy" }, + { subscriptionId: expect.any(String), key: "healthy" }, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }, ); await expect(recovered).resolves.toEqual({ key: "healthy", agentId: null }); @@ -280,7 +246,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { expect(request).toHaveBeenNthCalledWith( 3, "sessions.messages.unsubscribe", - { key: "stalled" }, + { subscriptionId: expect.any(String), key: "stalled" }, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }, ); }); @@ -477,7 +443,10 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await expect(replacement).resolves.toEqual({ key: "main", agentId: null }); expect(request).toHaveBeenCalledTimes(3); - expect(request).toHaveBeenNthCalledWith(3, "sessions.messages.subscribe", { key: "main" }); + expect(request).toHaveBeenNthCalledWith(3, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + }); }); it("keeps approval observers upgraded when plain owners arrive later", async () => { @@ -500,6 +469,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { }); expect(plain).toEqual({ key: "main", agentId: null }); expect(request).toHaveBeenCalledExactlyOnceWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "main", includeApprovals: true, }); @@ -527,8 +497,12 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { const laterPlain = await coordinator.acquire("main"); expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.subscribe", { key: "main" }); + expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + }); expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "main", includeApprovals: true, }); @@ -699,10 +673,14 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { ]); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "main", includeApprovals: true, }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.subscribe", { key: "main" }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.subscribe", { + subscriptionId: expect.any(String), + key: "main", + }); }); it("releases the last plain owner after its provisional approval upgrade fails", async () => { @@ -727,7 +705,10 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { approval.reject(new Error("approval replay unavailable")); await expect(pendingApproval).rejects.toThrow("approval replay unavailable"); await expect(release).resolves.toBeUndefined(); - expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { key: "main" }); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); expect(request).toHaveBeenCalledTimes(3); }); @@ -762,7 +743,10 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await releaseGatewaySessionMessageSubscription(replacement); expect(request).toHaveBeenCalledTimes(3); - expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { key: "main" }); + expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), + key: "main", + }); }); it("rejects a subscribe acknowledgment from a retired connection", async () => { @@ -796,6 +780,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { }).acquire("main"); expect(request).toHaveBeenCalledExactlyOnceWith("sessions.messages.subscribe", { + subscriptionId: expect.any(String), key: "MAIN", }); expect(first.key).toBe("agent:main:main"); @@ -811,6 +796,7 @@ describe("GatewaySessionMessageSubscriptionCoordinator", () => { await releaseGatewaySessionMessageSubscription(second); expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenLastCalledWith("sessions.messages.unsubscribe", { + subscriptionId: expect.any(String), key: "agent:main:main", }); }); diff --git a/packages/gateway-client/src/session-subscriptions.ts b/packages/gateway-client/src/session-subscriptions.ts index 92e68c66cb52..48868ee207c7 100644 --- a/packages/gateway-client/src/session-subscriptions.ts +++ b/packages/gateway-client/src/session-subscriptions.ts @@ -1,3 +1,5 @@ +import { normalizeAgentIdStrict } from "@openclaw/normalization-core/agent-id"; +import { generateUUID } from "@openclaw/normalization-core/uuid"; import { GatewayProtocolRequestTimeoutError, type GatewayProtocolRequestOptions, @@ -15,12 +17,14 @@ export type GatewaySessionMessageRequestClient = { export type GatewaySessionMessageSubscription = { key: string; agentId?: string | null; + mode?: "narration"; includeApprovals?: true; approvalReplay?: unknown; }; export type GatewaySessionMessageSubscriptionOptions = { agentId?: string | null; + mode?: "narration"; includeApprovals?: boolean; }; @@ -30,17 +34,25 @@ type SessionMessageSubscriptionResponse = { }; type SessionMessageSubscriptionEntry = { + subscriptionId: string; key: string; requestedKeys: Set; agentId: string | null; + scopeAgentId: string | null; + ownerAgentId: string | null; ready: Promise; approvalRequest: Promise | null; plainFallback: Promise | null; + wireRequest: Promise | null; + mode?: "narration"; + includeApprovals: boolean; canonicalSettled: boolean; refreshRequired: boolean; handles: Set; pendingOwners: number; + pendingFullOwners: number; release: Promise | null; + releasing?: GatewaySessionMessageSubscription; }; type SessionMessageSubscriptionOwner = { @@ -52,16 +64,17 @@ export type GatewaySessionMessageSubscriptionCoordinatorOptions = { keysEquivalent?: (left: string, right: string) => boolean; }; -function sessionSubscriptionParams(key: string, agentId: string | null) { - return { - key: key.trim(), - ...(agentId ? { agentId } : {}), - }; +function normalizedAgentScope(agentId: string | null): string | null { + if (!agentId) { + return null; + } + const normalized = normalizeAgentIdStrict(agentId); + return normalized.ok ? normalized.value : agentId; } /** - * One Gateway connection owns one targeted observer per canonical session. - * Approval delivery is an upgrade of that observer, never a second observer. + * Shared leases retain one wire observer ID. The Gateway combines independently + * addressed observers; approval delivery upgrades the same owner. */ export class GatewaySessionMessageSubscriptionCoordinator { readonly #client: GatewaySessionMessageRequestClient; @@ -99,34 +112,38 @@ export class GatewaySessionMessageSubscriptionCoordinator { throw new Error("Session message subscription requires a session key"); } const agentId = options.agentId?.trim() || null; + const scopeAgentId = normalizedAgentScope(agentId); + const narration = options.mode === "narration"; + const includeApprovals = options.includeApprovals === true; let entry: SessionMessageSubscriptionEntry; + let created = false; while (true) { if (this.#retired) { throw new Error("Session message subscription belongs to a replaced Gateway connection"); } - const existing = [...this.#entries].find( - (candidate) => - candidate.agentId === agentId && - (this.#areKeysEquivalent(candidate.key, normalizedKey) || - [...candidate.requestedKeys].some((requestedKey) => - this.#areKeysEquivalent(requestedKey, normalizedKey), - )), - ); + const existing = [...this.#entries].find((candidate) => { + return ( + candidate.scopeAgentId === scopeAgentId && + ([...candidate.requestedKeys].some((requestedKey) => + this.#areKeysEquivalent(requestedKey, normalizedKey), + ) || + (candidate.key !== "global" && this.#areKeysEquivalent(candidate.key, normalizedKey))) + ); + }); if (!existing) { const provisional = [...this.#entries].find( (candidate) => - candidate.agentId === agentId && + candidate.scopeAgentId === scopeAgentId && !candidate.canonicalSettled && this.#couldShareCanonicalIdentity(candidate.key, normalizedKey), ); if (provisional) { - // Only potentially aliased sessions need the first Gateway acknowledgment; - // unrelated bodies must not inherit another observer's request deadline. await (provisional.plainFallback ?? provisional.ready).catch(() => undefined); continue; } - entry = this.#createEntry(normalizedKey, agentId, options.includeApprovals === true); + entry = this.#createEntry(normalizedKey, agentId); + created = true; break; } if (!existing.release) { @@ -140,7 +157,18 @@ export class GatewaySessionMessageSubscriptionCoordinator { } entry.pendingOwners += 1; + if (!narration) { + entry.pendingFullOwners += 1; + } try { + if (created) { + entry.ready = this.#requestSubscribe(entry, includeApprovals); + if (includeApprovals) { + entry.ready = this.#trackApprovalRequest(entry, entry.ready); + } + // Concurrent owners share this request without an unhandled side branch. + void entry.ready.catch(() => undefined); + } if (entry.refreshRequired) { entry.refreshRequired = false; entry.plainFallback = null; @@ -153,7 +181,10 @@ export class GatewaySessionMessageSubscriptionCoordinator { throw error; }); } - const result = await this.#acquireCapability(entry, options.includeApprovals === true); + const result = await this.#acquireCapability(entry, includeApprovals); + if (!narration && entry.mode === "narration") { + await this.#requestSubscribe(entry, false, "full"); + } if (this.#retired) { throw new Error("Session message subscription completed on a replaced Gateway connection"); } @@ -161,7 +192,8 @@ export class GatewaySessionMessageSubscriptionCoordinator { const subscription: GatewaySessionMessageSubscription = { key: result.key, agentId, - ...(options.includeApprovals === true + ...(narration ? { mode: "narration" as const } : {}), + ...(includeApprovals ? { includeApprovals: true as const, ...(result.approvalReplay !== undefined @@ -178,6 +210,9 @@ export class GatewaySessionMessageSubscriptionCoordinator { return subscription; } finally { entry.pendingOwners -= 1; + if (!narration) { + entry.pendingFullOwners -= 1; + } if (entry.pendingOwners === 0 && entry.handles.size === 0 && !entry.release) { this.#entries.delete(entry); } @@ -190,53 +225,57 @@ export class GatewaySessionMessageSubscriptionCoordinator { return Promise.resolve(); } const { entry } = owner; - if (this.#retired || entry.handles.size > 1) { + if (entry.release) { + return entry.releasing === subscription + ? entry.release + : entry.release.catch(() => undefined).then(() => this.release(subscription)); + } + const releasesLastFullOwner = + subscription.mode !== "narration" && + [...entry.handles].every((handle) => handle === subscription || handle.mode === "narration"); + if (this.#retired || (entry.handles.size > 1 && !releasesLastFullOwner)) { this.#finishRelease(subscription, owner); return Promise.resolve(); } - if (entry.release) { - return entry.release; - } if (entry.pendingOwners > 0) { // Keep the final live handle until every provisional owner commits or // fails; otherwise a rejected approval upgrade or acquire orphans it. - const pending = [entry.ready, ...(entry.approvalRequest ? [entry.approvalRequest] : [])]; - const tracked = Promise.allSettled(pending).then(() => { + const tracked = Promise.allSettled([ + entry.ready, + entry.approvalRequest, + entry.wireRequest, + ]).then(() => { if (entry.release === tracked) { entry.release = null; } return this.release(subscription); }); entry.release = tracked; + entry.releasing = subscription; return tracked; } - // Retain both the handle and its wire entry until the Gateway acknowledges - // the last release. A rejected unsubscribe must remain genuinely retryable. - const request = this.#client - .request( - "sessions.messages.unsubscribe", - sessionSubscriptionParams(entry.key, entry.agentId), - { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }, - ) - .then( - () => { - this.#finishRelease(subscription, owner, true); - }, - (error: unknown) => { + // Both downgrade and unsubscribe retain the lease until acknowledged, so + // rejected releases remain retryable on their original owner. + const removeEntry = entry.handles.size === 1; + const request = removeEntry + ? this.#requestMessages(entry).catch((error: unknown) => { if (error instanceof GatewayProtocolRequestTimeoutError && error.requestSent) { // The unsubscribe may have committed despite its missing acknowledgment. entry.refreshRequired = true; } throw error; - }, - ); - const tracked = request.finally(() => { - if (entry.release === tracked) { - entry.release = null; - } - }); + }) + : this.#requestSubscribe(entry, false, "narration"); + const tracked = request + .then(() => this.#finishRelease(subscription, owner, removeEntry)) + .finally(() => { + if (entry.release === tracked) { + entry.release = null; + } + }); entry.release = tracked; + entry.releasing = subscription; return tracked; } @@ -254,31 +293,26 @@ export class GatewaySessionMessageSubscriptionCoordinator { this.#entries.clear(); } - #createEntry( - key: string, - agentId: string | null, - includeApprovals: boolean, - ): SessionMessageSubscriptionEntry { + #createEntry(key: string, agentId: string | null): SessionMessageSubscriptionEntry { const entry: SessionMessageSubscriptionEntry = { + subscriptionId: generateUUID(), key, requestedKeys: new Set([key]), agentId, + scopeAgentId: normalizedAgentScope(agentId), + ownerAgentId: null, ready: Promise.resolve({ key }), approvalRequest: null, plainFallback: null, + wireRequest: null, + includeApprovals: false, canonicalSettled: false, refreshRequired: false, handles: new Set(), pendingOwners: 0, + pendingFullOwners: 0, release: null, }; - entry.ready = this.#requestSubscribe(entry, includeApprovals); - if (includeApprovals) { - entry.ready = this.#trackApprovalRequest(entry, entry.ready); - } - // Concurrent owners observe the same rejection; this observer only prevents - // an unhandled side branch and never changes the rejected acquire result. - void entry.ready.catch(() => undefined); this.#entries.add(entry); return entry; } @@ -335,18 +369,65 @@ export class GatewaySessionMessageSubscriptionCoordinator { return tracked; } - async #requestSubscribe( + #requestSubscribe( entry: SessionMessageSubscriptionEntry, includeApprovals: boolean, + mode?: "full" | "narration", ): Promise { - const params = sessionSubscriptionParams(entry.key, entry.agentId); - const result = await this.#client - .request( - "sessions.messages.subscribe", - includeApprovals ? { ...params, includeApprovals: true } : params, - { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }, - ) - .catch(async (error: unknown) => { + const send = () => this.#sendSubscribe(entry, includeApprovals, mode); + // Approval replay and stream-mode changes replace the same wire observer. + // Serializing them prevents a slow narration replay from undoing a full upgrade. + const request = entry.wireRequest ? entry.wireRequest.then(send, send) : send(); + const tracked = request.finally(() => { + if (entry.wireRequest === tracked) { + entry.wireRequest = null; + } + }); + entry.wireRequest = tracked; + return tracked; + } + + #requestMessages( + entry: SessionMessageSubscriptionEntry, + subscription?: { mode?: "narration"; includeApprovals: boolean }, + ) { + const agentId = entry.key === "global" ? (entry.ownerAgentId ?? entry.agentId) : entry.agentId; + return this.#client.request( + subscription ? "sessions.messages.subscribe" : "sessions.messages.unsubscribe", + { + subscriptionId: entry.subscriptionId, + key: entry.key, + ...(agentId ? { agentId } : {}), + ...(subscription?.mode ? { mode: subscription.mode } : {}), + ...(subscription?.includeApprovals ? { includeApprovals: true } : {}), + }, + { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }, + ); + } + + async #sendSubscribe( + entry: SessionMessageSubscriptionEntry, + requestedApprovals: boolean, + requestedMode?: "full" | "narration", + ): Promise { + if (this.#retired) { + throw new Error("Session message subscription belongs to a replaced Gateway connection"); + } + // A preceding queued request may already have upgraded every full owner. + if (requestedMode === "full" && entry.mode !== "narration") { + return { key: entry.key }; + } + const includeApprovals = + requestedApprovals || (requestedMode !== undefined && entry.includeApprovals); + const mode = + requestedMode === "narration" || + (requestedMode !== "full" && + entry.pendingFullOwners === 0 && + [...entry.handles].every((handle) => handle.mode === "narration")) + ? "narration" + : undefined; + const result = await this.#requestMessages(entry, { mode, includeApprovals }).catch( + async (error: unknown) => { if ( !(error instanceof GatewayProtocolRequestTimeoutError) || !error.requestSent || @@ -358,13 +439,19 @@ export class GatewaySessionMessageSubscriptionCoordinator { // A sent request can commit before its acknowledgment. Restore only // capabilities still owned by acquired leases, including older approval panes. const retainedApprovals = [...entry.handles].some((handle) => handle.includeApprovals); - await this.#client.request( + const retainedMode = + entry.handles.size > 0 && + [...entry.handles].every((handle) => handle.mode === "narration") + ? "narration" + : undefined; + await this.#requestMessages( + entry, entry.handles.size > 0 - ? "sessions.messages.subscribe" - : "sessions.messages.unsubscribe", - retainedApprovals ? { ...params, includeApprovals: true } : params, - { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }, + ? { mode: retainedMode, includeApprovals: retainedApprovals } + : undefined, ); + entry.mode = retainedMode; + entry.includeApprovals = retainedApprovals; } catch (recoveryError) { if (!this.#retired) { const subscriptionRecoveryFailure = new AggregateError( @@ -376,12 +463,21 @@ export class GatewaySessionMessageSubscriptionCoordinator { } } throw error; - }); + }, + ); const response = result && typeof result === "object" ? result : null; const responseKey = response && "key" in response ? response.key : undefined; entry.key = typeof responseKey === "string" && responseKey.trim() ? responseKey.trim() : entry.key; entry.canonicalSettled = true; + const responseAgentId = response && "agentId" in response ? response.agentId : undefined; + entry.ownerAgentId = normalizedAgentScope( + (typeof responseAgentId === "string" ? responseAgentId : null) ?? + entry.ownerAgentId ?? + entry.scopeAgentId, + ); + entry.mode = mode; + entry.includeApprovals = includeApprovals; return { key: entry.key, ...(response && "approvalReplay" in response diff --git a/packages/gateway-protocol/src/public-schema.ts b/packages/gateway-protocol/src/public-schema.ts index 7e8e95bbcaaa..03fdd28c3b41 100644 --- a/packages/gateway-protocol/src/public-schema.ts +++ b/packages/gateway-protocol/src/public-schema.ts @@ -212,6 +212,7 @@ export { SESSION_OBSERVER_HEALTH_VALUES, SessionCompanionExchangeSchema, SessionObserverDigestSchema, + SessionNarrationEventSchema, SessionObserverHealthSchema, SessionObserverPlanProgressSchema, SessionMemberAddParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 0fdb481afe64..b96f9415608a 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -460,14 +460,27 @@ export const SessionsSendParamsSchema = closedObject({ export const SessionsMessagesSubscribeParamsSchema = closedObject({ key: NonEmptyString, agentId: Type.Optional(NonEmptyString), + /** Stable connection-local observer identity; omission replaces the legacy observer. */ + subscriptionId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), + /** Background narration receives bounded digests; omission preserves full transcript streams. */ + mode: Type.Optional(Type.Literal("narration")), /** Opt in to sanitized durable approval events for this session and its descendants. */ includeApprovals: Type.Optional(Type.Literal(true)), }); +/** Latest bounded assistant text for a background narration subscriber. */ +export const SessionNarrationEventSchema = closedObject({ + sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), + runId: NonEmptyString, + text: Type.String({ maxLength: 16384 }), +}); + /** Removes a live message subscription for one session. */ export const SessionsMessagesUnsubscribeParamsSchema = closedObject({ key: NonEmptyString, agentId: Type.Optional(NonEmptyString), + subscriptionId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), }); /** Aborts the active or named run for a session. */ @@ -742,6 +755,7 @@ export type SessionsRecoverParams = Static; export type SessionsRecoverResult = Static; export type SessionsSendParams = Static; export type SessionsMessagesSubscribeParams = Static; +export type SessionNarrationEvent = Static; export type SessionsMessagesUnsubscribeParams = Static< typeof SessionsMessagesUnsubscribeParamsSchema >; diff --git a/packages/gateway-protocol/src/session-narration.test.ts b/packages/gateway-protocol/src/session-narration.test.ts new file mode 100644 index 000000000000..cd0d8f298292 --- /dev/null +++ b/packages/gateway-protocol/src/session-narration.test.ts @@ -0,0 +1,51 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { + SessionNarrationEventSchema, + SessionsMessagesSubscribeParamsSchema, + SessionsMessagesUnsubscribeParamsSchema, +} from "./schema/sessions.js"; + +describe("session narration protocol", () => { + it.each([ + [{ key: "agent:main:work" }, true], + [{ key: "agent:main:work", mode: "narration" }, true], + [{ key: "agent:main:work", mode: "narration", includeApprovals: true }, true], + [{ key: "agent:main:work", mode: "full" }, false], + [{ key: "agent:main:work", mode: true }, false], + ])("validates the optional narration intent: %j", (params, valid) => { + expect(Value.Check(SessionsMessagesSubscribeParamsSchema, params)).toBe(valid); + }); + + it.each([ + [undefined, true], + ["owner", true], + ["x".repeat(128), true], + ["", false], + ["x".repeat(129), false], + [1, false], + ])("bounds observer identities for subscribe and unsubscribe: %j", (subscriptionId, valid) => { + const params = { + key: "agent:main:work", + ...(subscriptionId === undefined ? {} : { subscriptionId }), + }; + expect(Value.Check(SessionsMessagesSubscribeParamsSchema, params)).toBe(valid); + expect(Value.Check(SessionsMessagesUnsubscribeParamsSchema, params)).toBe(valid); + }); + + it("bounds digest text while requiring the session and run identity", () => { + const digest = { + sessionKey: "agent:main:work", + agentId: "main", + runId: "run-1", + text: "x".repeat(16384), + }; + expect(Value.Check(SessionNarrationEventSchema, digest)).toBe(true); + expect(Value.Check(SessionNarrationEventSchema, { ...digest, text: "" })).toBe(true); + expect(Value.Check(SessionNarrationEventSchema, { ...digest, text: `${digest.text}x` })).toBe( + false, + ); + expect(Value.Check(SessionNarrationEventSchema, { ...digest, runId: "" })).toBe(false); + expect(Value.Check(SessionNarrationEventSchema, { ...digest, sessionKey: "" })).toBe(false); + }); +}); diff --git a/packages/normalization-core/package.json b/packages/normalization-core/package.json index 817063ca4a4e..7a2d7f45e753 100644 --- a/packages/normalization-core/package.json +++ b/packages/normalization-core/package.json @@ -133,6 +133,11 @@ "types": "./dist/utf16-slice.d.mts", "import": "./dist/utf16-slice.mjs", "default": "./dist/utf16-slice.mjs" + }, + "./uuid": { + "types": "./dist/uuid.d.mts", + "import": "./dist/uuid.mjs", + "default": "./dist/uuid.mjs" } }, "scripts": { diff --git a/packages/normalization-core/src/uuid.ts b/packages/normalization-core/src/uuid.ts new file mode 100644 index 000000000000..be36291c597c --- /dev/null +++ b/packages/normalization-core/src/uuid.ts @@ -0,0 +1,33 @@ +type CryptoLike = { + randomUUID?: (() => string) | undefined; + getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; +}; + +let warnedWeakCrypto = false; + +function warnWeakCryptoOnce() { + if (warnedWeakCrypto) { + return; + } + warnedWeakCrypto = true; + console.warn("[uuid] crypto API missing; refusing insecure UUID generation"); +} + +export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { + if (cryptoLike && typeof cryptoLike.randomUUID === "function") { + return cryptoLike.randomUUID(); + } + + if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoLike.getRandomValues(bytes); + const view = new DataView(bytes.buffer); + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40); // version 4 + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80); // variant 1 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + warnWeakCryptoOnce(); + throw new Error("Web Crypto is required for UUID generation"); +} diff --git a/src/gateway/server-broadcast-narration.ts b/src/gateway/server-broadcast-narration.ts new file mode 100644 index 000000000000..bfbbcb0cc388 --- /dev/null +++ b/src/gateway/server-broadcast-narration.ts @@ -0,0 +1,303 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { parseAgentSessionKey } from "@openclaw/session-url-contract/session-key-normalization"; +import type { SessionNarrationEvent } from "../../packages/gateway-protocol/src/schema/sessions.js"; +import { stripInternalRuntimeContext } from "../agents/internal-runtime-context.js"; +import { extractAssistantPhaseText } from "../shared/chat-message-content.js"; +import { sanitizeAssistantVisibleTextWithProfile } from "../shared/text/assistant-visible-text.js"; +import type { + GatewayBroadcastOpts, + GatewayBroadcastToConnIdsFn, +} from "./server-broadcast-types.js"; +import type { SessionMessageSubscriberRegistry } from "./server-chat-state.js"; +import type { GatewayClientRegistry } from "./server/client-registry.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; + +const NARRATION_INTERVAL_MS = 2_000; +const NARRATION_TAIL_CHARS = 16_384; + +type PendingNarration = { + payload: Record; + projection: NarrationProjection; + sessionKeys: readonly string[]; + opts?: GatewayBroadcastOpts; +}; +type NarrationState = { + subscriptionKeys: readonly string[]; + lastSentAt?: number; + last?: SessionNarrationEvent; + pending?: PendingNarration; + timer?: ReturnType; + retirePending?: () => void; +}; +type ConnectionNarration = { + socket: GatewayWsClient["socket"]; + sessions: Map; + close: () => void; +}; +type NarrationProjection = { + explicitAgentId?: string; + agentId?: string; + key: string; + digest?: SessionNarrationEvent; +}; + +/** Per-session pacing retains only the newest publication, never token queues. */ +export function createGatewayNarrationDelivery(params: { + clients: GatewayClientRegistry; + sessionMessageSubscribers?: SessionMessageSubscriberRegistry; + send: GatewayBroadcastToConnIdsFn; +}) { + const connections = new WeakMap(); + const projections = new WeakMap(); + const groups = new WeakMap>(); + const subscribers = params.sessionMessageSubscribers; + + const isNarration = (connId: string, keys: readonly string[]) => { + let subscribed = false; + for (const key of keys) { + if (subscribers?.get(key).has(connId)) { + if (!subscribers.getNarration(key).has(connId)) { + return false; + } + subscribed = true; + } + } + return subscribed; + }; + const cancelPending = (state: NarrationState) => { + clearTimeout(state.timer); + state.timer = undefined; + state.retirePending?.(); + state.retirePending = undefined; + state.pending = undefined; + }; + const connectionFor = (client: GatewayWsClient) => { + const existing = connections.get(client); + if (existing?.socket === client.socket) { + return existing; + } + existing?.close(); + const socket = client.socket; + const sessions = new Map(); + const close = () => { + for (const state of sessions.values()) { + cancelPending(state); + } + sessions.clear(); + socket.off("close", close); + connections.delete(client); + }; + const connection = { socket, sessions, close }; + connections.set(client, connection); + socket.once("close", close); + return connection; + }; + subscribers?.onChange((key, connId) => { + const client = params.clients.getByConnectionId(connId); + const connection = client && connections.get(client); + if (!connection) { + return; + } + // A new intent cannot inherit queued text from the previous subscription. + for (const [sessionKey, state] of connection.sessions) { + if (state.subscriptionKeys.includes(key)) { + cancelPending(state); + connection.sessions.delete(sessionKey); + } + } + if (connection.sessions.size === 0) { + connection.close(); + } + }); + const projectionFor = ( + payload: Record, + sessionKey: string, + explicitAgentId?: string, + ): NarrationProjection => { + const existing = projections.get(payload); + if (existing && existing.explicitAgentId === explicitAgentId) { + return existing; + } + const agentId = + normalizeOptionalLowercaseString(explicitAgentId ?? payload.agentId) ?? + parseAgentSessionKey(sessionKey)?.agentId; + const projection = { + explicitAgentId, + agentId, + key: `${sessionKey.length}:${sessionKey}${agentId ?? ""}`, + }; + projections.set(payload, projection); + return projection; + }; + const project = ( + payload: Record, + projection: NarrationProjection, + ): SessionNarrationEvent => { + if (projection.digest) { + return projection.digest; + } + // Filter the complete snapshot before slicing: a tail can start inside a + // hidden block whose opening marker no longer fits in the bounded digest. + const visible = stripInternalRuntimeContext( + sanitizeAssistantVisibleTextWithProfile( + extractAssistantPhaseText(payload.message) ?? "", + "internal-scaffolding", + payload.state === "delta", + ), + { streaming: payload.state === "delta" }, + ); + const digest: SessionNarrationEvent = { + sessionKey: String(payload.sessionKey), + runId: String(payload.runId), + ...(projection.agentId ? { agentId: projection.agentId } : {}), + text: sliceUtf16Safe(visible, -NARRATION_TAIL_CHARS), + }; + projection.digest = digest; + return digest; + }; + const flush = ( + client: GatewayWsClient, + connection: ConnectionNarration, + state: NarrationState, + ) => { + const pending = state.pending; + cancelPending(state); + if (!pending || client.socket !== connection.socket || !params.clients.has(client)) { + return; + } + const { payload, projection, sessionKeys, opts } = pending; + const live = opts?.liveText; + if (!isNarration(client.connId, sessionKeys) || live?.group.aborted) { + return; + } + try { + if (live?.isCurrent?.() === false) { + return; + } + } catch { + return; + } + const digest = project(payload, projection); + if (state.last?.runId === digest.runId && state.last.text === digest.text) { + return; + } + state.lastSentAt = Date.now(); + state.last = digest; + // Re-enter the normal broadcaster so delayed delivery rechecks scopes, + // sharing, subscription mode, socket liveness, and slow-consumer policy. + params.send("session.narration", digest, new Set([client.connId]), { + sessionKeys, + agentId: projection.agentId, + dropIfSlow: true, + sessionSubscriptionVerified: true, + }); + }; + + return { + isNarration, + consume: ( + client: GatewayWsClient, + event: string, + payload: unknown, + sessionKeys: readonly string[], + opts?: GatewayBroadcastOpts, + ): boolean => { + if (!isRecord(payload) || !isNarration(client.connId, sessionKeys)) { + return false; + } + // The chat projection owns visible assistant text; raw assistant events + // duplicate it and can include text intentionally hidden from chat. + if (event === "agent") { + if (payload.stream === "assistant" || payload.stream === "thinking") { + return true; + } + if ( + payload.stream === "item" && + isRecord(payload.data) && + payload.data.phase === "update" && + (payload.data.kind === "preamble" || + (payload.data.kind === "answer_candidate" && payload.data.status === "candidate")) + ) { + return true; + } + } + if (typeof payload.sessionKey !== "string") { + return false; + } + const projection = projectionFor(payload, payload.sessionKey, opts?.agentId); + const key = projection.key; + const pendingState = connections.get(client)?.sessions.get(key); + if ( + pendingState?.pending && + ((typeof payload.runId === "string" && + payload.runId.trim().length > 0 && + payload.runId !== pendingState.pending.payload.runId) || + ((event === "agent" || event === "session.tool") && + payload.stream === "tool" && + isRecord(payload.data) && + typeof payload.data.name === "string" && + payload.data.name.trim().length > 0)) + ) { + // Delayed text cannot overtake newer activity. Keep the existing pacing window. + cancelPending(pendingState); + } + if (event !== "chat" || typeof payload.runId !== "string") { + return false; + } + const delta = payload.state === "delta"; + const terminal = + payload.state === "final" || payload.state === "error" || payload.state === "aborted"; + if (!delta && !terminal) { + return false; + } + const connection = connectionFor(client); + const state: NarrationState = connection.sessions.get(key) ?? { + subscriptionKeys: sessionKeys, + }; + state.subscriptionKeys = sessionKeys; + connection.sessions.set(key, state); + if (isRecord(payload.message)) { + cancelPending(state); + state.pending = { payload, projection, sessionKeys, opts }; + const signal = opts?.liveText?.group; + if (signal) { + let states = groups.get(signal); + if (!states) { + states = new Set(); + groups.set(signal, states); + const pendingStates = states; + signal.addEventListener( + "abort", + () => { + for (const pending of pendingStates) { + cancelPending(pending); + } + groups.delete(signal); + }, + { once: true }, + ); + } + states.add(state); + const pendingStates = states; + state.retirePending = () => pendingStates.delete(state); + } + } + if (terminal) { + if (state.pending?.payload.runId === payload.runId) { + flush(client, connection, state); + } + } else if (state.pending) { + const delay = NARRATION_INTERVAL_MS - (Date.now() - (state.lastSentAt ?? -Infinity)); + if (delay <= 0) { + flush(client, connection, state); + } else { + state.timer = setTimeout(() => flush(client, connection, state), delay); + state.timer.unref?.(); + } + } + return delta; + }, + }; +} diff --git a/src/gateway/server-broadcast-scopes.ts b/src/gateway/server-broadcast-scopes.ts index 5ae53ccf47c5..b0f7103d0e6a 100644 --- a/src/gateway/server-broadcast-scopes.ts +++ b/src/gateway/server-broadcast-scopes.ts @@ -82,6 +82,7 @@ const EVENT_SCOPE_GUARDS: Record = { "plugins.controlUi.changed": [READ_SCOPE], "session.approval": [APPROVALS_SCOPE], "session.message": [SESSION_READ_SCOPE], + "session.narration": [SESSION_READ_SCOPE], "session.observer": [SESSION_READ_SCOPE], "session.operation": [READ_SCOPE], "session.sharing": [READ_SCOPE], diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts index cd71cbf9ae73..4868a750745b 100644 --- a/src/gateway/server-broadcast.board.test.ts +++ b/src/gateway/server-broadcast.board.test.ts @@ -504,11 +504,11 @@ describe("collaboration event scope guards", () => { for (const peer of unrelated) { expect(frames(peer)).toEqual([]); } - expect(getSubscribers).toHaveBeenCalledExactlyOnceWith(sessionKey); expect(filter).toHaveBeenCalledTimes(2); await upsertSessionEntryCore(target, { ...entry, ...hidden, updatedAt: 2 }); invalidateSessionSharingSnapshot(sessionKey); + getSubscribers.mockClear(); filter.mockClear(); broadcast("chat", payload); broadcast("tick", {}); @@ -525,6 +525,8 @@ describe("collaboration event scope guards", () => { }, ]); } + // Hidden recipients skip narration's intent lookups, isolating admission's shared lookup. + expect(getSubscribers).toHaveBeenCalledExactlyOnceWith(sessionKey); expect(filter).toHaveBeenCalledTimes(2); }); }, diff --git a/src/gateway/server-broadcast.narration.test.ts b/src/gateway/server-broadcast.narration.test.ts new file mode 100644 index 000000000000..ac0b3dd565b7 --- /dev/null +++ b/src/gateway/server-broadcast.narration.test.ts @@ -0,0 +1,380 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GATEWAY_CLIENT_CAPS } from "../../packages/gateway-protocol/src/client-info.js"; +import { INTERNAL_RUNTIME_CONTEXT_BEGIN } from "../agents/internal-runtime-context.js"; +import { createGatewayBroadcaster } from "./server-broadcast.js"; +import { chatWireProjection } from "./server-chat-live-text.js"; +import { createSessionMessageSubscriberRegistry } from "./server-chat-state.js"; +import { GatewayClientRegistry } from "./server/client-registry.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; + +const key = "agent:main:narrated"; +type Frame = { + event: string; + seq: number; + payload: { runId: string; sessionKey?: string; agentId?: string; text?: string; state?: string }; +}; + +function peer(connId: string) { + const frames: Frame[] = []; + const socket = Object.assign(new EventEmitter(), { + readyState: 1, + bufferedAmount: 0, + close: vi.fn(), + terminate: vi.fn(), + send: (wire: string, done?: () => void) => { + frames.push(JSON.parse(wire)); + done?.(); + }, + }); + const client: GatewayWsClient = { + connId, + socket: socket as unknown as GatewayWsClient["socket"], + connect: { + role: "operator", + scopes: ["operator.read"], + caps: [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], + } as GatewayWsClient["connect"], + usesSharedGatewayAuth: false, + }; + return { client, socket, frames }; +} + +function chat(text: string, state = "delta", runId = "run") { + return { + sessionKey: key, + runId, + state, + deltaText: text, + message: { role: "assistant", content: [{ type: "text", text }] }, + }; +} + +function harness() { + vi.useFakeTimers(); + vi.setSystemTime(0); + const narration = peer("narration"); + const foreground = peer("foreground"); + const clients = new GatewayClientRegistry([narration.client, foreground.client]); + const subscribers = createSessionMessageSubscriberRegistry(); + subscribers.subscribe("narration", key, { mode: "narration" }); + subscribers.subscribe("foreground", key); + let allowed = true; + const broadcaster = createGatewayBroadcaster({ + clients, + sessionMessageSubscribers: subscribers, + canReceiveSessionEvent: () => allowed, + }); + return { + ...broadcaster, + narration, + foreground, + subscribers, + clients, + revoke: () => { + allowed = false; + }, + }; +} + +afterEach(() => vi.useRealTimers()); + +describe("narration delivery through the Gateway broadcaster", () => { + it("paces background snapshots while preserving full foreground and lifecycle streams", () => { + const h = harness(); + const run = new AbortController(); + const publish = (text: string, state = "delta") => + h.broadcast("chat", chat(text, state), { + liveText: { + group: run.signal, + projection: + state === "delta" + ? chatWireProjection({ key: "chat", text, now: Date.now() }) + : undefined, + }, + }); + h.broadcast("agent", { + sessionKey: key, + runId: "run", + stream: "lifecycle", + data: { phase: "start" }, + }); + publish("First sentence."); + for (let index = 1; index <= 19; index += 1) { + vi.advanceTimersByTime(100); + h.broadcast("agent", { + sessionKey: key, + runId: "run", + stream: "assistant", + data: { text: `Raw ${index}` }, + }); + publish(`Latest sentence ${index}.`); + } + expect(h.narration.frames.map(({ event }) => event)).toEqual(["agent", "session.narration"]); + vi.advanceTimersByTime(100); + expect(h.narration.frames.at(-1)?.payload.text).toBe("Latest sentence 19."); + expect(h.foreground.frames.filter(({ event }) => event === "chat")).toHaveLength(20); + expect( + h.foreground.frames.findLast(({ event }) => event === "chat")?.payload, + ).not.toHaveProperty("message"); + expect(h.foreground.frames.filter(({ event }) => event === "agent")).toHaveLength(20); + publish("Final corrected sentence.", "final"); + expect( + h.narration.frames + .slice(-2) + .map(({ event, payload }) => [event, payload.text ?? payload.state]), + ).toEqual([ + ["session.narration", "Final corrected sentence."], + ["chat", "final"], + ]); + vi.advanceTimersByTime(5_000); + expect(h.narration.frames).toHaveLength(5); + expect(h.narration.frames.map(({ seq }) => seq)).toEqual([1, 2, 3, 4, 5]); + run.abort(); + }); + + it("bounds visible tails after stripping hidden blocks and immediately delivers final retractions", () => { + const h = harness(); + h.broadcast( + "chat", + chat(`Visible.\n${INTERNAL_RUNTIME_CONTEXT_BEGIN}\n${"private ".repeat(4_000)}`), + ); + expect(h.narration.frames.at(-1)?.payload.text).toBe("Visible."); + vi.advanceTimersByTime(2_000); + h.broadcast("chat", chat(`Visible. ${"private ".repeat(4_000)}`)); + expect(h.narration.frames.at(-1)?.payload.text?.trim()).toBe("Visible."); + vi.advanceTimersByTime(2_000); + h.broadcast("chat", chat(`${"x".repeat(20_000)}\n\nLatest line.`)); + expect(h.narration.frames.at(-1)?.payload.text).toHaveLength(16_384); + expect(h.narration.frames.at(-1)?.payload.text).toMatch(/Latest line\.$/); + h.broadcast("chat", chat("", "final")); + expect(h.narration.frames.at(-2)?.payload.text).toBe(""); + }); + + it.each(["agent", "session.tool"])( + "retires text superseded by %s tool activity without restarting its pacing window", + (toolEvent) => { + const h = harness(); + const tool = () => + h.broadcast(toolEvent, { + sessionKey: key, + runId: "run", + stream: "tool", + data: { phase: "start", name: "read" }, + }); + h.broadcast("chat", chat("First.")); + vi.advanceTimersByTime(100); + h.broadcast("chat", chat("Before the tool.")); + vi.advanceTimersByTime(100); + tool(); + vi.advanceTimersByTime(1_800); + expect(h.narration.frames.map(({ event }) => event)).toEqual([ + "session.narration", + toolEvent, + ]); + + h.broadcast("chat", chat("Assistant resumed.")); + vi.advanceTimersByTime(100); + h.broadcast("chat", chat("Before another tool.")); + tool(); + h.broadcast("chat", chat("Latest assistant activity.")); + vi.advanceTimersByTime(1_899); + expect(h.narration.frames.map(({ event }) => event)).toEqual([ + "session.narration", + toolEvent, + "session.narration", + toolEvent, + ]); + vi.advanceTimersByTime(1); + expect(h.narration.frames.at(-1)?.payload.text).toBe("Latest assistant activity."); + expect(h.foreground.frames.filter(({ event }) => event === "chat")).toHaveLength(5); + }, + ); + + it.each([ + { event: "agent", payload: { stream: "lifecycle", data: { phase: "start" } } }, + { + event: "session.observer", + payload: { + revision: 1, + updatedAt: 200, + headline: "New run is working", + health: "on-track", + }, + }, + ])("does not let an older digest follow a new-run $event", ({ event, payload }) => { + const h = harness(); + h.broadcast("chat", chat("First.")); + vi.advanceTimersByTime(100); + h.broadcast("chat", chat("Queued from the previous run.")); + vi.advanceTimersByTime(100); + h.broadcast(event, { ...payload, sessionKey: key, runId: "next-run" }); + vi.advanceTimersByTime(1_800); + expect(h.narration.frames.map((frame) => [frame.event, frame.payload.runId])).toEqual([ + ["session.narration", "run"], + [event, "next-run"], + ]); + h.broadcast("chat", chat("New run progress.", "delta", "next-run")); + expect(h.narration.frames.at(-1)?.payload).toMatchObject({ + runId: "next-run", + text: "New run progress.", + }); + }); + + it("omits token previews while retaining item completion, status, and tool events", () => { + const h = harness(); + for (const payload of [ + { stream: "thinking", data: { delta: "Private reasoning" } }, + { stream: "item", data: { phase: "update", kind: "preamble", progressText: "Working" } }, + { + stream: "item", + data: { + phase: "update", + kind: "answer_candidate", + status: "candidate", + progressText: "Draft", + }, + }, + { stream: "item", data: { phase: "end", kind: "preamble" } }, + { stream: "item", data: { phase: "update", kind: "answer_candidate", status: "selected" } }, + { stream: "run_status", data: { phase: "starting_model" } }, + { stream: "tool", data: { phase: "start", name: "read" } }, + ]) { + h.broadcast("agent", { ...payload, sessionKey: key, runId: "run" }); + } + expect(h.foreground.frames).toHaveLength(7); + expect(h.narration.frames).toMatchObject([ + { payload: { stream: "item", data: { phase: "end", kind: "preamble" } } }, + { + payload: { + stream: "item", + data: { phase: "update", kind: "answer_candidate", status: "selected" }, + }, + }, + { payload: { stream: "run_status", data: { phase: "starting_model" } } }, + { payload: { stream: "tool", data: { phase: "start", name: "read" } } }, + ]); + }); + + it.each(["unsubscribe", "foreground", "revocation", "close", "retirement"] as const)( + "never delivers queued narration after %s", + (reason) => { + const h = harness(); + const run = new AbortController(); + const opts = { liveText: { group: run.signal } }; + h.broadcast("chat", chat("First."), opts); + h.broadcast("chat", chat("Pending."), opts); + if (reason === "unsubscribe") { + h.subscribers.unsubscribe("narration", key); + } + if (reason === "foreground") { + h.subscribers.subscribe("narration", key); + } + if (reason === "revocation") { + h.revoke(); + } + if (reason === "close") { + h.narration.socket.readyState = 3; + h.narration.socket.emit("close"); + } + if (reason === "retirement") { + run.abort(); + } + vi.advanceTimersByTime(2_000); + expect(h.narration.frames).toHaveLength(1); + if (reason === "foreground") { + h.broadcast("chat", chat("Full transcript.")); + expect(h.narration.frames.at(-1)?.event).toBe("chat"); + } + run.abort(); + }, + ); + + it("keeps full delivery when any matching subscription is full and isolates session pacing", () => { + const h = harness(); + const sibling = "agent:main:sibling"; + h.subscribers.subscribe("narration", sibling); + h.broadcast("chat", chat("Full alias."), { sessionKeys: [key, sibling] }); + expect(h.narration.frames.at(-1)?.event).toBe("chat"); + h.subscribers.subscribe("narration", sibling, { mode: "narration" }); + h.broadcast("chat", chat("First session.")); + h.broadcast("chat", { ...chat("Second session."), sessionKey: sibling }); + expect(h.narration.frames.slice(-2).map(({ payload }) => payload.text)).toEqual([ + "First session.", + "Second session.", + ]); + h.broadcast("chat", chat("Last partial.")); + h.broadcast("chat", { + sessionKey: key, + runId: "run", + state: "error", + errorMessage: "Stopped.", + }); + expect(h.narration.frames.slice(-2).map(({ event }) => event)).toEqual([ + "session.narration", + "chat", + ]); + expect(h.narration.frames.at(-2)?.payload.text).toBe("Last partial."); + }); + + it("separates logical global sessions and retires every state for a released wire key", () => { + const h = harness(); + const mainKey = "agent:main:global"; + const opsKey = "agent:ops:global"; + h.subscribers.subscribe("narration", mainKey, { subscriptionId: "raw", mode: "narration" }); + h.subscribers.subscribe("narration", mainKey, { + subscriptionId: "literal", + mode: "narration", + }); + h.subscribers.subscribe("narration", opsKey, { subscriptionId: "ops", mode: "narration" }); + const initial = { ...chat("Initial."), sessionKey: "global" }; + h.broadcast("chat", initial, { sessionKeys: [mainKey], agentId: "main" }); + h.broadcast("chat", { ...initial, sessionKey: mainKey }, { sessionKeys: [mainKey] }); + h.broadcast("chat", initial, { sessionKeys: [opsKey], agentId: "ops" }); + expect( + h.narration.frames.map(({ payload }) => [payload.sessionKey, payload.agentId, payload.text]), + ).toEqual([ + ["global", "main", "Initial."], + [mainKey, "main", "Initial."], + ["global", "ops", "Initial."], + ]); + + const publish = (sessionKey: string, agentId: string, text: string) => + h.broadcast( + "chat", + { ...chat(text), sessionKey, agentId }, + { sessionKeys: [`agent:${agentId}:global`] }, + ); + vi.advanceTimersByTime(100); + publish("global", "main", "Raw pending."); + publish(mainKey, "main", "Literal pending."); + publish("global", "ops", "Ops pending."); + h.broadcast( + "session.tool", + { sessionKey: "global", runId: "run", stream: "tool", data: { name: "read" } }, + { sessionKeys: [mainKey], agentId: "main" }, + ); + vi.advanceTimersByTime(1_900); + expect(h.narration.frames.slice(-2).map(({ payload }) => payload.text)).toEqual([ + "Literal pending.", + "Ops pending.", + ]); + expect(h.narration.frames).toHaveLength(6); + + publish("global", "main", "Raw resumed."); + vi.advanceTimersByTime(100); + publish("global", "main", "Raw retired."); + publish(mainKey, "main", "Literal retired."); + publish("global", "ops", "Ops retained."); + h.subscribers.unsubscribe("narration", mainKey, "raw"); + h.subscribers.unsubscribe("narration", mainKey, "literal"); + vi.advanceTimersByTime(1_900); + expect(h.narration.frames).toHaveLength(8); + expect(h.narration.frames.at(-1)?.payload.text).toBe("Ops retained."); + + publish("global", "ops", "Retired on foreground admission."); + h.subscribers.subscribe("narration", opsKey, { subscriptionId: "ops" }); + vi.advanceTimersByTime(2_000); + expect(h.narration.frames).toHaveLength(8); + }); +}); diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index c126131166be..0fc9ac648717 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -20,6 +20,7 @@ import { type LiveTextPublication, type PendingLiveText, } from "./server-broadcast-live-text.js"; +import { createGatewayNarrationDelivery } from "./server-broadcast-narration.js"; import { hasEventScope, isSessionReadInvalidation, @@ -49,6 +50,7 @@ const SESSION_SUBSCRIPTION_EVENTS = new Set([ "chat", "chat.side_result", "session.observer", + "session.narration", // Mirrors the raw agent tool event (full args/result snapshots) onto // session subscribers; omitting it here would hand scoped clients the // exact payload the registry gate suppresses on the `agent` event. @@ -215,6 +217,10 @@ export function createGatewayBroadcaster(params: { const clientSeq = new WeakMap(); const reportedSlowPayloadClients = new WeakSet(); const delivery = createGatewayLiveTextDelivery(params); + const narration = createGatewayNarrationDelivery({ + ...params, + send: (event, payload, connIds, opts) => broadcastInternal(event, payload, opts, connIds), + }); const isCurrent = (predicate?: () => boolean) => { try { return predicate?.() !== false; @@ -398,6 +404,18 @@ export function createGatewayBroadcaster(params: { if ((retained && !isCurrent(live?.isCurrent)) || (live?.coalesce && live.group.aborted)) { continue; } + // Narration consumes producer snapshots before the per-socket wire + // projection below removes cumulative text from foreground appends. + if ( + (event === "session.narration" && !narration.isNarration(c.connId, sessionKeys)) || + ((event === "chat" || + event === "agent" || + event === "session.tool" || + event === "session.observer") && + narration.consume(c, event, payload, sessionKeys, opts)) + ) { + continue; + } if (!outboundEventLogged) { outboundEventLogged = true; logWs("out", "event", () => { diff --git a/src/gateway/server-chat-state.subscriptions.test.ts b/src/gateway/server-chat-state.subscriptions.test.ts new file mode 100644 index 000000000000..b9b2bfe03ebc --- /dev/null +++ b/src/gateway/server-chat-state.subscriptions.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionMessageSubscriberRegistry } from "./server-chat-state.js"; + +const key = "agent:main:global"; + +describe("session message subscription owners", () => { + it("aggregates full streams and approvals while releasing only the named observer", () => { + const registry = createSessionMessageSubscriberRegistry(); + const changes = vi.fn(() => ({ + subscribed: registry.get(key).has("conn"), + narration: registry.getNarration(key).has("conn"), + approvals: registry.getApprovals(key).has("conn"), + })); + registry.onChange(changes); + registry.subscribe("conn", key, { subscriptionId: "foreground" }); + registry.subscribe("conn", key, { + subscriptionId: "sidebar", + mode: "narration", + includeApprovals: true, + }); + expect([...registry.getNarration(key)]).toEqual([]); + expect([...registry.getApprovals(key)]).toEqual(["conn"]); + + registry.unsubscribe("conn", key, "foreground"); + expect(changes.mock.results.at(-1)?.value).toEqual({ + subscribed: true, + narration: true, + approvals: true, + }); + registry.subscribe("conn", key); + expect([...registry.getNarration(key)]).toEqual([]); + registry.unsubscribe("conn", key); + expect([...registry.getNarration(key)]).toEqual(["conn"]); + registry.unsubscribe("conn", key, "unknown"); + expect([...registry.get(key)]).toEqual(["conn"]); + + registry.unsubscribe("conn", key, "sidebar"); + expect(changes.mock.results.at(-1)?.value).toEqual({ + subscribed: false, + narration: false, + approvals: false, + }); + }); + + it("keeps a provisional foreground owner through another owner's failed replay", () => { + const registry = createSessionMessageSubscriberRegistry(); + registry.subscribe("conn", key, { subscriptionId: "sidebar", mode: "narration" }); + const foreground = registry.subscribe("conn", key, { + subscriptionId: "foreground", + provisional: true, + includeApprovals: true, + })!; + const narration = registry.subscribe("conn", key, { + subscriptionId: "sidebar", + provisional: true, + mode: "narration", + })!; + expect([...registry.getNarration(key)]).toEqual([]); + expect([...registry.getApprovals(key)]).toEqual(["conn"]); + + narration(); + expect([...registry.getNarration(key)]).toEqual([]); + expect([...registry.getApprovals(key)]).toEqual(["conn"]); + foreground(); + expect([...registry.getNarration(key)]).toEqual(["conn"]); + expect([...registry.getApprovals(key)]).toEqual([]); + }); + + it.each([false, true])( + "retains full delivery until an older in-flight intent settles (succeeds=%s)", + (succeeds) => { + const registry = createSessionMessageSubscriberRegistry(); + const foreground = registry.subscribe("conn", key, { + subscriptionId: "owner", + provisional: true, + })!; + const narration = registry.subscribe("conn", key, { + subscriptionId: "owner", + provisional: true, + mode: "narration", + })!; + narration.commit(); + expect([...registry.getNarration(key)]).toEqual([]); + + if (succeeds) { + foreground.commit(); + } else { + foreground(); + } + expect([...registry.getNarration(key)]).toEqual(["conn"]); + }, + ); + + it.each(["unsubscribe", "disconnect"])( + "fences pending owner settlements after %s and ID reuse", + (action) => { + const registry = createSessionMessageSubscriberRegistry(); + const old = registry.subscribe("conn", key, { + subscriptionId: "owner", + provisional: true, + includeApprovals: true, + })!; + if (action === "disconnect") { + registry.unsubscribeAll("conn"); + } else { + registry.unsubscribe("conn", key, "owner"); + } + registry.subscribe("conn", key, { subscriptionId: "owner", mode: "narration" }); + old.commit(); + expect([...registry.getNarration(key)]).toEqual(["conn"]); + expect([...registry.getApprovals(key)]).toEqual([]); + registry.subscribe("conn", key, { subscriptionId: "another" }); + registry.unsubscribeAll("conn"); + expect([...registry.get(key)]).toEqual([]); + expect([...registry.getNarration(key)]).toEqual([]); + expect([...registry.getApprovals(key)]).toEqual([]); + }, + ); +}); diff --git a/src/gateway/server-chat-state.test.ts b/src/gateway/server-chat-state.test.ts index dd7acdb28fa2..7b6ede9931bb 100644 --- a/src/gateway/server-chat-state.test.ts +++ b/src/gateway/server-chat-state.test.ts @@ -768,6 +768,29 @@ describe("createChatRunState", () => { }); describe("createSessionMessageSubscriberRegistry", () => { + it("replaces narration intent and invalidates the audience after the new mode is visible", () => { + const subscribers = createSessionMessageSubscriberRegistry(); + const modes: string[] = []; + subscribers.onChange((key, connId) => { + modes.push( + !subscribers.get(key).has(connId) + ? "none" + : subscribers.getNarration(key).has(connId) + ? "narration" + : "full", + ); + }); + + subscribers.subscribe("conn", "agent:main:main", { mode: "narration" }); + subscribers.subscribe("conn", "agent:main:main", { mode: "narration" }); + subscribers.subscribe("conn", "agent:main:main"); + subscribers.subscribe("conn", "agent:main:main", { mode: "narration" }); + subscribers.unsubscribeAll("conn"); + + expect(modes).toEqual(["narration", "full", "narration", "none"]); + expect([...subscribers.getNarration("agent:main:main")]).toEqual([]); + }); + it("keeps approval delivery opt-in and updates it on resubscribe", () => { const subscribers = createSessionMessageSubscriberRegistry(); @@ -810,6 +833,7 @@ describe("createSessionMessageSubscriberRegistry", () => { const first = subscribers.subscribe("conn", "agent:main:main", { provisional: true, includeApprovals: true, + mode: "narration", })!; const second = subscribers.subscribe("conn", "agent:main:main", { provisional: true })!; @@ -823,6 +847,7 @@ describe("createSessionMessageSubscriberRegistry", () => { expect([...subscribers.get("agent:main:main")]).toEqual([]); expect([...subscribers.getApprovals("agent:main:main")]).toEqual([]); + expect([...subscribers.getNarration("agent:main:main")]).toEqual([]); }, ); @@ -832,13 +857,14 @@ describe("createSessionMessageSubscriberRegistry", () => { ["first", true], ["second", true], ] as const)( - "keeps the latest successful replay's approval mode (%s settles first, earlier succeeds=%s)", + "keeps the latest successful replay's modes (%s settles first, earlier succeeds=%s)", (firstResolution, firstSucceeds) => { const subscribers = createSessionMessageSubscriberRegistry(); subscribers.subscribe("conn", "agent:main:other"); const first = subscribers.subscribe("conn", "agent:main:main", { provisional: true, includeApprovals: true, + mode: "narration", })!; const second = subscribers.subscribe("conn", "agent:main:main", { provisional: true })!; const settleFirst = firstSucceeds ? first.commit : first; @@ -854,6 +880,28 @@ describe("createSessionMessageSubscriberRegistry", () => { expect([...subscribers.get("agent:main:other")]).toEqual(["conn"]); expect([...subscribers.get("agent:main:main")]).toEqual(["conn"]); expect([...subscribers.getApprovals("agent:main:main")]).toEqual([]); + expect([...subscribers.getNarration("agent:main:main")]).toEqual([]); + }, + ); + + it.each([undefined, "narration"] as const)( + "restores committed narration mode %s when an approval replay fails", + (mode) => { + const subscribers = createSessionMessageSubscriberRegistry(); + subscribers.subscribe("conn", "agent:main:main", { mode }); + const rollback = subscribers.subscribe("conn", "agent:main:main", { + provisional: true, + includeApprovals: true, + mode: mode === "narration" ? undefined : "narration", + })!; + + rollback(); + + expect([...subscribers.get("agent:main:main")]).toEqual(["conn"]); + expect([...subscribers.getNarration("agent:main:main")]).toEqual( + mode === "narration" ? ["conn"] : [], + ); + expect([...subscribers.getApprovals("agent:main:main")]).toEqual([]); }, ); @@ -891,6 +939,7 @@ describe("createSessionMessageSubscriberRegistry", () => { const subscription = subscribers.subscribe("conn", "agent:main:main", { provisional: true, includeApprovals: true, + mode: "narration", })!; if (invalidation === "disconnect") { @@ -907,6 +956,7 @@ describe("createSessionMessageSubscriberRegistry", () => { subscription.commit(); expect([...subscribers.get("agent:main:main")]).toEqual(["conn"]); expect([...subscribers.getApprovals("agent:main:main")]).toEqual([]); + expect([...subscribers.getNarration("agent:main:main")]).toEqual([]); replacement(); expect([...subscribers.get("agent:main:main")]).toEqual([]); diff --git a/src/gateway/server-chat-state.ts b/src/gateway/server-chat-state.ts index 54bc1f6015bd..07ee91a3eea6 100644 --- a/src/gateway/server-chat-state.ts +++ b/src/gateway/server-chat-state.ts @@ -448,23 +448,35 @@ export type SessionMessageSubscriberRegistry = { subscribe: ( connId: string, sessionKey: string, - opts?: { includeApprovals?: boolean; provisional?: boolean }, + opts?: { + includeApprovals?: boolean; + provisional?: boolean; + mode?: "narration"; + subscriptionId?: string; + }, ) => SessionMessageSubscription | undefined; - unsubscribe: (connId: string, sessionKey: string) => void; + unsubscribe: (connId: string, sessionKey: string, subscriptionId?: string) => void; unsubscribeAll: (connId: string) => void; get: (sessionKey: string) => ReadonlySet; getApprovals: (sessionKey: string) => ReadonlySet; + getNarration: (sessionKey: string) => ReadonlySet; onChange: (listener: (sessionKey: string, connId: string) => void) => () => void; }; type SessionMessageSubscription = (() => void) & { commit: () => void }; -type ProvisionalSubscriptionState = { - base?: boolean; - inflight: number; - lastSuccess?: { sequence: number; includeApprovals: boolean }; +type SessionMessageSubscriptionMode = { + includeApprovals: boolean; + mode?: "narration"; }; +type ProvisionalSubscriptionState = { + committed?: { sequence: number; mode: SessionMessageSubscriptionMode }; + inflight: Map; +}; + +type SessionMessageSubscriptionOwners = Map; + const TOOL_EVENT_RECIPIENT_TTL_MS = 10 * 60 * 1000; const TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS = 30 * 1000; @@ -503,50 +515,76 @@ export function createSessionMessageSubscriberRegistry( onSubscriptionChange?: (connId: string) => void, ): SessionMessageSubscriberRegistry { const sessionToConnIds = new Map>(); - // Booleans retain committed approval mode; records own unsettled replays. - // Replacing a record fences late settlements, including connection/session reuse. - const connections = new Map>(); + // Removing a record fences late replay settlements, including connection/session reuse. + const connections = new Map>(); const approvalSessionToConnIds = new Map>(); + const narrationSessionToConnIds = new Map>(); const changeListeners = new Set<(sessionKey: string, connId: string) => void>(); const empty = new Set(); let subscriptionSequence = 0; - const setMessageSubscription = (connId: string, sessionKey: string, subscribed: boolean) => { - const connIds = sessionToConnIds.get(sessionKey); - const wasSubscribed = connIds?.has(connId) === true; + const setMembership = ( + index: Map>, + connId: string, + sessionKey: string, + subscribed: boolean, + ) => { + const connIds = index.get(sessionKey); if (subscribed) { const nextConnIds = connIds ?? new Set(); nextConnIds.add(connId); - sessionToConnIds.set(sessionKey, nextConnIds); - if (!wasSubscribed) { - for (const listener of changeListeners) { - listener(sessionKey, connId); - } - } + index.set(sessionKey, nextConnIds); return; } connIds?.delete(connId); if (connIds?.size === 0) { - sessionToConnIds.delete(sessionKey); + index.delete(sessionKey); } - if (wasSubscribed) { + }; + const setSubscription = ( + connId: string, + sessionKey: string, + mode?: SessionMessageSubscriptionMode, + ) => { + const subscribed = mode !== undefined; + const narration = mode?.mode === "narration"; + const changed = + (sessionToConnIds.get(sessionKey)?.has(connId) === true) !== subscribed || + (narrationSessionToConnIds.get(sessionKey)?.has(connId) === true) !== narration; + setMembership(sessionToConnIds, connId, sessionKey, subscribed); + setMembership(approvalSessionToConnIds, connId, sessionKey, mode?.includeApprovals === true); + setMembership(narrationSessionToConnIds, connId, sessionKey, narration); + if (changed) { for (const listener of changeListeners) { listener(sessionKey, connId); } } }; - const setApprovalSubscription = (connId: string, sessionKey: string, subscribed: boolean) => { - const connIds = approvalSessionToConnIds.get(sessionKey); - if (subscribed) { - const nextConnIds = connIds ?? new Set(); - nextConnIds.add(connId); - approvalSessionToConnIds.set(sessionKey, nextConnIds); - return; - } - connIds?.delete(connId); - if (connIds?.size === 0) { - approvalSessionToConnIds.delete(sessionKey); + const updateSubscription = ( + connId: string, + sessionKey: string, + owners?: SessionMessageSubscriptionOwners, + ) => { + let mode: SessionMessageSubscriptionMode | undefined; + const include = (interest: SessionMessageSubscriptionMode) => { + if (!mode) { + mode = { ...interest }; + } else { + mode.includeApprovals ||= interest.includeApprovals; + if (interest.mode !== "narration") { + mode.mode = undefined; + } + } + }; + for (const owner of owners?.values() ?? []) { + if (owner.committed) { + include(owner.committed.mode); + } + for (const interest of owner.inflight.values()) { + include(interest); + } } + setSubscription(connId, sessionKey, mode); }; const registry: SessionMessageSubscriberRegistry = { @@ -562,52 +600,49 @@ export function createSessionMessageSubscriberRegistry( } onSubscriptionChange?.(normalizedConnId); const states = - connections.get(normalizedConnId) ?? - new Map(); - const previous = states.get(normalizedSessionKey); - const state: ProvisionalSubscriptionState = - typeof previous === "object" ? previous : { base: previous, inflight: 0 }; - state.inflight += 1; - states.set(normalizedSessionKey, state); + connections.get(normalizedConnId) ?? new Map(); + const owners: SessionMessageSubscriptionOwners = + states.get(normalizedSessionKey) ?? new Map(); + const subscriptionId = opts?.subscriptionId; + const state: ProvisionalSubscriptionState = owners.get(subscriptionId) ?? { + inflight: new Map(), + }; + owners.set(subscriptionId, state); + states.set(normalizedSessionKey, owners); connections.set(normalizedConnId, states); subscriptionSequence += 1; const provisionalRecency = subscriptionSequence; - setMessageSubscription(normalizedConnId, normalizedSessionKey, true); - - setApprovalSubscription( - normalizedConnId, - normalizedSessionKey, - opts?.includeApprovals === true, - ); + const mode: SessionMessageSubscriptionMode = { + includeApprovals: opts?.includeApprovals === true, + mode: opts?.mode, + }; + state.inflight.set(provisionalRecency, mode); + updateSubscription(normalizedConnId, normalizedSessionKey, owners); let settled = false; const settle = (succeeded: boolean) => { - if (settled || connections.get(normalizedConnId)?.get(normalizedSessionKey) !== state) { + if ( + settled || + connections.get(normalizedConnId)?.get(normalizedSessionKey)?.get(subscriptionId) !== + state + ) { return; } settled = true; - if (succeeded) { - if (provisionalRecency >= (state.lastSuccess?.sequence ?? -Infinity)) { - state.lastSuccess = { - sequence: provisionalRecency, - includeApprovals: opts?.includeApprovals === true, - }; - } + if (succeeded && provisionalRecency >= (state.committed?.sequence ?? -Infinity)) { + state.committed = { + sequence: provisionalRecency, + mode, + }; } - state.inflight -= 1; - if (state.inflight > 0) { - return; - } - const committed = state.lastSuccess?.includeApprovals ?? state.base; - if (committed === undefined) { + state.inflight.delete(provisionalRecency); + if (!state.committed && state.inflight.size === 0) { onSubscriptionChange?.(normalizedConnId); - states.delete(normalizedSessionKey); - setMessageSubscription(normalizedConnId, normalizedSessionKey, false); - setApprovalSubscription(normalizedConnId, normalizedSessionKey, false); - } else { - states.set(normalizedSessionKey, committed); - setMessageSubscription(normalizedConnId, normalizedSessionKey, true); - setApprovalSubscription(normalizedConnId, normalizedSessionKey, committed); + owners.delete(subscriptionId); } + if (owners.size === 0) { + states.delete(normalizedSessionKey); + } + updateSubscription(normalizedConnId, normalizedSessionKey, owners); if (states.size === 0) { connections.delete(normalizedConnId); } @@ -620,7 +655,7 @@ export function createSessionMessageSubscriberRegistry( } return rollback; }, - unsubscribe: (connId: string, sessionKey: string) => { + unsubscribe: (connId: string, sessionKey: string, subscriptionId?: string) => { const normalizedConnId = connId.trim(); const normalizedSessionKey = sessionKey.trim(); if (!normalizedConnId || !normalizedSessionKey) { @@ -628,12 +663,15 @@ export function createSessionMessageSubscriberRegistry( } onSubscriptionChange?.(normalizedConnId); const states = connections.get(normalizedConnId); - states?.delete(normalizedSessionKey); + const owners = states?.get(normalizedSessionKey); + owners?.delete(subscriptionId); + if (owners?.size === 0) { + states?.delete(normalizedSessionKey); + } if (states?.size === 0) { connections.delete(normalizedConnId); } - setMessageSubscription(normalizedConnId, normalizedSessionKey, false); - setApprovalSubscription(normalizedConnId, normalizedSessionKey, false); + updateSubscription(normalizedConnId, normalizedSessionKey, owners); }, unsubscribeAll: (connId: string) => { const normalizedConnId = connId.trim(); @@ -647,14 +685,12 @@ export function createSessionMessageSubscriberRegistry( } connections.delete(normalizedConnId); for (const sessionKey of states.keys()) { - setMessageSubscription(normalizedConnId, sessionKey, false); - } - for (const sessionKey of states.keys()) { - setApprovalSubscription(normalizedConnId, sessionKey, false); + setSubscription(normalizedConnId, sessionKey); } }, get: (sessionKey) => sessionToConnIds.get(sessionKey.trim()) ?? empty, getApprovals: (sessionKey) => approvalSessionToConnIds.get(sessionKey.trim()) ?? empty, + getNarration: (sessionKey) => narrationSessionToConnIds.get(sessionKey.trim()) ?? empty, onChange: (listener) => { changeListeners.add(listener); return () => changeListeners.delete(listener); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 00b3e9632370..131bafe7c523 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -39,6 +39,7 @@ export const GATEWAY_EVENTS = [ "ui.command", "session.approval", "session.message", + "session.narration", "session.observer", "session.operation", "session.sharing", diff --git a/src/gateway/server-methods.canonical-validation.test.ts b/src/gateway/server-methods.canonical-validation.test.ts index e9b81404646a..76d48cec14ef 100644 --- a/src/gateway/server-methods.canonical-validation.test.ts +++ b/src/gateway/server-methods.canonical-validation.test.ts @@ -227,7 +227,7 @@ it("authorizes exact rows independently of bulk validation and fences dirty rows }); expect(respond).toHaveBeenCalledWith( true, - { subscribed: true, key: "agent:main:clean" }, + { subscribed: true, key: "agent:main:clean", agentId: "main" }, undefined, ); expect( diff --git a/src/gateway/server-methods.session-scoped-reads.test.ts b/src/gateway/server-methods.session-scoped-reads.test.ts index 12571b58e21f..6d6e04d10da2 100644 --- a/src/gateway/server-methods.session-scoped-reads.test.ts +++ b/src/gateway/server-methods.session-scoped-reads.test.ts @@ -113,7 +113,7 @@ describe("narrow session read owners", () => { } else if (row.visible) { expect(respond).toHaveBeenCalledExactlyOnceWith( true, - { subscribed: true, key: sessionKey }, + { subscribed: true, key: sessionKey, agentId: "main" }, undefined, ); } else { @@ -227,7 +227,7 @@ describe("narrow session read owners", () => { if (change === "current") { expect(respond).toHaveBeenCalledExactlyOnceWith( true, - { subscribed: true, key }, + { subscribed: true, key, agentId: "main" }, undefined, ); } else { diff --git a/src/gateway/server-methods/sessions-subscriptions.ts b/src/gateway/server-methods/sessions-subscriptions.ts index d325212dda4f..5c1a6f478985 100644 --- a/src/gateway/server-methods/sessions-subscriptions.ts +++ b/src/gateway/server-methods/sessions-subscriptions.ts @@ -155,7 +155,12 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { const rollbackSubscription = context.subscribeSessionMessageEvents( connId, subscriptionKey, - { includeApprovals: true, provisional: true }, + { + includeApprovals: true, + provisional: true, + mode: p.mode, + subscriptionId: p.subscriptionId, + }, ); try { prepared = await context.listSessionPendingApprovals?.(subscriptionKey, client); @@ -210,6 +215,8 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { } else { const rollback = context.subscribeSessionMessageEvents(connId, subscriptionKey, { provisional: true, + mode: p.mode, + subscriptionId: p.subscriptionId, }); try { read?.assertCurrent(); @@ -225,6 +232,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { { subscribed: true, key: canonicalKey, + agentId: requestedAgentId, ...(p.includeApprovals === true ? { approvalReplay, @@ -235,7 +243,11 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { ); return; } - respond(true, { subscribed: false, key: canonicalKey }, undefined); + respond( + true, + { subscribed: false, key: canonicalKey, agentId: requestedAgentId }, + undefined, + ); } catch (error) { if (!(error instanceof SessionMutationAuthorizationChangedError)) { throw error; @@ -271,7 +283,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { }); const subscriptionKey = resolveSessionSubscriptionKey(canonicalKey, requestedAgentId); if (connId) { - context.unsubscribeSessionMessageEvents(connId, subscriptionKey); + context.unsubscribeSessionMessageEvents(connId, subscriptionKey, p.subscriptionId); } respond(true, { subscribed: false, key: canonicalKey }, undefined); }, diff --git a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts index 06e957a04d0a..c6350dd51bda 100644 --- a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts +++ b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts @@ -841,7 +841,11 @@ describe("sessions.abort agent scope", () => { expect(subscribeSessionMessageEvents).toHaveBeenCalledWith("conn-sub", "agent:work:global", { provisional: true, }); - expect(respond).toHaveBeenCalledWith(true, { subscribed: true, key: "global" }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + { subscribed: true, key: "global", agentId: "work" }, + undefined, + ); }); it("aborts an active legacy-key run owned by the configured default agent", async () => { diff --git a/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts b/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts index faffb226342b..b43e12cde255 100644 --- a/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts +++ b/src/gateway/server-methods/sessions.messages-subscribe-approvals.test.ts @@ -230,6 +230,116 @@ describe("sessions.messages.subscribe approval opt-in", () => { vi.useRealTimers(); }); + it.each([false, true])( + "replaces narration intent without changing approval delivery (approvals=%s)", + async (includeApprovals) => { + const key = "agent:main:child"; + const registry = createSessionMessageSubscriberRegistry(); + const client = createClient({ scopes: ["operator.admin"] }); + const { context } = createContext({ + replay: { sessionKey: key, updatedAtMs: 42, approvals: [], truncated: false }, + }); + context.subscribeSessionMessageEvents = registry.subscribe; + const body = { key, ...(includeApprovals ? { includeApprovals: true } : {}) }; + + const narration = await subscribe({ + body: { ...body, mode: "narration" }, + client, + context, + }); + expect(narration).toHaveBeenCalledWith(true, expect.any(Object), undefined); + expect([...registry.getNarration(key)]).toEqual([client.connId]); + + const foreground = await subscribe({ body, client, context }); + expect(foreground).toHaveBeenCalledWith(true, expect.any(Object), undefined); + expect([...registry.get(key)]).toEqual([client.connId]); + expect([...registry.getNarration(key)]).toEqual([]); + expect([...registry.getApprovals(key)]).toEqual(includeApprovals ? [client.connId] : []); + }, + ); + + it.each(["foreground", "narration"])( + "retains the other global observer after releasing %s", + async (releaseId) => { + const key = "agent:work:global"; + const registry = createSessionMessageSubscriberRegistry(); + const client = createClient({ scopes: ["operator.admin"] }); + const { context, listSessionPendingApprovals } = createContext({ + globalScope: true, + agents: [{ id: "main", default: true }, { id: "work" }], + replay: { sessionKey: key, updatedAtMs: 42, approvals: [], truncated: false }, + }); + context.subscribeSessionMessageEvents = registry.subscribe; + context.unsubscribeSessionMessageEvents = registry.unsubscribe; + const foreground = await subscribe({ + body: { key, subscriptionId: "foreground" }, + client, + context, + }); + expect(foreground).toHaveBeenCalledWith( + true, + { subscribed: true, key, agentId: "work" }, + undefined, + ); + const narration = await subscribe({ + body: { + key: "global", + agentId: "work", + subscriptionId: "narration", + mode: "narration", + includeApprovals: true, + }, + client, + context, + }); + expect(narration).toHaveBeenCalledWith( + true, + expect.objectContaining({ key: "global", agentId: "work" }), + undefined, + ); + expect([...registry.getNarration(key)]).toEqual([]); + expect([...registry.getApprovals(key)]).toEqual([client.connId]); + + listSessionPendingApprovals.mockRejectedValueOnce(new Error("replay failed")); + const failed = await subscribe({ + body: { key, subscriptionId: "failed", mode: "narration", includeApprovals: true }, + client, + context, + }); + expect(failed).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE" }), + ); + expect([...registry.getNarration(key)]).toEqual([]); + + const respond = vi.fn(); + await expectDefined( + sessionSubscriptionHandlers["sessions.messages.unsubscribe"], + "session unsubscribe handler", + )({ + req: { id: "unsubscribe-owner" } as never, + params: { + key: releaseId === "foreground" ? key : "global", + agentId: "work", + subscriptionId: releaseId, + }, + respond, + context, + client, + isWebchatConnect: () => false, + } satisfies GatewayRequestHandlerOptions); + expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined); + expect([...registry.get(key)]).toEqual([client.connId]); + expect([...registry.getNarration(key)]).toEqual( + releaseId === "foreground" ? [client.connId] : [], + ); + expect([...registry.getApprovals(key)]).toEqual( + releaseId === "foreground" ? [client.connId] : [], + ); + }, + ); + it("allows an admin without a paired device and uses the exact scoped subscription key", async () => { const approvalReplay = { sessionKey: "agent:work:global", @@ -262,7 +372,7 @@ describe("sessions.messages.subscribe approval opt-in", () => { }); expect(respond).toHaveBeenCalledWith( true, - { subscribed: true, key: "global", approvalReplay }, + { subscribed: true, key: "global", agentId: "work", approvalReplay }, undefined, ); expect(loadSessionEntryMock).not.toHaveBeenCalled(); @@ -305,7 +415,7 @@ describe("sessions.messages.subscribe approval opt-in", () => { expect(listSessionPendingApprovals).toHaveBeenCalledTimes(2); expect(respond).toHaveBeenCalledExactlyOnceWith( true, - { subscribed: true, key: "agent:main:child", approvalReplay: currentReplay }, + { subscribed: true, key: "agent:main:child", agentId: "main", approvalReplay: currentReplay }, undefined, ); }); @@ -361,7 +471,7 @@ describe("sessions.messages.subscribe approval opt-in", () => { ); expect(respond).toHaveBeenCalledWith( true, - { subscribed: true, key: "agent:main:child", approvalReplay }, + { subscribed: true, key: "agent:main:child", agentId: "main", approvalReplay }, undefined, ); }); @@ -417,7 +527,7 @@ describe("sessions.messages.subscribe approval opt-in", () => { ); expect(respond).toHaveBeenCalledWith( true, - { subscribed: true, key: "agent:main:child" }, + { subscribed: true, key: "agent:main:child", agentId: "main" }, undefined, ); expect(respond.mock.calls[0]?.[1]).not.toHaveProperty("approvalReplay"); @@ -439,7 +549,7 @@ describe("sessions.messages.subscribe approval opt-in", () => { ); expect(respond).toHaveBeenCalledWith( true, - { subscribed: true, key: "agent:main:work" }, + { subscribed: true, key: "agent:main:work", agentId: "main" }, undefined, ); expect(loadSessionEntryMock).not.toHaveBeenCalled(); diff --git a/src/gateway/server-methods/sessions.messages-subscribe-perf.test.ts b/src/gateway/server-methods/sessions.messages-subscribe-perf.test.ts index 449f7a2186ea..f6b7db51c885 100644 --- a/src/gateway/server-methods/sessions.messages-subscribe-perf.test.ts +++ b/src/gateway/server-methods/sessions.messages-subscribe-perf.test.ts @@ -142,6 +142,7 @@ it("shares approval replay across 64 subscribers during unrelated approval activ { subscribed: true, key: sessionKey, + agentId: "main", approvalReplay: { sessionKey, updatedAtMs: 5000, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 175eb5119427..be4083ee697d 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -57,7 +57,12 @@ import type { ChannelRuntimeSnapshotOptions, StartChannelOptions, } from "../server-channel-runtime.types.js"; -import type { ChatRunEntry, ChatRunRegistration, ChatRunState } from "../server-chat-state.js"; +import type { + ChatRunEntry, + ChatRunRegistration, + ChatRunState, + SessionMessageSubscriberRegistry, +} from "../server-chat-state.js"; import type { GatewayCronServiceContract } from "../server-cron-contract.js"; import type { GatewayApprovalEventPublisher, @@ -359,12 +364,8 @@ type GatewayTransportContext = { subscribeSessionEvents: (connId: string) => void; unsubscribeSessionEvents: (connId: string) => void; forgetConnectionAncestors: (connId: string) => void; - subscribeSessionMessageEvents: ( - connId: string, - sessionKey: string, - opts?: { includeApprovals?: boolean; provisional?: boolean }, - ) => ((() => void) & { commit: () => void }) | undefined; - unsubscribeSessionMessageEvents: (connId: string, sessionKey: string) => void; + subscribeSessionMessageEvents: SessionMessageSubscriberRegistry["subscribe"]; + unsubscribeSessionMessageEvents: SessionMessageSubscriberRegistry["unsubscribe"]; unsubscribeAllSessionEvents: (connId: string) => void; getSessionEventSubscriberConnIds: () => ReadonlySet; registerToolEventRecipient: (runId: string, connId: string) => void; diff --git a/tsconfig.json b/tsconfig.json index aa199f3408f5..44e212c5c45b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -232,6 +232,7 @@ "@openclaw/normalization-core/utf16-slice": [ "./packages/normalization-core/src/utf16-slice.ts" ], + "@openclaw/normalization-core/uuid": ["./packages/normalization-core/src/uuid.ts"], "@openclaw/normalization-core/home-dir": [ "./packages/normalization-core/src/home-dir.ts" ], diff --git a/ui/src/components/app-sidebar-session-narration-retry.test.ts b/ui/src/components/app-sidebar-session-narration-retry.test.ts index 62f55372ea43..d9feaa7136aa 100644 --- a/ui/src/components/app-sidebar-session-narration-retry.test.ts +++ b/ui/src/components/app-sidebar-session-narration-retry.test.ts @@ -37,7 +37,7 @@ function releaseFixture( }); const coordinator = new GatewaySessionMessageSubscriptionCoordinator({ request }); const source = { - subscribeMessages: (key: string, options?: { agentId?: string | null }) => + subscribeMessages: (key: string, options?: Parameters[1]) => coordinator.acquire(key, options), unsubscribeMessages: vi.fn((handle: Awaited>) => coordinator.release(handle), @@ -141,12 +141,27 @@ describe("sidebar narration subscription retries", () => { expect(request).toHaveBeenCalledTimes(2); expect(wireKeys.size).toBe(1); expect(vi.getTimerCount()).toBe(0); - const pane = await coordinator.acquire(input.rows[0]!.key); + const key = input.rows[0]!.key; + const pane = await coordinator.acquire(key); controller.disconnect(); await vi.advanceTimersByTimeAsync(0); - expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([ + [ + "sessions.messages.subscribe", + { key, mode: "narration", subscriptionId: expect.any(String) }, + ], + ["sessions.messages.unsubscribe", { key, subscriptionId: expect.any(String) }], + ["sessions.messages.subscribe", { key, subscriptionId: expect.any(String) }], + ]); + expect(wireKeys.has(key)).toBe(true); + expect(vi.getTimerCount()).toBe(0); server.failure = null; await coordinator.release(pane); + expect(request).toHaveBeenCalledTimes(4); + expect(request.mock.calls.at(-1)?.slice(0, 2)).toEqual([ + "sessions.messages.unsubscribe", + { key, subscriptionId: expect.any(String) }, + ]); expect(wireKeys.size).toBe(0); }); @@ -362,10 +377,14 @@ describe("sidebar narration subscription retries", () => { ) .mockResolvedValue({ key: "agent:main:run" }); const coordinator = new GatewaySessionMessageSubscriptionCoordinator({ request }); + const subscribeMessages = vi.fn(coordinator.acquire.bind(coordinator)); const { controller } = createRunningNarrationController({ - subscribeMessages: (key, options) => coordinator.acquire(key, options), + subscribeMessages, unsubscribeMessages: (handle) => coordinator.release(handle), }); + await expect(subscribeMessages.mock.results[0]?.value).rejects.toBeInstanceOf( + GatewayProtocolRequestTimeoutError, + ); await vi.advanceTimersByTimeAsync(0); expect(request.mock.calls.map(([method]) => method)).toEqual([ "sessions.messages.subscribe", diff --git a/ui/src/components/app-sidebar-session-narration.test.ts b/ui/src/components/app-sidebar-session-narration.test.ts index f0b600013e87..aad46afcfd30 100644 --- a/ui/src/components/app-sidebar-session-narration.test.ts +++ b/ui/src/components/app-sidebar-session-narration.test.ts @@ -344,11 +344,133 @@ describe("SidebarSessionNarrationController", () => { await Promise.resolve(); expect(subscribeMessages).toHaveBeenCalledTimes(1); - expect(subscribeMessages).toHaveBeenCalledWith("agent:main:active", { agentId: undefined }); + expect(subscribeMessages).toHaveBeenCalledWith("agent:main:active", { + agentId: undefined, + mode: "narration", + }); controller.disconnect(); }); + it("renders paced digests immediately and keeps the final line after queued tool activity", async () => { + const source = { + subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })), + unsubscribeMessages: vi.fn(() => Promise.resolve()), + }; + const { controller, updates } = createRunningNarrationController(source); + const digest = (text: string) => + gatewayEvent("session.narration", { sessionKey: "agent:main:run", runId: "run-1", text }); + const tool = () => + gatewayEvent("session.tool", { + sessionKey: "agent:main:run", + runId: "run-1", + stream: "tool", + data: { phase: "start", name: "read" }, + }); + + controller.handleEvent(tool()); + controller.handleEvent(digest("**Reading** the current implementation.")); + expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading the current implementation."); + await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS); + controller.handleEvent(digest("Earlier paragraph.\n\nChecks are **passing**.")); + expect(updates.at(-1)?.get("agent:main:run")).toBe("Checks are passing."); + + controller.handleEvent(tool()); + await vi.advanceTimersByTimeAsync(100); + controller.handleEvent(digest("Final result is correct.")); + controller.handleEvent( + gatewayEvent("chat", { + sessionKey: "agent:main:run", + runId: "run-1", + state: "final", + message: { role: "assistant", content: "Final result is correct." }, + }), + ); + expect(updates.at(-1)?.get("agent:main:run")).toBe("Final result is correct."); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS); + expect(updates.at(-1)?.get("agent:main:run")).toBe("Final result is correct."); + controller.disconnect(); + }); + + it("scopes digest replacements and retracts hidden content across run boundaries", () => { + const source = { + subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })), + unsubscribeMessages: vi.fn(() => Promise.resolve()), + }; + const { controller, updates } = createRunningNarrationController(source); + const digest = (text: string) => + gatewayEvent("session.narration", { sessionKey: "agent:main:run", runId: "run-1", text }); + + controller.handleEvent(digest("First visible progress.")); + controller.handleEvent( + gatewayEvent("session.narration", { + sessionKey: "agent:main:other", + runId: "run-1", + text: "An unrelated session.", + }), + ); + controller.handleEvent( + gatewayEvent("session.narration", { sessionKey: "agent:main:run", text: "No run identity." }), + ); + expect(updates.at(-1)?.get("agent:main:run")).toBe("First visible progress."); + + for (const text of ["", "REPLY_SKIP", "HEARTBEAT_OK"]) { + controller.handleEvent(digest("Visible draft.")); + controller.handleEvent(digest(text)); + expect(updates.at(-1)?.has("agent:main:run")).toBe(false); + } + controller.handleEvent(digest("Previous run result.")); + controller.handleEvent( + gatewayEvent("agent", { + sessionKey: "agent:main:run", + runId: "run-2", + stream: "lifecycle", + data: { phase: "start" }, + }), + ); + expect(updates.at(-1)?.has("agent:main:run")).toBe(false); + controller.handleEvent( + gatewayEvent("session.narration", { + sessionKey: "agent:main:run", + runId: "run-2", + text: "New run progress.", + }), + ); + expect(updates.at(-1)?.get("agent:main:run")).toBe("New run progress."); + controller.disconnect(); + }); + + it.each(["final", "aborted", "error"])( + "settles queued full-owner narration immediately on %s", + (state) => { + const source = { + subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })), + unsubscribeMessages: vi.fn(() => Promise.resolve()), + }; + const { controller, updates } = createRunningNarrationController(source); + controller.handleEvent(chatDelta("Initial work.")); + controller.handleEvent(chatDelta("Last visible result.")); + expect(updates.at(-1)?.get("agent:main:run")).toBe("Initial work."); + + controller.handleEvent( + gatewayEvent("chat", { + sessionKey: "agent:main:run", + runId: "run-1", + state, + ...(state === "final" + ? { message: { role: "assistant", content: "Final corrected result." } } + : {}), + }), + ); + expect(updates.at(-1)?.get("agent:main:run")).toBe( + state === "final" ? "Final corrected result." : "Last visible result.", + ); + expect(vi.getTimerCount()).toBe(0); + controller.disconnect(); + }, + ); + it("hands subtitle ownership only to a run-identified digest", async () => { const source = { subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })), @@ -440,6 +562,13 @@ describe("SidebarSessionNarrationController", () => { data: { name: "test" }, }), ); + controller.handleEvent( + gatewayEvent("session.narration", { + sessionKey: "agent:main:run", + runId: "run-1", + text: "Raw narration does not replace an observer headline.", + }), + ); expect(lines.at(-1)?.has("agent:main:run")).toBe(false); controller.handleEvent( @@ -856,7 +985,10 @@ describe("SidebarSessionNarrationController", () => { await Promise.resolve(); expect(unsubscribeMessages).toHaveBeenCalledWith({ key: "global", agentId: "main" }); - expect(subscribeMessages).toHaveBeenLastCalledWith("global", { agentId: "research" }); + expect(subscribeMessages).toHaveBeenLastCalledWith("global", { + agentId: "research", + mode: "narration", + }); expect(updates.at(-1)?.has("global")).toBe(false); }); diff --git a/ui/src/components/app-sidebar-session-narration.ts b/ui/src/components/app-sidebar-session-narration.ts index 30fda0c49b9c..a25be64cc61b 100644 --- a/ui/src/components/app-sidebar-session-narration.ts +++ b/ui/src/components/app-sidebar-session-narration.ts @@ -6,24 +6,17 @@ import { import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { Value } from "typebox/value"; import { + SessionNarrationEventSchema, SessionObserverDigestSchema, type SessionObserverDigest, } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END, - stripInternalRuntimeContext, } from "../../../src/agents/internal-runtime-context.js"; -import { - isSuppressedControlReplyLeadFragment, - isSuppressedControlReplyText, - stripSuppressedControlReplyToken, -} from "../../../src/gateway/control-reply-text.js"; import { extractAssistantPhaseText } from "../../../src/shared/chat-message-content.js"; -import { stripInlineDirectiveTagsForDisplay } from "../../../src/utils/directive-tags.js"; import type { GatewayEventFrame } from "../api/gateway.ts"; import { t } from "../i18n/index.ts"; -import { stripHeartbeatTokenForDisplay } from "../lib/chat/heartbeat-display.ts"; import { pickFreshestObserverDigest } from "../lib/observer-digest.ts"; import type { SessionCapability } from "../lib/sessions/index.ts"; import { @@ -90,26 +83,6 @@ export type SidebarNarrationSyncInput = { agentId: string; }; -// TRANSITIONAL(marker-retirement): live narration strips inline markers because -// streamed drafts still carry them mid-run; persisted data is already clean. -// Drop the stripInlineDirectiveTagsForDisplay call when the visibleReplies -// default flips to "message_tool". -function normalizeSidebarNarrationText(text: string): string | null { - const displayText = stripSuppressedControlReplyToken( - stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(text).text), - ); - const heartbeat = stripHeartbeatTokenForDisplay(displayText); - if ( - !displayText || - isSuppressedControlReplyText(displayText) || - isSuppressedControlReplyLeadFragment(displayText) || - heartbeat.shouldSkip - ) { - return null; - } - return heartbeat.text; -} - function trailingInternalDelimiterPrefix(text: string): string { const tokens = [INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END]; for ( @@ -253,6 +226,10 @@ export class SidebarSessionNarrationController { this.handleChatEvent(event.payload); return; } + if (event.event === "session.narration") { + this.handleNarrationEvent(event.payload); + return; + } if (event.event === "session.observer") { this.handleObserverEvent(event.payload); return; @@ -296,6 +273,7 @@ export class SidebarSessionNarrationController { try { const subscription = await source.subscribeMessages(key, { agentId: pending.agentId ?? undefined, + mode: "narration", }); const owned = { key, source, connectionIdentity, subscription }; const current = this.pendingSubscriptions.get(key) === pending; @@ -459,6 +437,8 @@ export class SidebarSessionNarrationController { if (this.observerDigests.has(key)) { return; } + const immediate = + record.state === "final" || record.state === "aborted" || record.state === "error"; const message = record.message as Record | undefined; if (message && typeof message.role === "string" && message.role !== "assistant") { return; @@ -479,6 +459,7 @@ export class SidebarSessionNarrationController { streamLength: replacement.length, fragment: replacement, reset: true, + immediate, }); return; } @@ -489,12 +470,14 @@ export class SidebarSessionNarrationController { streamLength: messageText.length, fragment: appends ? deltaText : messageText, reset: !appends, + immediate, }); } else if (consumed > 0) { this.publishText(key, { streamLength: consumed + deltaText.length, fragment: deltaText, reset: false, + immediate, }); } // consumed === 0 with a bare delta: a mid-run join may sit INSIDE an @@ -507,13 +490,37 @@ export class SidebarSessionNarrationController { streamLength: messageText.length, fragment: messageText, reset: true, + immediate, }); + } else if (immediate) { + const pending = this.throttles.get(key)?.pending; + if (pending) { + this.publishImmediate(key, pending); + } } } + private handleNarrationEvent(payload: unknown): void { + if (!Value.Check(SessionNarrationEventSchema, payload)) { + return; + } + const key = this.matchingDesiredKey(payload.sessionKey, payload.agentId); + if (!key) { + return; + } + this.observeRun(key, payload.runId); + if (this.observerDigests.has(key)) { + return; + } + // The Gateway bounds already-sanitized text and owns digest pacing. Retire + // any full-owner stream and pending tool line before publishing its snapshot. + this.streams.delete(key); + this.publishImmediate(key, { kind: "text", text: payload.text }); + } + private publishText( key: string, - update: { streamLength: number; fragment: string; reset: boolean }, + update: { streamLength: number; fragment: string; reset: boolean; immediate?: boolean }, ): void { if (update.streamLength <= 0) { if (update.reset) { @@ -554,7 +561,12 @@ export class SidebarSessionNarrationController { nextVisibleText.length > SIDEBAR_NARRATION_BUFFER_CHARS ? sliceUtf16Safe(nextVisibleText, -SIDEBAR_NARRATION_BUFFER_CHARS) : nextVisibleText; - this.publishThrottled(key, { kind: "text", text: stream.visibleText }); + const activity: NarrationActivity = { kind: "text", text: stream.visibleText }; + if (update.immediate) { + this.publishImmediate(key, activity); + } else { + this.publishThrottled(key, activity); + } } private stripInternalRuntimeFragment(stream: NarrationStream, fragment: string): string { @@ -611,15 +623,12 @@ export class SidebarSessionNarrationController { return; } const record = payload as Record; - if (record.stream !== "tool") { - return; - } const key = this.matchingDesiredKey(record.sessionKey, record.agentId); if (!key) { return; } this.observeRun(key, record.runId); - if (this.observerDigests.has(key)) { + if (record.stream !== "tool" || this.observerDigests.has(key)) { return; } const data = record.data as Record | undefined; @@ -672,11 +681,7 @@ export class SidebarSessionNarrationController { const now = Date.now(); const throttle = this.throttles.get(key); if (!throttle || now - throttle.lastPublishedAt >= SIDEBAR_NARRATION_THROTTLE_MS) { - if (throttle?.timer) { - globalThis.clearTimeout(throttle.timer); - } - this.throttles.set(key, { lastPublishedAt: now, pending: null, timer: null }); - this.publishActivity(key, activity); + this.publishImmediate(key, activity); return; } throttle.pending = activity; @@ -698,13 +703,18 @@ export class SidebarSessionNarrationController { ); } + private publishImmediate(key: string, activity: NarrationActivity): void { + const timer = this.throttles.get(key)?.timer; + if (timer) { + globalThis.clearTimeout(timer); + } + this.throttles.set(key, { lastPublishedAt: Date.now(), pending: null, timer: null }); + this.publishActivity(key, activity); + } + private publishActivity(key: string, activity: NarrationActivity): void { - const safeText = activity.kind === "text" ? normalizeSidebarNarrationText(activity.text) : null; - const line = safeText - ? deriveSidebarNarrationLine(safeText) - : activity.kind === "line" - ? activity.line - : ""; + const line = + activity.kind === "text" ? deriveSidebarNarrationLine(activity.text) : activity.line; if (line) { if (this.lines.get(key) !== line) { this.lines.set(key, line); diff --git a/ui/src/components/sidebar-narration-line.ts b/ui/src/components/sidebar-narration-line.ts index f16d61897478..ce661ae877f3 100644 --- a/ui/src/components/sidebar-narration-line.ts +++ b/ui/src/components/sidebar-narration-line.ts @@ -1,14 +1,46 @@ import { flattenMarkdownToPlainText } from "@openclaw/normalization-core/markdown-plain-text"; +import { stripInternalRuntimeContext } from "../../../src/agents/internal-runtime-context.js"; +import { + isSuppressedControlReplyLeadFragment, + isSuppressedControlReplyText, + stripSuppressedControlReplyToken, +} from "../../../src/gateway/control-reply-text.js"; +import { stripInlineDirectiveTagsForDisplay } from "../../../src/utils/directive-tags.js"; +import { stripHeartbeatTokenForDisplay } from "../lib/chat/heartbeat-display.ts"; import { clampText } from "../lib/format.ts"; const SIDEBAR_NARRATION_MAX_LENGTH = 120; +// TRANSITIONAL(marker-retirement): live narration strips inline markers because +// streamed drafts still carry them mid-run; persisted data is already clean. +// Drop the stripInlineDirectiveTagsForDisplay call when the visibleReplies +// default flips to "message_tool". +function normalizeSidebarNarrationText(text: string): string | null { + const displayText = stripSuppressedControlReplyToken( + stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(text).text), + ); + const heartbeat = stripHeartbeatTokenForDisplay(displayText); + if ( + !displayText || + isSuppressedControlReplyText(displayText) || + isSuppressedControlReplyLeadFragment(displayText) || + heartbeat.shouldSkip + ) { + return null; + } + return heartbeat.text; +} + /** Compact the newest prose into one quiet, stable sidebar line. */ export function deriveSidebarNarrationLine(text: string): string { + const displayText = normalizeSidebarNarrationText(text); + if (!displayText) { + return ""; + } // Fences are dropped before the paragraph split, not just by the shared // flattener: a fenced block contains blank lines, so splitting first would // let code fragments become the "newest paragraph" and win the line. - const paragraphs = text.replace(/```[\s\S]*?```/g, " ").split(/\n\s*\n/); + const paragraphs = displayText.replace(/```[\s\S]*?```/g, " ").split(/\n\s*\n/); let paragraph = ""; for (let index = paragraphs.length - 1; index >= 0; index -= 1) { paragraph = flattenMarkdownToPlainText(paragraphs[index] ?? ""); diff --git a/ui/src/lib/sessions/index-subscriptions.test.ts b/ui/src/lib/sessions/index-subscriptions.test.ts index 03a1a902aa59..224564faf0b0 100644 --- a/ui/src/lib/sessions/index-subscriptions.test.ts +++ b/ui/src/lib/sessions/index-subscriptions.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../../test/helpers/promise.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { createTestSessionCapability } from "./session-capability.test-support.ts"; +import type { SessionCapability } from "./session-capability.ts"; import { createSessionScopedOperations } from "./session-scoped-operations.ts"; const subscriptionRequestOptions = { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }; @@ -26,6 +27,78 @@ function createGateway(client: GatewayBrowserClient) { } describe("createSessionCapability message subscriptions", () => { + it("retains the requested subscription intent while admission is pending", async () => { + const request = vi.fn(async (_method: string, params?: Record) => ({ + key: params?.key, + agentId: params?.agentId, + })); + const client = { request } as unknown as GatewayBrowserClient; + const sessions = createTestSessionCapability(createGateway(client)); + const options: NonNullable[1]> = { + agentId: " Main ", + mode: "narration", + includeApprovals: true, + }; + const pending = sessions.subscribeMessages("global", options); + options.agentId = "work"; + options.mode = undefined; + options.includeApprovals = false; + const subscription = await pending; + + expect(request).toHaveBeenCalledExactlyOnceWith( + "sessions.messages.subscribe", + { + key: "global", + agentId: "main", + mode: "narration", + includeApprovals: true, + subscriptionId: expect.any(String), + }, + subscriptionRequestOptions, + ); + await sessions.unsubscribeMessages(subscription); + sessions.dispose(); + }); + + it.each(["reconnect", "dispose"])( + "does not create a wire observer when %s retires an acquisition before admission", + async (retirement) => { + const request = vi.fn(async (_method: string, params?: Record) => ({ + key: params?.key, + })); + const client = { request } as unknown as GatewayBrowserClient; + let scope = { client, epoch: 0 }; + const operations = createSessionScopedOperations({ + notifyCreated: vi.fn(), + reportError: vi.fn(), + connection: { + capture: () => scope, + isCurrent: (captured) => captured === scope, + }, + reconcileMutation: async () => ({ status: "stale" }), + }); + const acquisition = operations.subscribeMessages("agent:main:old"); + if (retirement === "dispose") { + operations.dispose(); + } else { + scope = { client, epoch: 1 }; + operations.retireConnection(client); + } + await expect(acquisition).rejects.toThrow("replaced Gateway connection"); + expect(request).not.toHaveBeenCalled(); + if (retirement === "reconnect") { + const current = await operations.subscribeMessages("agent:main:current"); + expect(request).toHaveBeenCalledExactlyOnceWith( + "sessions.messages.subscribe", + { key: "agent:main:current", subscriptionId: expect.any(String) }, + subscriptionRequestOptions, + ); + await operations.unsubscribeMessages(current); + } + operations.dispose(); + }, + ); + it("retries a rejected unsubscribe against its original live Gateway observer", async () => { let unsubscribeCalls = 0; const request = vi.fn(async (method: string, params?: Record) => { @@ -52,19 +125,19 @@ describe("createSessionCapability message subscriptions", () => { expect(request).toHaveBeenNthCalledWith( 2, "sessions.messages.unsubscribe", - { key: "agent:main:main" }, + { key: "agent:main:main", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); expect(request).toHaveBeenNthCalledWith( 3, "sessions.messages.unsubscribe", - { key: "agent:main:main" }, + { key: "agent:main:main", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); sessions.dispose(); }); - it("shares canonical observers across capabilities without releasing the live owner", async () => { + it("shares narration and foreground observers across capabilities without retiring the full stream", async () => { const request = vi.fn(async (method: string) => { if (method === "sessions.messages.subscribe") { return { key: "agent:main:main" }; @@ -80,21 +153,28 @@ describe("createSessionCapability message subscriptions", () => { const second = createTestSessionCapability(gateway); const [firstLease, secondLease] = await Promise.all([ - first.subscribeMessages("main"), + first.subscribeMessages("main", { mode: "narration" }), second.subscribeMessages("agent:main:main"), ]); - expect(request).toHaveBeenCalledExactlyOnceWith( + expect(request).toHaveBeenNthCalledWith( + 1, "sessions.messages.subscribe", - { key: "main" }, + { key: "main", mode: "narration", subscriptionId: expect.any(String) }, + subscriptionRequestOptions, + ); + expect(request).toHaveBeenNthCalledWith( + 2, + "sessions.messages.subscribe", + { key: "agent:main:main", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); await first.unsubscribeMessages(firstLease); - expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledTimes(2); await second.unsubscribeMessages(secondLease); expect(request).toHaveBeenLastCalledWith( "sessions.messages.unsubscribe", - { key: "agent:main:main" }, + { key: "agent:main:main", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); first.dispose(); @@ -131,7 +211,7 @@ describe("createSessionCapability message subscriptions", () => { expect(request).toHaveBeenNthCalledWith( 2, "sessions.messages.subscribe", - { key: "main", includeApprovals: true }, + { key: "main", includeApprovals: true, subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); await sessions.unsubscribeMessages(approval); @@ -189,18 +269,18 @@ describe("createSessionCapability message subscriptions", () => { expect(request).toHaveBeenNthCalledWith( 1, "sessions.messages.subscribe", - { key: keyFor("main"), agentId: "main" }, + { key: keyFor("main"), agentId: "main", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); expect(request).toHaveBeenNthCalledWith( 2, "sessions.messages.subscribe", - { key: keyFor("work"), agentId: "work" }, + { key: keyFor("work"), agentId: "work", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); expect(request).toHaveBeenLastCalledWith( "sessions.messages.unsubscribe", - { key: "global", agentId: "work" }, + { key: "global", agentId: "work", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); sessions.dispose(); @@ -238,7 +318,7 @@ describe("createSessionCapability message subscriptions", () => { expect(request).toHaveBeenNthCalledWith( 2, "sessions.messages.unsubscribe", - { key: "main" }, + { key: "main", subscriptionId: expect.any(String) }, subscriptionRequestOptions, ); expect(forceReconnect).toHaveBeenCalledExactlyOnceWith("session subscription recovery failed"); diff --git a/ui/src/lib/sessions/session-capability.ts b/ui/src/lib/sessions/session-capability.ts index 41197bf69342..959fabc8ed1e 100644 --- a/ui/src/lib/sessions/session-capability.ts +++ b/ui/src/lib/sessions/session-capability.ts @@ -341,7 +341,7 @@ export type SessionCapability = { ) => Promise; subscribeMessages: ( key: string, - options?: { agentId?: string | null; includeApprovals?: boolean }, + options?: { agentId?: string | null; includeApprovals?: boolean; mode?: "narration" }, ) => Promise; unsubscribeMessages: (subscription: SessionMessageSubscription) => Promise; rewind: ( diff --git a/ui/src/lib/sessions/session-message-subscriptions.runtime.ts b/ui/src/lib/sessions/session-message-subscriptions.runtime.ts new file mode 100644 index 000000000000..03557ea0f166 --- /dev/null +++ b/ui/src/lib/sessions/session-message-subscriptions.runtime.ts @@ -0,0 +1,5 @@ +export { + getGatewaySessionMessageSubscriptionCoordinator, + releaseGatewaySessionMessageSubscription, + resetGatewaySessionMessageSubscriptionCoordinator, +} from "@openclaw/gateway-client/browser"; diff --git a/ui/src/lib/sessions/session-scoped-operations.ts b/ui/src/lib/sessions/session-scoped-operations.ts index 26a8501db4da..385b10825e30 100644 --- a/ui/src/lib/sessions/session-scoped-operations.ts +++ b/ui/src/lib/sessions/session-scoped-operations.ts @@ -1,9 +1,4 @@ -import { - GatewayProtocolRequestTimeoutError, - getGatewaySessionMessageSubscriptionCoordinator, - releaseGatewaySessionMessageSubscription, - resetGatewaySessionMessageSubscriptionCoordinator, -} from "@openclaw/gateway-client/browser"; +import { GatewayProtocolRequestTimeoutError } from "@openclaw/gateway-client/browser"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { requestSessionRecovery } from "./recover.ts"; import type { @@ -36,6 +31,18 @@ const retiredFailedSubscriptionRecoveries = new WeakSet(); export function createSessionScopedOperations(host: SessionScopedOperationsHost) { const ownedSubscriptions = new Set(); + type SubscriptionRuntime = typeof import("./session-message-subscriptions.runtime.ts"); + let subscriptionRuntime: SubscriptionRuntime | undefined; + let subscriptionRuntimeLoading: Promise | undefined; + let disposed = false; + const loadSubscriptionRuntime = () => + (subscriptionRuntimeLoading ??= import("./session-message-subscriptions.runtime.ts").then( + (runtime) => (subscriptionRuntime = runtime), + (error: unknown) => { + subscriptionRuntimeLoading = undefined; + throw error; + }, + )); const recover = async (params: { key: string; agentId?: string }) => { const scope = host.connection.capture(); @@ -94,32 +101,41 @@ export function createSessionScopedOperations(host: SessionScopedOperationsHost) requestCurrent((client) => requestSessionFileSet(client, key, path, content, options)); const unsubscribeMessages = async (subscription: SessionMessageSubscription): Promise => { - await releaseGatewaySessionMessageSubscription(subscription); + const runtime = subscriptionRuntime ?? (await loadSubscriptionRuntime()); + await runtime.releaseGatewaySessionMessageSubscription(subscription); ownedSubscriptions.delete(subscription); }; const subscribeMessages = async ( key: string, - options: { agentId?: string | null; includeApprovals?: boolean } = {}, + options: NonNullable[1]> = {}, ): Promise => { const scope = host.connection.capture(); - if (!scope) { + if (!scope || disposed) { throw new Error("Session message subscription requires an active Gateway connection"); } const normalizedKey = key.trim(); const agentId = options.agentId?.trim() ? normalizeAgentId(options.agentId) : null; - const subscription = await getGatewaySessionMessageSubscriptionCoordinator(scope.client, { - keysEquivalent: areUiSessionKeysEquivalent, - }) + const { mode, includeApprovals } = options; + const runtime = subscriptionRuntime ?? (await loadSubscriptionRuntime()); + if (disposed || !host.connection.isCurrent(scope)) { + throw new Error("Session message subscription completed on a replaced Gateway connection"); + } + const subscription = await runtime + .getGatewaySessionMessageSubscriptionCoordinator(scope.client, { + keysEquivalent: areUiSessionKeysEquivalent, + }) .acquire(normalizedKey, { agentId, - ...(options.includeApprovals ? { includeApprovals: true } : {}), + ...(includeApprovals ? { includeApprovals: true } : {}), + ...(mode ? { mode } : {}), }) .catch((error: unknown) => { if ( error instanceof AggregateError && error.errors[0] instanceof GatewayProtocolRequestTimeoutError && error.errors[0].requestSent && + !disposed && host.connection.isCurrent(scope) && !retiredFailedSubscriptionRecoveries.has(error) ) { @@ -131,7 +147,7 @@ export function createSessionScopedOperations(host: SessionScopedOperationsHost) throw error; }); ownedSubscriptions.add(subscription); - if (!host.connection.isCurrent(scope)) { + if (disposed || !host.connection.isCurrent(scope)) { await unsubscribeMessages(subscription).catch(() => undefined); throw new Error("Session message subscription completed on a replaced Gateway connection"); } @@ -194,11 +210,14 @@ export function createSessionScopedOperations(host: SessionScopedOperationsHost) unsubscribeMessages, retireConnection: (previousClient: GatewayBrowserClient | null) => { if (previousClient) { - resetGatewaySessionMessageSubscriptionCoordinator(previousClient); + // No observer can be acquired before the runtime is installed and its + // captured connection revalidated, so a pending import needs no reset. + subscriptionRuntime?.resetGatewaySessionMessageSubscriptionCoordinator(previousClient); } ownedSubscriptions.clear(); }, dispose: () => { + disposed = true; for (const subscription of ownedSubscriptions) { void unsubscribeMessages(subscription).catch(() => undefined); } diff --git a/ui/src/lib/uuid.ts b/ui/src/lib/uuid.ts index 4b23985088ad..142b639b5e6f 100644 --- a/ui/src/lib/uuid.ts +++ b/ui/src/lib/uuid.ts @@ -1,32 +1 @@ -type CryptoLike = { - randomUUID?: (() => string) | undefined; - getRandomValues?: (>(array: T) => T) | undefined; -}; - -let warnedWeakCrypto = false; - -function warnWeakCryptoOnce() { - if (warnedWeakCrypto) { - return; - } - warnedWeakCrypto = true; - console.warn("[uuid] crypto API missing; refusing insecure UUID generation"); -} - -export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { - if (cryptoLike && typeof cryptoLike.randomUUID === "function") { - return cryptoLike.randomUUID(); - } - - if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { - const bytes = new Uint8Array(16); - cryptoLike.getRandomValues(bytes); - bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 1 - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; - } - - warnWeakCryptoOnce(); - throw new Error("Web Crypto is required for UUID generation"); -} +export { generateUUID } from "@openclaw/normalization-core/uuid"; diff --git a/ui/src/pages/activity/activity-page.test.ts b/ui/src/pages/activity/activity-page.test.ts index 6946923c4384..26aec38e7c49 100644 --- a/ui/src/pages/activity/activity-page.test.ts +++ b/ui/src/pages/activity/activity-page.test.ts @@ -476,7 +476,7 @@ describe("ActivityPage gateway lifecycle", () => { expect(new Set(page.entries.map((entry) => entry.id)).size).toBe(4); expect(current().request).toHaveBeenCalledWith( "sessions.messages.subscribe", - { key: "unknown", agentId: "research" }, + { key: "unknown", agentId: "research", subscriptionId: expect.any(String) }, expect.anything(), ); }, @@ -503,13 +503,16 @@ describe("ActivityPage gateway lifecycle", () => { current() .request.mock.calls.filter(([method]) => method === "sessions.messages.unsubscribe") .map(([, params]) => params); - expect(unsubscribedKeys()).not.toContainEqual({ key: "main" }); + expect(unsubscribedKeys()).not.toContainEqual(expect.objectContaining({ key: "main" })); await sessions.unsubscribeMessages(remainingOwner); await sessions.unsubscribeMessages(otherOwner); expect(page.entries).toEqual([]); expect(unsubscribedKeys()).toEqual( - expect.arrayContaining([{ key: "main" }, { key: "agent:other:work" }]), + expect.arrayContaining([ + { key: "main", subscriptionId: expect.any(String) }, + { key: "agent:other:work", subscriptionId: expect.any(String) }, + ]), ); current().request.mockImplementation(async (method, params) => activityResponse(method, params), diff --git a/ui/src/pages/chat/chat-history-hydration.ts b/ui/src/pages/chat/chat-history-hydration.ts index aa75316d7a96..c01efbb6905b 100644 --- a/ui/src/pages/chat/chat-history-hydration.ts +++ b/ui/src/pages/chat/chat-history-hydration.ts @@ -23,6 +23,7 @@ import { } from "./chat-history-snapshot.ts"; import { beginHistoryRequest, + chatHistoryRequests, ownsHistoryRequest, acceptsHistoryResult, resetChatHistoryProjection, @@ -128,7 +129,6 @@ export async function hydrateChatHistory( // Any pending input-history snapshot becomes invalid once we start reloading transcript state. state.resetChatInputHistoryNavigation?.(); state.chatLoading = true; - setChatError(state, null); const request = (cursor?: string) => requestSharedHistory( sessions, @@ -143,6 +143,35 @@ export async function hydrateChatHistory( inputRunIds, ); try { + const requests = chatHistoryRequests(state); + let admission = requests.subscriptionReady; + while (admission) { + const ready = await admission; + if (!isCurrent()) { + return undefined; + } + if (admission === requests.subscriptionReady) { + if (!ready) { + if (requests.subscriptionError) { + setChatHistoryLoad(state, { + phase: "failed", + sessionKey, + requestAgentId, + startup: method === "chat.startup", + message: requests.subscriptionError, + retryable: false, + }); + state.requestUpdate?.(); + } + return undefined; + } + break; + } + admission = requests.subscriptionReady; + } + // The snapshot covers activity emitted before the foreground observer was + // admitted; subsequent activity arrives through its acknowledged full stream. + setChatError(state, null); let response = await request(deltaCursor); if (!isCurrent()) { recordTiming("stale", { diff --git a/ui/src/pages/chat/chat-history-state.ts b/ui/src/pages/chat/chat-history-state.ts index 9af37032fd3f..4f3fbdad3086 100644 --- a/ui/src/pages/chat/chat-history-state.ts +++ b/ui/src/pages/chat/chat-history-state.ts @@ -52,6 +52,7 @@ type ChatHistoryPaneRequests = { historyVersion: number; branchVersion: number; subscriptionGeneration: number; + subscriptionReady?: Promise; subscriptionError?: string; pendingSubscriptionReleases: Set; historyLoad: ChatHistoryLoadState; diff --git a/ui/src/pages/chat/chat-history-subscription-admission.test.ts b/ui/src/pages/chat/chat-history-subscription-admission.test.ts new file mode 100644 index 000000000000..f14e023a4656 --- /dev/null +++ b/ui/src/pages/chat/chat-history-subscription-admission.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment node +import { describe, expect, it, onTestFinished } from "vitest"; +import { createDeferred } from "../../../../test/helpers/promise.js"; +import { getChatHistoryLoadState } from "./chat-history-state.ts"; +import { + disposeSelectedSessionMessageSubscription, + syncSelectedSessionMessageSubscription, +} from "./chat-history-subscription.ts"; +import { loadChatHistory } from "./chat-history.ts"; +import { makeChatHost, requestCalls } from "./chat-host.test-support.ts"; + +function admissionFixture() { + const key = "agent:main:selected"; + const requested = createDeferred(); + const admitted = createDeferred<{ key: string; agentId: string }>(); + const messages: unknown[] = []; + const state = makeChatHost({ + sessionKey: key, + requestHandlers: { + "sessions.messages.subscribe": (params: { mode?: string }) => { + if (params.mode === "narration") { + return { key, agentId: "main" }; + } + requested.resolve(); + return admitted.promise; + }, + "chat.history": () => ({ messages }), + "chat.startup": () => ({ messages }), + }, + }); + onTestFinished(() => state.sessions.dispose()); + return { state, key, requested, admitted, messages }; +} + +describe("foreground history subscription admission", () => { + it.each([false, true])( + "reads pre-admission activity after the full-stream upgrade ACK (startup: %s)", + async (startup) => { + const { state, key, requested, admitted, messages } = admissionFixture(); + await state.sessions.subscribeMessages(key, { mode: "narration" }); + const subscription = syncSelectedSessionMessageSubscription(state); + const history = loadChatHistory(state, { startup, deferBranches: true }); + await requested.promise; + const method = startup ? "chat.startup" : "chat.history"; + expect(requestCalls(state.request, method)).toHaveLength(0); + expect(state.chatLoading).toBe(true); + + const missedMessage = { role: "assistant", content: "Completed before stream admission." }; + messages.push(missedMessage); + admitted.resolve({ key, agentId: "main" }); + await Promise.all([subscription, history]); + + expect(requestCalls(state.request, method)).toHaveLength(1); + expect(state.chatMessages).toEqual([missedMessage]); + expect(getChatHistoryLoadState(state).phase).toBe("committed"); + }, + ); + + it.each(["selection", "connection"])( + "retires the history read when %s changes before stream admission", + async (change) => { + const { state, key, requested, admitted } = admissionFixture(); + const subscription = syncSelectedSessionMessageSubscription(state); + const history = loadChatHistory(state, { startup: true, deferBranches: true }); + await requested.promise; + if (change === "selection") { + state.sessionKey = "agent:main:replacement"; + } else { + state.connectionEpoch += 1; + } + admitted.resolve({ key, agentId: "main" }); + await Promise.all([subscription, history]); + + expect(requestCalls(state.request, "chat.startup")).toHaveLength(0); + expect(state.chatMessages).toEqual([]); + }, + ); + + it("settles a rejected admission visibly without reading an incomplete transcript", async () => { + const { state, requested, admitted } = admissionFixture(); + const subscription = syncSelectedSessionMessageSubscription(state); + const history = loadChatHistory(state, { startup: true, deferBranches: true }); + await requested.promise; + admitted.reject(new Error("Live stream subscription failed")); + await Promise.all([subscription, history]); + + expect(requestCalls(state.request, "chat.startup")).toHaveLength(0); + expect(getChatHistoryLoadState(state)).toMatchObject({ + phase: "failed", + message: "Live stream subscription failed", + startup: true, + }); + expect(state.chatLoading).toBe(false); + expect(state.chatError).toBe("Live stream subscription failed"); + }); + + it("retires an acknowledged admission before a queued history read can issue", async () => { + const { state, key, requested, admitted } = admissionFixture(); + const subscription = syncSelectedSessionMessageSubscription(state); + await requested.promise; + admitted.resolve({ key, agentId: "main" }); + await expect(subscription).resolves.toBe(true); + + const history = loadChatHistory(state, { startup: true, deferBranches: true }); + disposeSelectedSessionMessageSubscription(state); + await history; + + expect(requestCalls(state.request, "chat.startup")).toHaveLength(0); + expect(state.chatSessionMessageSubscription).toBeNull(); + expect(state.chatMessages).toEqual([]); + }); +}); diff --git a/ui/src/pages/chat/chat-history-subscription.ts b/ui/src/pages/chat/chat-history-subscription.ts index f939001e73fe..5cd4eba1f297 100644 --- a/ui/src/pages/chat/chat-history-subscription.ts +++ b/ui/src/pages/chat/chat-history-subscription.ts @@ -97,6 +97,7 @@ async function releaseDetachedSessionMessageSubscription( export function disposeSelectedSessionMessageSubscription(state: ChatState): void { const requests = chatHistoryRequests(state); requests.subscriptionGeneration += 1; + requests.subscriptionReady = Promise.resolve(false); const subscriptions = new Set(requests.pendingSubscriptionReleases); requests.pendingSubscriptionReleases.clear(); if (state.chatSessionMessageSubscription) { @@ -117,18 +118,47 @@ export function disposeSelectedSessionMessageSubscription(state: ChatState): voi } } -export async function syncSelectedSessionMessageSubscription( +export function syncSelectedSessionMessageSubscription( state: ChatSessionMessageSubscriptionState, opts?: { force?: boolean }, -) { +): Promise { + const requests = chatHistoryRequests(state); + const client = state.client; + const connectionEpoch = state.connectionEpoch; + const requestedKey = state.sessionKey.trim(); + const requestedAgentId = resolveSelectedSessionMessageSubscriptionAgentId(state, requestedKey); + const pending = synchronizeSelectedSessionMessageSubscription(state, opts); + const generation = requests.subscriptionGeneration; + const ready = pending.then( + (admitted) => + admitted && + client !== null && + isCurrentSelectedSessionMessageSubscriptionSync(state, { + generation, + client, + connectionEpoch, + requestedKey, + requestedAgentId, + }) && + state.chatSessionMessageSubscriptionRequestedKey === requestedKey && + state.chatSessionMessageSubscription != null, + ); + requests.subscriptionReady = ready; + return ready; +} + +async function synchronizeSelectedSessionMessageSubscription( + state: ChatSessionMessageSubscriptionState, + opts?: { force?: boolean }, +): Promise { if (!state.client || !state.connected) { - return; + return false; } const client = state.client; const connectionEpoch = state.connectionEpoch; const nextKey = state.sessionKey.trim(); if (!nextKey) { - return; + return false; } const previousRequestedKey = normalizeNullableString( state.chatSessionMessageSubscriptionRequestedKey, @@ -195,7 +225,7 @@ export async function syncSelectedSessionMessageSubscription( ) { clearRecoveredError(); } - return; + return isCurrent() && previousSubscription !== null; } try { let unsubscribePromise: Promise = Promise.resolve(); @@ -237,16 +267,16 @@ export async function syncSelectedSessionMessageSubscription( publishError( `${formatUiError(unsubscribeResult.reason)}; replacement release failed: ${formatUiError(replacementReleaseError)}`, ); - } else { - paneRequests.pendingSubscriptionReleases.add(subscribeResult.value); + return true; } - return; + paneRequests.pendingSubscriptionReleases.add(subscribeResult.value); + return false; } } if (isCurrent()) { publishError(unsubscribeResult.reason); } - return; + return false; } const subscribed = subscribeResult.status === "fulfilled" ? subscribeResult.value : null; if (!subscribed) { @@ -257,7 +287,7 @@ export async function syncSelectedSessionMessageSubscription( if (subscribeResult.status === "rejected") { throw subscribeResult.reason; } - return; + return false; } if (!isCurrent()) { // Generation advances before awaiting, so only the newest lease can reach assignment below. @@ -271,7 +301,7 @@ export async function syncSelectedSessionMessageSubscription( // exact handle so the next sync can complete the original unsubscribe. paneRequests.pendingSubscriptionReleases.add(subscribed); } - return; + return false; } state.chatSessionMessageSubscriptionRequestedKey = nextKey; state.chatSessionMessageSubscription = subscribed; @@ -285,9 +315,11 @@ export async function syncSelectedSessionMessageSubscription( state.chatSessionApprovalQueue = []; } clearRecoveredError(); + return true; } catch (err) { if (isCurrent()) { publishError(err); } + return false; } } diff --git a/ui/src/pages/chat/chat-pane-connection-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-connection-lifecycle.test.ts index a572982ca5f4..455850f83d1d 100644 --- a/ui/src/pages/chat/chat-pane-connection-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-connection-lifecycle.test.ts @@ -10,6 +10,7 @@ import { sessionsResult } from "../../lib/sessions/session-capability.test-suppo import { createTestGatewayClient } from "../../test-helpers/gateway-client.ts"; import { sessionMutationGatewayHello } from "../../test-helpers/gateway-methods.ts"; import type { ChatHistoryResult } from "./chat-history-snapshot.ts"; +import { chatHistoryRequests } from "./chat-history-state.ts"; import { applyChatAgentsList } from "./chat-history.ts"; import { makeRequestMock } from "./chat-host.test-support.ts"; import { ChatPaneBase } from "./chat-pane-base.ts"; @@ -383,6 +384,7 @@ describe("chat pane connection lifecycle", () => { pane.applyGatewaySnapshot({ ...snapshot, phase: "connected" }); pane.applyGatewaySnapshot({ ...snapshot, phase: "connected" }); + await expect(chatHistoryRequests(state).subscriptionReady).resolves.toBe(true); expect(request.mock.calls.filter(([method]) => method === "chat.startup")).toHaveLength(1); expect(request).toHaveBeenCalledWith( "chat.startup", diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index bf55d4b434f6..ec211404db2f 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -483,6 +483,15 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { state.hello = snapshot.hello; state.selfUser = snapshot.selfUser ?? null; state.assistantAgentId = assistantAgentId; + const routeSessionKey = this.sessionKey.trim(); + const catalogRouteKey = parseCatalogSessionKey(routeSessionKey); + if ( + state.connected && + !catalogRouteKey && + (sourceChanged || this.connectedClient !== snapshot.client) + ) { + void syncSelectedSessionMessageSubscription(state, { force: true }); + } this.reconcileTaskSuggestionConnection(sourceChanged); this.synchronizeSessionObservation(); if (wasConnected && !state.connected) { @@ -536,8 +545,6 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { if (state.connected && state.pendingAbort) { void replayPendingChatAbort(state).finally(() => state.requestUpdate?.()); } - const routeSessionKey = this.sessionKey.trim(); - const catalogRouteKey = parseCatalogSessionKey(routeSessionKey); const canonicalRouteSessionKey = routeSessionKey && !catalogRouteKey ? resolveSessionKey(routeSessionKey, snapshot.hello) @@ -620,7 +627,6 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { state.requestUpdate?.(); return; } - void syncSelectedSessionMessageSubscription(state, { force: true }); const historyRefresh = refreshPageChat(state, { startup: true, awaitHistory: true, diff --git a/ui/src/pages/chat/chat-pane-history-issuance.test.ts b/ui/src/pages/chat/chat-pane-history-issuance.test.ts index dd6c55a4da90..708220a773c1 100644 --- a/ui/src/pages/chat/chat-pane-history-issuance.test.ts +++ b/ui/src/pages/chat/chat-pane-history-issuance.test.ts @@ -10,6 +10,7 @@ import { } from "../../lib/sessions/session-capability.test-support.ts"; import { getChatHistoryLoadState } from "./chat-history-state.ts"; import { loadChatHistory } from "./chat-history.ts"; +import { requestCalls } from "./chat-host.test-support.ts"; import { createTestChatPane } from "./chat-pane.test-support.ts"; function createCanonicalRoutePane(request: ReturnType) { @@ -54,10 +55,19 @@ function assistantHistory(text: string) { describe("chat pane history issuance across Gateway connection transitions", () => { it("does not request the optional header platform while initial history is pending", async () => { + const subscribed = createDeferred(); + const historyStarted = createDeferred(); const history = createDeferred>(); - const request = vi.fn((method: string) => - method === "chat.startup" ? history.promise : Promise.resolve({}), - ); + const request = vi.fn((method: string) => { + if (method === "sessions.messages.subscribe") { + return subscribed.promise.then(() => ({})); + } + if (method === "chat.startup") { + historyStarted.resolve(); + return history.promise; + } + return Promise.resolve({}); + }); const { pane, state, snapshot } = createCanonicalRoutePane(request); pane.sessionKey = state.sessionKey; state.loadAssistantIdentity = vi.fn(async () => undefined); @@ -65,7 +75,10 @@ describe("chat pane history issuance across Gateway connection transitions", () pane.context.gateway.snapshot.hello = hello; try { pane.applyGatewaySnapshot({ ...snapshot, hello }); - expect(request.mock.calls.some(([method]) => method === "chat.startup")).toBe(true); + expect(requestCalls(request, "chat.startup")).toHaveLength(0); + subscribed.resolve(); + await historyStarted.promise; + expect(requestCalls(request, "chat.startup")).toHaveLength(1); expect(request.mock.calls.filter(([method]) => method === "system.info")).toEqual([]); } finally { history.resolve(assistantHistory("Selected transcript")); @@ -90,7 +103,7 @@ describe("chat pane history issuance across Gateway connection transitions", () pane.applyGatewaySnapshot(snapshot); - await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(requestCalls(request, "chat.history")).toHaveLength(1)); expect(request).toHaveBeenCalledWith( "chat.history", { @@ -109,16 +122,22 @@ describe("chat pane history issuance across Gateway connection transitions", () }); it("automatically retries a retryable history failure when the Gateway reconnects", async () => { - const request = vi - .fn() - .mockRejectedValueOnce( - new GatewayRequestError({ - code: "GATEWAY_UNAVAILABLE", - message: "Gateway connection interrupted", - retryable: true, - }), - ) - .mockResolvedValueOnce(assistantHistory("Recovered after reconnect")); + let historyAttempts = 0; + const request = vi.fn((method: string) => { + if (method !== "chat.startup") { + return Promise.resolve({}); + } + historyAttempts += 1; + return historyAttempts === 1 + ? Promise.reject( + new GatewayRequestError({ + code: "GATEWAY_UNAVAILABLE", + message: "Gateway connection interrupted", + retryable: true, + }), + ) + : Promise.resolve(assistantHistory("Recovered after reconnect")); + }); const { pane, state, snapshot } = createCanonicalRoutePane(request); await loadChatHistory(state, { startup: true }); @@ -132,9 +151,8 @@ describe("chat pane history issuance across Gateway connection transitions", () pane.applyGatewaySnapshot({ ...snapshot, phase: "reconnecting", hello: null }); pane.applyGatewaySnapshot(snapshot); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); - expect(request).toHaveBeenNthCalledWith( - 2, + await vi.waitFor(() => expect(requestCalls(request, "chat.startup")).toHaveLength(2)); + expect(request).toHaveBeenCalledWith( "chat.startup", { sessionKey: state.sessionKey, @@ -162,7 +180,8 @@ describe("chat pane history issuance across Gateway connection transitions", () state.sessionKey = "agent:main:different-session"; pane.applyGatewaySnapshot(snapshot); - expect(request).not.toHaveBeenCalled(); + expect(requestCalls(request, "chat.history")).toHaveLength(0); + expect(requestCalls(request, "chat.startup")).toHaveLength(0); expect(getChatHistoryLoadState(state)).toEqual({ phase: "idle" }); expect(state.chatLoading).toBe(false); }); diff --git a/ui/src/pages/chat/chat-pane-successor-event.test.ts b/ui/src/pages/chat/chat-pane-successor-event.test.ts index 2a9be476a72b..f26c1b807bcc 100644 --- a/ui/src/pages/chat/chat-pane-successor-event.test.ts +++ b/ui/src/pages/chat/chat-pane-successor-event.test.ts @@ -52,14 +52,19 @@ it.each(["branch-switch", "rewind"] as const)( } satisfies ChatHistoryResult; const replacementList = createDeferred>(); const successorHistory = createDeferred(); + const historyRequested = createDeferred(); const admitted = createDeferred(); let changed = false; const readList = vi.fn(() => changed ? replacementList.promise : sessionsResult([previous], 1), ); - const readHistory = vi.fn(() => - changed ? successorHistory.promise : initial, - ); + const readHistory = vi.fn(() => { + if (!changed) { + return initial; + } + historyRequested.resolve(); + return successorHistory.promise; + }); const { sessions, mount, emitGatewayEvent } = createMountedPanes( [previous], "main", @@ -103,6 +108,7 @@ it.each(["branch-switch", "rewind"] as const)( expect(selectedChatSessionRow(pane.state)).toMatchObject(successor); expect(pane.state.chatMessages).toBe(displayed); expect(pane.state.currentSessionId).toBe(previous.sessionId); + await historyRequested.promise; expect(readHistory).toHaveBeenCalledOnce(); const historyLoad = getChatHistoryLoadState(pane.state); expect(historyLoad.phase).toBe("in-flight"); @@ -158,15 +164,20 @@ it.each([false, true])( // Transcript custody can advance before the corresponding row metadata arrives. }; const newerHistory = createDeferred(); + const historyRequested = createDeferred(); const authoritative: ChatHistoryResult = { messages: [{ ...persisted, content: "Authoritative newer transcript" }], sessionId: newer.sessionId, sessionInfo: newer, }; let newerAdmitted = false; - const readHistory = vi.fn(() => - newerAdmitted ? newerHistory.promise : history, - ); + const readHistory = vi.fn(() => { + if (!newerAdmitted) { + return history; + } + historyRequested.resolve(); + return newerHistory.promise; + }); let loading: Promise | undefined; onTestFinished(async () => { newerHistory.resolve(authoritative); @@ -234,6 +245,7 @@ it.each([false, true])( if (reentrant) { expect(pane.state.chatMessages).toBe(messagesBefore); expect(pane.state.chatMessages).toEqual([persisted]); + await historyRequested.promise; const historyLoad = getChatHistoryLoadState(pane.state); if (historyLoad.phase === "in-flight") { loading = historyLoad.promise; diff --git a/ui/src/pages/chat/chat-pane-transcript-loading.test.ts b/ui/src/pages/chat/chat-pane-transcript-loading.test.ts index efb7595c7d4f..5318e91df5cc 100644 --- a/ui/src/pages/chat/chat-pane-transcript-loading.test.ts +++ b/ui/src/pages/chat/chat-pane-transcript-loading.test.ts @@ -60,8 +60,8 @@ describe("chat pane transcript loading", () => { await vi.waitFor(() => expect(state.chatMessages).toEqual(messages)); expect(observing).toBe(true); expect(subscriptions).toEqual([ - { key: sessionKey, includeApprovals: true }, - { key: sessionKey, includeApprovals: true }, + { subscriptionId: expect.any(String), key: sessionKey, includeApprovals: true }, + { subscriptionId: expect.any(String), key: sessionKey, includeApprovals: true }, ]); expect(state.chatError).toBeNull(); }); diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index daeab60144d4..3854898fd28c 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -594,13 +594,25 @@ describe("chat pane initialization", () => { } }); - it("starts the connected client when a route alias is already selected canonically", () => { - const request = vi.fn(() => new Promise(() => {})); + it("starts the connected client when a route alias is already selected canonically", async () => { + const canonicalSessionKey = "agent:main:main"; + const subscriptionRequested = createDeferred(); + const subscriptionAdmitted = createDeferred<{ key: string }>(); + const startupRequested = createDeferred(); + const request = createGatewayRequestMock((method) => { + if (method === "sessions.messages.subscribe") { + subscriptionRequested.resolve(); + return subscriptionAdmitted.promise; + } + if (method === "chat.startup") { + startupRequested.resolve(); + } + return new Promise(() => {}); + }); const client = createGatewayBrowserClientFixture({ request, }); const { pane, state } = createTestChatPane({ client }); - const canonicalSessionKey = "agent:main:main"; const hello = { features: { methods: ["chat.startup"] }, snapshot: { @@ -653,6 +665,10 @@ describe("chat pane initialization", () => { expect(navigate).toHaveBeenCalledWith("single", canonicalSessionKey, { replace: true }); expect(pane.connectedClient).toBe(client); + await subscriptionRequested.promise; + expect(request.mock.calls.filter(([method]) => method === "chat.startup")).toHaveLength(0); + subscriptionAdmitted.resolve({ key: canonicalSessionKey }); + await startupRequested.promise; expect(request).toHaveBeenCalledWith( "chat.startup", expect.objectContaining({ sessionKey: canonicalSessionKey }), diff --git a/ui/src/test-helpers/app-sidebar-cases/narration.ts b/ui/src/test-helpers/app-sidebar-cases/narration.ts index 3fd24d13aef0..130b1e6ea06f 100644 --- a/ui/src/test-helpers/app-sidebar-cases/narration.ts +++ b/ui/src/test-helpers/app-sidebar-cases/narration.ts @@ -45,20 +45,15 @@ describe("AppSidebar live narration", () => { await sidebar.updateComplete; await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(1)); - expect(sessions.subscribeMessages).toHaveBeenCalledWith(key, { agentId: undefined }); + expect(sessions.subscribeMessages).toHaveBeenCalledWith(key, { + agentId: undefined, + mode: "narration", + }); - gateway.publishEvent("chat", { + gateway.publishEvent("session.narration", { sessionKey: key, - state: "delta", - message: { - role: "assistant", - content: [ - { - type: "text", - text: "# Earlier work\n\nChecked the inputs. Final **verification** is running.", - }, - ], - }, + runId: "narrated-run", + text: "# Earlier work\n\nChecked the inputs. Final **verification** is running.", }); await waitForFast(() => @@ -244,9 +239,11 @@ describe("AppSidebar live narration", () => { await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(2)); expect(sessions.subscribeMessages).toHaveBeenCalledWith(openKey, { agentId: undefined, + mode: "narration", }); expect(sessions.subscribeMessages).toHaveBeenCalledWith(backgroundKey, { agentId: undefined, + mode: "narration", }); gateway.publish({ phase: "stopped" }); @@ -260,8 +257,8 @@ describe("AppSidebar live narration", () => { await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(4)); expect(sessions.subscribeMessages.mock.calls.slice(2)).toEqual( expect.arrayContaining([ - [backgroundKey, { agentId: undefined }], - [openKey, { agentId: undefined }], + [backgroundKey, { agentId: undefined, mode: "narration" }], + [openKey, { agentId: undefined, mode: "narration" }], ]), ); }); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 3452cdc64498..952220fec81a 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -374,6 +374,7 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] { sourcePackageAlias("normalization-core", "string-coerce"), sourcePackageAlias("normalization-core", "string-normalization"), sourcePackageAlias("normalization-core", "utf16-slice"), + sourcePackageAlias("normalization-core", "uuid"), sourcePackageAlias("normalization-core"), sourcePackageAlias("session-url-contract", "parse"), sourcePackageAlias("session-url-contract", "session-key-normalization"),