perf(gateway): throttle sidebar narration updates (#160002)

* test: align frozen-base fixtures with current lifecycle contracts

Apply the prerequisite fixture repairs already landed in 0b6f02faae (#159836) so the narration comparison base can complete validation.

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* perf(gateway): throttle background sidebar narration with digests

Declare narration intent on targeted session subscriptions and pace bounded text snapshots on the server. Preserve full streams whenever any SDK owner renders a foreground transcript, and flush corrected final text before terminal events. Update bundled clients, protocol artifacts, and the event contract.

* fix(gateway): preserve foreground ownership across narration addresses

Track independent SDK observer IDs at the server so every full-stream interest survives alias collisions, rollback, and unrelated releases. Keep global requests bound to their acknowledged owner and pace distinct logical sessions separately.

Wait for foreground admission before history reads, retire stale admissions, and load subscription runtime on demand. Share the existing browser-safe UUID implementation and extend protocol and lifecycle regressions.

* test(gateway): expect resolved agent ownership in shared approval replay ACKs

* test(gateway): isolate the shared subscription lookup assertion

Narration also reads subscription intent for eligible recipients, so the
visible chat path legitimately performs three registry lookups. Check the
single admission lookup after visibility is revoked, when narration is
not entered. Preserve delivery, unrelated-recipient, visibility-revocation,
and event-sequencing assertions.

* ci: split UI component tests into their own core type shard

Apply the upstream partition fix so the narration merge retains complete
root coverage within the unchanged local shard-size budget.

(cherry picked from commit 52ae7e314d)

* test(ui): align narration fixtures with stream admission

---------

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-09-28 07:11:52 -07:00 • committed by GitHub
parent eed9d9bf22
commit d769e31af2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2620 additions and 410 deletions

View file

@ -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) {

View file

@ -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"),

View file

@ -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":

View file

@ -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"
}
}

View file

@ -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.

View file

@ -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<unknown>();
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<unknown>();
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<unknown>();
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<unknown>();
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,
);
});
});

View file

@ -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<string, unknown>) => Promise<unknown>;
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<string, unknown>) => 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<string, unknown>,
options?: GatewayProtocolRequestOptions,
): Promise<unknown> => {
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,
};
}

View file

@ -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<string, unknown>) => Promise<unknown>;
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<string, unknown>) => 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<string, unknown>,
options?: GatewayProtocolRequestOptions,
): Promise<unknown> => {
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",
});
});

View file

@ -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<string>;
agentId: string | null;
scopeAgentId: string | null;
ownerAgentId: string | null;
ready: Promise<SessionMessageSubscriptionResponse>;
approvalRequest: Promise<SessionMessageSubscriptionResponse> | null;
plainFallback: Promise<SessionMessageSubscriptionResponse> | null;
wireRequest: Promise<SessionMessageSubscriptionResponse> | null;
mode?: "narration";
includeApprovals: boolean;
canonicalSettled: boolean;
refreshRequired: boolean;
handles: Set<GatewaySessionMessageSubscription>;
pendingOwners: number;
pendingFullOwners: number;
release: Promise<void> | 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<SessionMessageSubscriptionResponse> {
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<SessionMessageSubscriptionResponse> {
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

View file

@ -212,6 +212,7 @@ export {
SESSION_OBSERVER_HEALTH_VALUES,
SessionCompanionExchangeSchema,
SessionObserverDigestSchema,
SessionNarrationEventSchema,
SessionObserverHealthSchema,
SessionObserverPlanProgressSchema,
SessionMemberAddParamsSchema,

View file

@ -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<typeof SessionsRecoverParamsSchema>;
export type SessionsRecoverResult = Static<typeof SessionsRecoverResultSchema>;
export type SessionsSendParams = Static<typeof SessionsSendParamsSchema>;
export type SessionsMessagesSubscribeParams = Static<typeof SessionsMessagesSubscribeParamsSchema>;
export type SessionNarrationEvent = Static<typeof SessionNarrationEventSchema>;
export type SessionsMessagesUnsubscribeParams = Static<
typeof SessionsMessagesUnsubscribeParamsSchema
>;

View file

@ -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);
});
});

View file

@ -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": {

View file

@ -0,0 +1,33 @@
type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: ((array: Uint8Array<ArrayBuffer>) => Uint8Array<ArrayBuffer>) | 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");
}

View file

@ -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<string, unknown>;
projection: NarrationProjection;
sessionKeys: readonly string[];
opts?: GatewayBroadcastOpts;
};
type NarrationState = {
subscriptionKeys: readonly string[];
lastSentAt?: number;
last?: SessionNarrationEvent;
pending?: PendingNarration;
timer?: ReturnType<typeof setTimeout>;
retirePending?: () => void;
};
type ConnectionNarration = {
socket: GatewayWsClient["socket"];
sessions: Map<string, NarrationState>;
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<GatewayWsClient, ConnectionNarration>();
const projections = new WeakMap<object, NarrationProjection>();
const groups = new WeakMap<AbortSignal, Set<NarrationState>>();
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<string, NarrationState>();
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<string, unknown>,
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<string, unknown>,
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;
},
};
}

View file

@ -82,6 +82,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
"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],

View file

@ -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);
});
},

View file

@ -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. <think>${"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);
});
});

View file

@ -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<GatewayWsClient, number>();
const reportedSlowPayloadClients = new WeakSet<GatewayWsClient>();
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", () => {

View file

@ -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([]);
},
);
});

View file

@ -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([]);

View file

@ -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<string>;
getApprovals: (sessionKey: string) => ReadonlySet<string>;
getNarration: (sessionKey: string) => ReadonlySet<string>;
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<number, SessionMessageSubscriptionMode>;
};
type SessionMessageSubscriptionOwners = Map<string | undefined, ProvisionalSubscriptionState>;
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<string, Set<string>>();
// Booleans retain committed approval mode; records own unsettled replays.
// Replacing a record fences late settlements, including connection/session reuse.
const connections = new Map<string, Map<string, boolean | ProvisionalSubscriptionState>>();
// Removing a record fences late replay settlements, including connection/session reuse.
const connections = new Map<string, Map<string, SessionMessageSubscriptionOwners>>();
const approvalSessionToConnIds = new Map<string, Set<string>>();
const narrationSessionToConnIds = new Map<string, Set<string>>();
const changeListeners = new Set<(sessionKey: string, connId: string) => void>();
const empty = new Set<string>();
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<string, Set<string>>,
connId: string,
sessionKey: string,
subscribed: boolean,
) => {
const connIds = index.get(sessionKey);
if (subscribed) {
const nextConnIds = connIds ?? new Set<string>();
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<string>();
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<string, boolean | ProvisionalSubscriptionState>();
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<string, SessionMessageSubscriptionOwners>();
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);

View file

@ -39,6 +39,7 @@ export const GATEWAY_EVENTS = [
"ui.command",
"session.approval",
"session.message",
"session.narration",
"session.observer",
"session.operation",
"session.sharing",

View file

@ -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(

View file

@ -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 {

View file

@ -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);
},

View file

@ -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 () => {

View file

@ -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();

View file

@ -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,

View file

@ -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<string>;
registerToolEventRecipient: (runId: string, connId: string) => void;

View file

@ -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"
],

View file

@ -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<typeof coordinator.acquire>[1]) =>
coordinator.acquire(key, options),
unsubscribeMessages: vi.fn((handle: Awaited<ReturnType<typeof coordinator.acquire>>) =>
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",

View file

@ -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);
});

View file

@ -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<string, unknown> | 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<string, unknown>;
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<string, unknown> | 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);

View file

@ -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] ?? "");

View file

@ -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<string, unknown>) => ({
key: params?.key,
agentId: params?.agentId,
}));
const client = { request } as unknown as GatewayBrowserClient;
const sessions = createTestSessionCapability(createGateway(client));
const options: NonNullable<Parameters<SessionCapability["subscribeMessages"]>[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<string, unknown>) => ({
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<string, unknown>) => {
@ -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");

View file

@ -341,7 +341,7 @@ export type SessionCapability = {
) => Promise<SessionWorkspaceSetResult | null>;
subscribeMessages: (
key: string,
options?: { agentId?: string | null; includeApprovals?: boolean },
options?: { agentId?: string | null; includeApprovals?: boolean; mode?: "narration" },
) => Promise<SessionMessageSubscription>;
unsubscribeMessages: (subscription: SessionMessageSubscription) => Promise<void>;
rewind: (

View file

@ -0,0 +1,5 @@
export {
getGatewaySessionMessageSubscriptionCoordinator,
releaseGatewaySessionMessageSubscription,
resetGatewaySessionMessageSubscriptionCoordinator,
} from "@openclaw/gateway-client/browser";

View file

@ -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<AggregateError>();
export function createSessionScopedOperations(host: SessionScopedOperationsHost) {
const ownedSubscriptions = new Set<SessionMessageSubscription>();
type SubscriptionRuntime = typeof import("./session-message-subscriptions.runtime.ts");
let subscriptionRuntime: SubscriptionRuntime | undefined;
let subscriptionRuntimeLoading: Promise<SubscriptionRuntime> | 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<void> => {
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<Parameters<SessionCapability["subscribeMessages"]>[1]> = {},
): Promise<SessionMessageSubscription> => {
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);
}

View file

@ -1,32 +1 @@
type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: (<T extends Exclude<BufferSource, ArrayBuffer>>(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";

View file

@ -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),

View file

@ -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", {

View file

@ -52,6 +52,7 @@ type ChatHistoryPaneRequests = {
historyVersion: number;
branchVersion: number;
subscriptionGeneration: number;
subscriptionReady?: Promise<boolean>;
subscriptionError?: string;
pendingSubscriptionReleases: Set<SessionMessageSubscription>;
historyLoad: ChatHistoryLoadState;

View file

@ -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([]);
});
});

View file

@ -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<boolean> {
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<boolean> {
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<void> = 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;
}
}

View file

@ -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",

View file

@ -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,

View file

@ -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<typeof vi.fn>) {
@ -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<ReturnType<typeof assistantHistory>>();
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);
});

View file

@ -52,14 +52,19 @@ it.each(["branch-switch", "rewind"] as const)(
} satisfies ChatHistoryResult;
const replacementList = createDeferred<ReturnType<typeof sessionsResult>>();
const successorHistory = createDeferred<ChatHistoryResult>();
const historyRequested = createDeferred();
const admitted = createDeferred();
let changed = false;
const readList = vi.fn(() =>
changed ? replacementList.promise : sessionsResult([previous], 1),
);
const readHistory = vi.fn<GatewayRequestHandler>(() =>
changed ? successorHistory.promise : initial,
);
const readHistory = vi.fn<GatewayRequestHandler>(() => {
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<ChatHistoryResult>();
const historyRequested = createDeferred();
const authoritative: ChatHistoryResult = {
messages: [{ ...persisted, content: "Authoritative newer transcript" }],
sessionId: newer.sessionId,
sessionInfo: newer,
};
let newerAdmitted = false;
const readHistory = vi.fn<GatewayRequestHandler>(() =>
newerAdmitted ? newerHistory.promise : history,
);
const readHistory = vi.fn<GatewayRequestHandler>(() => {
if (!newerAdmitted) {
return history;
}
historyRequested.resolve();
return newerHistory.promise;
});
let loading: Promise<unknown> | 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;

View file

@ -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();
});

View file

@ -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<never>(() => {}));
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<never>(() => {});
});
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 }),

View file

@ -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" }],
]),
);
});

View file

@ -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"),