mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat(ui): live agent activity subtitles on running sidebar sessions (#111221)
* feat(ui): live agent activity subtitles on running sidebar sessions * refactor(ui): lazy-load narration controller off the startup chunk, unexport test-only symbols * chore(ui): raise startup JS budget to 312 KiB for the lazy narration feature * test(ui): restore real timers after narration controller cases
This commit is contained in:
@@ -12,7 +12,10 @@ const KIB = 1024;
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
||||
startupJsRequests: 18,
|
||||
startupCssRequests: 1,
|
||||
startupJsGzipBytes: 310 * KIB,
|
||||
// 312 KiB accompanies the live-narration sidebar feature (2026-07): the
|
||||
// controller is a lazy chunk; only its thin element/pref wiring (~1.5 KiB)
|
||||
// stays in startup, which exhausted the previous ratchet's headroom.
|
||||
startupJsGzipBytes: 312 * KIB,
|
||||
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
|
||||
@@ -39,6 +39,8 @@ const SESSION_PAGE_SIZE = 50;
|
||||
const TOTAL_MOCK_SESSIONS = 650;
|
||||
const TOTAL_TELEGRAM_SESSIONS = 180;
|
||||
const ATTENTION_FIXTURE_EXPIRES_AT = Date.parse("2099-01-01T00:00:00.000Z");
|
||||
const NARRATION_DEMO_SESSION_KEY = "agent:main:sidebar-narration-demo";
|
||||
const NARRATION_DEMO_RUN_ID = "mock-sidebar-narration-run";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const uiRoot = path.join(repoRoot, "ui");
|
||||
@@ -1031,6 +1033,11 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
sessionRow("agent:main:main", "Molty", baseTime - 1_000, {
|
||||
childSessions: ["agent:main:lisbon-trip"],
|
||||
}),
|
||||
sessionRow(NARRATION_DEMO_SESSION_KEY, "Sidebar narration demo", baseTime - 15_000, {
|
||||
hasActiveRun: true,
|
||||
startedAt: baseTime - 45_000,
|
||||
status: "running",
|
||||
}),
|
||||
sessionRow("agent:main:tax-research", "Tax filing research", baseTime - 60_000, {
|
||||
hasActiveRun: true,
|
||||
status: "running",
|
||||
@@ -1510,6 +1517,31 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
|
||||
},
|
||||
},
|
||||
models: modelProviders.models,
|
||||
repeatingSessionEvents: {
|
||||
intervalMs: 3_000,
|
||||
events: [
|
||||
{
|
||||
event: "agent",
|
||||
payload: {
|
||||
// replace: the demo replays the same snapshot each cycle; without
|
||||
// it the controller's cumulative-length dedupe drops the repeats.
|
||||
data: { replace: true, text: "Rebasing onto main and rerunning the sidebar suite." },
|
||||
runId: NARRATION_DEMO_RUN_ID,
|
||||
sessionKey: NARRATION_DEMO_SESSION_KEY,
|
||||
stream: "assistant",
|
||||
},
|
||||
},
|
||||
{
|
||||
event: "session.tool",
|
||||
payload: {
|
||||
data: { name: "exec" },
|
||||
runId: NARRATION_DEMO_RUN_ID,
|
||||
sessionKey: NARRATION_DEMO_SESSION_KEY,
|
||||
stream: "tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sessionArchiveFiltering: true,
|
||||
sessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
@@ -217,6 +217,8 @@ export type OpenClawConfig = {
|
||||
chatSendShortcut?: "enter" | "modifier-enter";
|
||||
/** Follow-up handling while a run is active; unset uses the server queue mode. */
|
||||
chatFollowUpMode?: "steer" | "queue";
|
||||
/** Show live agent activity beneath running Control UI sidebar sessions. */
|
||||
sidebarLiveActivity?: boolean;
|
||||
};
|
||||
};
|
||||
/** Secret providers, defaults, and ref-resolution settings. */
|
||||
|
||||
@@ -301,6 +301,7 @@ export const OpenClawSchemaShape = {
|
||||
chatPersistCommentary: z.boolean().optional(),
|
||||
chatSendShortcut: z.union([z.literal("enter"), z.literal("modifier-enter")]).optional(),
|
||||
chatFollowUpMode: z.union([z.literal("steer"), z.literal("queue")]).optional(),
|
||||
sidebarLiveActivity: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -1401,6 +1401,7 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
.canPairDevice=${gatewaySnapshot.connected &&
|
||||
hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)}
|
||||
.sidebarEntries=${navigationSnapshot.sidebarEntries}
|
||||
.sidebarLiveActivity=${uiSettings.sidebarLiveActivity !== false}
|
||||
.pinnedAgentIds=${navigationSnapshot.pinnedAgentIds}
|
||||
.themeMode=${context.theme.mode}
|
||||
.lobsterPetVisits=${uiSettings.lobsterPetVisits !== false}
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("server pref extraction", () => {
|
||||
locale: "de",
|
||||
chatShowThinking: false,
|
||||
chatSendShortcut: "modifier-enter",
|
||||
sidebarLiveActivity: false,
|
||||
sidebarEntries: ["route:usage", "session:agent:main:test", "route:usage", 7],
|
||||
bogus: true,
|
||||
}),
|
||||
@@ -48,6 +49,7 @@ describe("server pref extraction", () => {
|
||||
locale: "de",
|
||||
chatShowThinking: false,
|
||||
chatSendShortcut: "modifier-enter",
|
||||
sidebarLiveActivity: false,
|
||||
sidebarEntries: ["route:usage", "session:agent:main:test"],
|
||||
});
|
||||
});
|
||||
@@ -148,6 +150,14 @@ describe("changedServerUiPrefs", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("syncs the live sidebar activity preference", () => {
|
||||
const previous = loadSettings();
|
||||
expect(previous.sidebarLiveActivity).toBe(true);
|
||||
expect(changedServerUiPrefs(previous, { ...previous, sidebarLiveActivity: false })).toEqual({
|
||||
sidebarLiveActivity: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs chat behavior prefs and pushes clearable resets as null", () => {
|
||||
const previous = loadSettings();
|
||||
const withOverrides = {
|
||||
|
||||
@@ -100,6 +100,10 @@ const SYNCED_PREFS = {
|
||||
extract: (value) => normalizeSidebarEntries(value) ?? undefined,
|
||||
local: (settings) => settings.sidebarEntries,
|
||||
}),
|
||||
sidebarLiveActivity: prefSpec<boolean>({
|
||||
extract: (value) => (typeof value === "boolean" ? value : undefined),
|
||||
local: (settings) => settings.sidebarLiveActivity !== false,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
type SyncedPrefKey = keyof typeof SYNCED_PREFS;
|
||||
|
||||
@@ -597,6 +597,27 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
expect(loadSettings().catalogOpenTarget).toBe("viewer");
|
||||
});
|
||||
|
||||
it("defaults live sidebar activity on and persists only an explicit opt-out", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
|
||||
expect(loadSettings().sidebarLiveActivity).toBe(true);
|
||||
|
||||
saveSettings({ ...loadSettings(), sidebarLiveActivity: false });
|
||||
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}").sidebarLiveActivity).toBe(false);
|
||||
expect(loadSettings().sidebarLiveActivity).toBe(false);
|
||||
|
||||
saveSettings({ ...loadSettings(), sidebarLiveActivity: true });
|
||||
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty(
|
||||
"sidebarLiveActivity",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists only a normalized realtime Talk microphone id", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
|
||||
@@ -125,6 +125,7 @@ export type UiSettings = {
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||
sidebarEntries: string[]; // Ordered routes and pinned sessions below Home
|
||||
sidebarLiveActivity?: boolean; // Latest activity under running sidebar sessions (default true)
|
||||
pinnedAgentIds?: string[]; // Agents surfaced first in the agent-chip quick switcher
|
||||
textScale?: TextScaleStop; // Browser-local text scale percentage
|
||||
customTheme?: ImportedCustomTheme;
|
||||
@@ -357,6 +358,7 @@ export function loadSettings(): UiSettings {
|
||||
navCollapsed: false,
|
||||
navWidth: NAV_WIDTH_DEFAULT,
|
||||
sidebarEntries: [...DEFAULT_SIDEBAR_ENTRIES],
|
||||
sidebarLiveActivity: true,
|
||||
pinnedAgentIds: [],
|
||||
textScale: 100,
|
||||
composerHoldToRecord: true,
|
||||
@@ -454,6 +456,10 @@ export function loadSettings(): UiSettings {
|
||||
normalizeSidebarEntries(parsedRecord.sidebarEntries) ??
|
||||
migratedSidebarEntries ??
|
||||
defaults.sidebarEntries,
|
||||
sidebarLiveActivity:
|
||||
typeof parsed.sidebarLiveActivity === "boolean"
|
||||
? parsed.sidebarLiveActivity
|
||||
: defaults.sidebarLiveActivity,
|
||||
pinnedAgentIds: normalizePinnedAgentIds(parsed.pinnedAgentIds),
|
||||
textScale: normalizeTextScale(parsed.textScale, defaults.textScale),
|
||||
customTheme: customTheme ?? undefined,
|
||||
@@ -592,6 +598,7 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean }
|
||||
navCollapsed: next.navCollapsed,
|
||||
navWidth: next.navWidth,
|
||||
sidebarEntries: next.sidebarEntries,
|
||||
...(next.sidebarLiveActivity === false ? { sidebarLiveActivity: false } : {}),
|
||||
// Empty pin list is the default; only real pins persist.
|
||||
...(next.pinnedAgentIds && next.pinnedAgentIds.length > 0
|
||||
? { pinnedAgentIds: next.pinnedAgentIds }
|
||||
|
||||
@@ -25,6 +25,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) canPairDevice = false;
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) sidebarEntries: readonly string[] = DEFAULT_SIDEBAR_ENTRIES;
|
||||
@property({ attribute: false }) sidebarLiveActivity = true;
|
||||
/** Agents surfaced first in the chip quick switcher when many exist. */
|
||||
@property({ attribute: false }) pinnedAgentIds: readonly string[] = [];
|
||||
@property({ attribute: false }) themeMode: ThemeMode = "system";
|
||||
|
||||
@@ -10,11 +10,11 @@ import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.t
|
||||
import { writeSessionDragData, writeSessionGroupDragData } from "../lib/sessions/drag.ts";
|
||||
import { sidebarSectionHasHeader } from "../lib/sessions/grouping.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import { AppSidebarMenusElement } from "./app-sidebar-menus.ts";
|
||||
import {
|
||||
type CatalogBackingSessionDisplay,
|
||||
renderSessionCatalogGroups,
|
||||
} from "./app-sidebar-session-catalogs.ts";
|
||||
import { AppSidebarSessionNarrationElement } from "./app-sidebar-session-narration-element.ts";
|
||||
import {
|
||||
limitSidebarSessionRows,
|
||||
loadStoredSidebarCatalogGrouping,
|
||||
@@ -28,16 +28,19 @@ import { icons } from "./icons.ts";
|
||||
import {
|
||||
renderSessionAttentionIcon,
|
||||
renderSessionState,
|
||||
sessionAttentionSubtitle,
|
||||
} from "./session-attention-presentation.ts";
|
||||
import { resolveSessionIcon } from "./session-icon-registry.ts";
|
||||
import { renderSessionRowBadges } from "./session-row-badges.ts";
|
||||
import {
|
||||
renderSidebarSessionSubtitle,
|
||||
resolveSidebarSessionSubtitle,
|
||||
} from "./session-row-subtitle.ts";
|
||||
import "./elapsed-time.ts";
|
||||
|
||||
const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4;
|
||||
|
||||
/** Session-list presentation and catalog renderer wiring. */
|
||||
export abstract class AppSidebarSessionListElement extends AppSidebarMenusElement {
|
||||
export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarrationElement {
|
||||
@state() protected catalogProjectGrouping = loadStoredSidebarCatalogGrouping();
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>) {
|
||||
@@ -59,19 +62,20 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
|
||||
display?: CatalogBackingSessionDisplay,
|
||||
) {
|
||||
const label = display?.label ?? session.label;
|
||||
const subtitle =
|
||||
sessionAttentionSubtitle(session.attention) ??
|
||||
(display
|
||||
? display.subtitle
|
||||
: session.subtitle && session.workSession && session.subtitle !== session.label
|
||||
? session.subtitle
|
||||
: undefined);
|
||||
const { subtitle, narration } = resolveSidebarSessionSubtitle({
|
||||
session,
|
||||
hasDisplay: display !== undefined,
|
||||
displaySubtitle: display?.subtitle,
|
||||
sidebarLiveActivity: this.sidebarLiveActivity,
|
||||
narrationLine: this.sidebarNarrationLines.get(session.key),
|
||||
});
|
||||
const running = session.hasActiveRun || session.status === "running";
|
||||
const meta = display?.meta ?? session.meta;
|
||||
const rowMeta = session.pinned ? "" : meta;
|
||||
const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null);
|
||||
const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined;
|
||||
const menuSession = display ? { ...session, meta } : session;
|
||||
const title = display?.title ?? [label, rowMeta].filter(Boolean).join(" · ");
|
||||
const title = display?.title ?? [label, narration, rowMeta].filter(Boolean).join(" · ");
|
||||
// Pinned rows reposition the state badge into the nav-item slot; render
|
||||
// every state renderSessionState knows (spinner, unread, child terminal
|
||||
// badges) so pinning a subagent session cannot hide its outcome.
|
||||
@@ -87,7 +91,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
|
||||
session.visuallyActive ? "sidebar-recent-session--active" : "",
|
||||
this.selectedSessionKeys.has(session.key) ? "sidebar-recent-session--selected" : "",
|
||||
session.pinned ? "session-row-host--pinned" : "",
|
||||
session.hasActiveRun ? "session-row-host--running" : "",
|
||||
running ? "session-row-host--running" : "",
|
||||
session.attention.kind === "error"
|
||||
? "sidebar-recent-session--attention-danger"
|
||||
: session.attention.kind !== "none"
|
||||
@@ -145,9 +149,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
|
||||
: nothing}
|
||||
<span class="sidebar-recent-session__text">
|
||||
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
|
||||
${subtitle
|
||||
? html`<span class="sidebar-recent-session__subtitle">${subtitle}</span>`
|
||||
: nothing}
|
||||
${renderSidebarSessionSubtitle({ subtitle, narration })}
|
||||
</span>
|
||||
${!session.isChild && sessionHasBoard(session.key)
|
||||
? html`<span
|
||||
@@ -253,13 +255,12 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
|
||||
return keyed(session.key, row);
|
||||
}
|
||||
|
||||
private renderSessionTree(session: SidebarRecentSession): TemplateResult {
|
||||
const expanded = this.isSessionChildrenExpanded(session);
|
||||
protected visibleSessionChildren(session: SidebarRecentSession): readonly SidebarRecentSession[] {
|
||||
const showAllChildren = this.fullyShownChildSessionKeys.has(session.key);
|
||||
// The cap hides quiet children only: the active branch and any branch with
|
||||
// live runs (runningChildCount is transitive) must stay visible, or an
|
||||
// auto-expanded parent would omit its own selection or a running session.
|
||||
const visibleChildren = showAllChildren
|
||||
return showAllChildren
|
||||
? session.children
|
||||
: session.children.filter(
|
||||
(child, index) =>
|
||||
@@ -271,6 +272,11 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
|
||||
child.runningChildCount > 0 ||
|
||||
child.attention.kind !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
private renderSessionTree(session: SidebarRecentSession): TemplateResult {
|
||||
const expanded = this.isSessionChildrenExpanded(session);
|
||||
const visibleChildren = this.visibleSessionChildren(session);
|
||||
const hiddenChildCount = session.children.length - visibleChildren.length;
|
||||
return html`<div class="sidebar-session-tree" data-session-tree=${session.key}>
|
||||
${this.renderRecentSession(session)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { state } from "lit/decorators.js";
|
||||
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
|
||||
import { AppSidebarMenusElement } from "./app-sidebar-menus.ts";
|
||||
import type {
|
||||
SidebarNarrationSyncInput,
|
||||
SidebarSessionNarrationController,
|
||||
} from "./app-sidebar-session-narration.ts";
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
|
||||
/** Gateway subscription and reactive narration state for the session-list renderer. */
|
||||
export abstract class AppSidebarSessionNarrationElement extends AppSidebarMenusElement {
|
||||
@state() protected sidebarNarrationLines: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
// Lazy: the controller pulls core token-suppression modules that must stay
|
||||
// out of the startup chunk (QA smoke startup-JS budget). It loads on the
|
||||
// first update with the preference enabled; earlier events are safely
|
||||
// dropped because the controller aligns from cumulative snapshots.
|
||||
private narration: SidebarSessionNarrationController | null = null;
|
||||
private narrationLoad: Promise<void> | null = null;
|
||||
private readonly narrationSubscriptions = new SubscriptionsController(this);
|
||||
|
||||
protected abstract visibleSessionChildren(
|
||||
session: SidebarRecentSession,
|
||||
): readonly SidebarRecentSession[];
|
||||
|
||||
private visibleNarrationRowsInOrder(): SidebarRecentSession[] {
|
||||
const rows: SidebarRecentSession[] = [];
|
||||
const append = (session: SidebarRecentSession) => {
|
||||
rows.push(session);
|
||||
if (this.isSessionChildrenExpanded(session)) {
|
||||
this.visibleSessionChildren(session).forEach(append);
|
||||
}
|
||||
};
|
||||
this.visibleSessionRowsInOrder().forEach(append);
|
||||
return rows;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.narrationSubscriptions.effect(
|
||||
() => this.context?.gateway,
|
||||
(gateway) => gateway.subscribeEvents((event) => this.narration?.handleEvent(event)),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.narration?.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private narrationSyncInput(): SidebarNarrationSyncInput {
|
||||
const gateway = this.context?.gateway.snapshot;
|
||||
return {
|
||||
enabled: this.sidebarLiveActivity,
|
||||
connected: this.connected && gateway?.connected === true,
|
||||
connectionIdentity: gateway?.client ?? null,
|
||||
source: this.context?.sessions ?? null,
|
||||
rows: this.visibleNarrationRowsInOrder(),
|
||||
openSessionKey: this.activeRouteId === "chat" ? this.getRouteSessionKey() : "",
|
||||
agentId: this.selectedAgentIdForSessions(),
|
||||
};
|
||||
}
|
||||
|
||||
private ensureNarrationController(): void {
|
||||
if (this.narration || this.narrationLoad) {
|
||||
return;
|
||||
}
|
||||
this.narrationLoad = import("./app-sidebar-session-narration.ts").then((module) => {
|
||||
this.narrationLoad = null;
|
||||
// The element may have left the DOM while the chunk loaded.
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.narration = new module.SidebarSessionNarrationController((lines) => {
|
||||
this.sidebarNarrationLines = lines;
|
||||
});
|
||||
this.narration.sync(this.narrationSyncInput());
|
||||
});
|
||||
}
|
||||
|
||||
override updated() {
|
||||
super.updated();
|
||||
if (!this.narration) {
|
||||
if (this.sidebarLiveActivity) {
|
||||
this.ensureNarrationController();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.narration.sync(this.narrationSyncInput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayEventFrame } from "../api/gateway.ts";
|
||||
import type { SessionCapability } from "../lib/sessions/index.ts";
|
||||
import { SidebarSessionNarrationController } from "./app-sidebar-session-narration.ts";
|
||||
import { deriveSidebarNarrationLine } from "./sidebar-narration-line.ts";
|
||||
|
||||
// Mirrors the controller-internal throttle; asserting through timers keeps the
|
||||
// constant unexported (production-only export policy).
|
||||
const SIDEBAR_NARRATION_THROTTLE_MS = 2_000;
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
|
||||
function runningRow(key: string): SidebarRecentSession {
|
||||
return {
|
||||
key,
|
||||
label: "Run",
|
||||
meta: "now",
|
||||
href: "#",
|
||||
active: false,
|
||||
visuallyActive: false,
|
||||
hasActiveRun: true,
|
||||
modelSelectionLocked: false,
|
||||
pinned: false,
|
||||
cloudWorkerActive: false,
|
||||
hasAutomation: false,
|
||||
unread: false,
|
||||
attention: { kind: "none" },
|
||||
startedAt: 1,
|
||||
childSessionKeys: [],
|
||||
children: [],
|
||||
isChild: false,
|
||||
loadingChildren: false,
|
||||
containsActiveDescendant: false,
|
||||
runningChildCount: 0,
|
||||
failedChildCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayEvent(eventName: string, payload: unknown): GatewayEventFrame {
|
||||
return { event: eventName, payload } as GatewayEventFrame;
|
||||
}
|
||||
|
||||
describe("sidebar narration derivation", () => {
|
||||
it("uses the last paragraph and sentence while removing markdown", () => {
|
||||
expect(
|
||||
deriveSidebarNarrationLine(
|
||||
"# Plan\n\nFirst **check** finished.\n\n```ts\nconst answer = 1;\n```\nFinal _verification_ is running.",
|
||||
),
|
||||
).toBe("Final verification is running.");
|
||||
});
|
||||
|
||||
it("collapses whitespace and ellipsizes long fragments", () => {
|
||||
const line = deriveSidebarNarrationLine(`Earlier.\n\n- ${"result ".repeat(30)}`);
|
||||
expect(line).toHaveLength(120);
|
||||
expect(line.endsWith("…")).toBe(true);
|
||||
expect(line).not.toContain(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarSessionNarrationController", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(10_000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// isolate:false shares the worker clock: a leaked fake timer deterministically
|
||||
// times out unrelated later files (seen: chat-background-tasks 60s hangs).
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("publishes assistant commentary and throttles a newer tool signal", async () => {
|
||||
const subscribeMessages = vi.fn(() =>
|
||||
Promise.resolve({ key: "agent:main:run", agentId: null }),
|
||||
);
|
||||
const unsubscribeMessages = vi.fn(() => Promise.resolve());
|
||||
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
const connectionIdentity = {};
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity,
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { phase: "commentary", text: "**Reading** files.", delta: "**Reading** files." },
|
||||
}),
|
||||
);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("session.tool", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "tool",
|
||||
data: { phase: "start", name: "read" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading files.");
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS - 1);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading files.");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Using read");
|
||||
|
||||
controller.disconnect();
|
||||
expect(unsubscribeMessages).toHaveBeenCalledWith({
|
||||
key: "agent:main:run",
|
||||
agentId: null,
|
||||
});
|
||||
expect(updates.at(-1)?.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("seeds a mid-run chat subscription from the cumulative message snapshot", () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
state: "delta",
|
||||
deltaText: "les now.",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Reading files now." }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading files now.");
|
||||
});
|
||||
|
||||
it("normalizes raw assistant events before publishing narration", () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: [
|
||||
"Visible work is complete.",
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"private runtime details",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"[[audio_as_voice]]",
|
||||
"REPLY_SKIP",
|
||||
].join("\n"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible work is complete.");
|
||||
});
|
||||
|
||||
it("removes a trailing heartbeat token from a mixed visible response", () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: `${"Visible progress continues. ".repeat(16)}Final visible status. HEARTBEAT_OK`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const line = updates.at(-1)?.get("agent:main:run");
|
||||
expect(line).toBe("Final visible status.");
|
||||
expect(line).not.toContain("HEARTBEAT_OK");
|
||||
});
|
||||
|
||||
it("keeps a truncated internal block hidden until its closing delimiter arrives", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: `Visible setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\n${"private runtime detail ".repeat(1_000)}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { delta: "\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nFinal bounded line." },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Final bounded line.");
|
||||
});
|
||||
|
||||
it("holds a partial internal delimiter until its next fragment proves the boundary", () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { text: "Visible setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONT" },
|
||||
}),
|
||||
);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { delta: "EXT>>>\nprivate runtime detail" },
|
||||
}),
|
||||
);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
|
||||
});
|
||||
|
||||
it("resets internal streaming state when a chat replacement is followed by deltas", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
state: "delta",
|
||||
deltaText: "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nprivate runtime text",
|
||||
}),
|
||||
);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
state: "delta",
|
||||
replace: true,
|
||||
deltaText: "Replacement",
|
||||
}),
|
||||
);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
state: "delta",
|
||||
deltaText: " now.",
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Replacement now.");
|
||||
});
|
||||
|
||||
it("keeps an outer internal block hidden after its opening delimiter leaves the raw buffer", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: [
|
||||
"Visible setup.",
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"private outer runtime detail ".repeat(1_000),
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"private nested runtime detail ".repeat(1_000),
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
].join("\n"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { delta: "\nStill private after the nested block." },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { delta: "\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nFinal public line." },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Final public line.");
|
||||
});
|
||||
|
||||
it("replaces stale assistant narration when an agent event requests replacement", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { text: "Draft answer." },
|
||||
}),
|
||||
);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { replace: true, text: "Corrected answer." },
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Corrected answer.");
|
||||
});
|
||||
|
||||
it("stays silent on a mid-run join until a cumulative snapshot aligns the stream", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
// First observed event is a bare delta: it could be the inside of an
|
||||
// internal-context block whose opening delimiter predates the join.
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
deltaText: "secret internal continuation.",
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.has("agent:main:run") ?? false).toBe(false);
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
deltaText: " Visible update.",
|
||||
message: { role: "assistant", content: "Public progress line. Visible update." },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible update.");
|
||||
});
|
||||
|
||||
it("retracts the shown line when a chat replacement is empty", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
deltaText: "Queued draft that gets withdrawn.",
|
||||
message: { role: "assistant", content: "Queued draft that gets withdrawn." },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Queued draft that gets withdrawn.");
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
deltaText: "",
|
||||
replace: true,
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.has("agent:main:run")).toBe(false);
|
||||
});
|
||||
|
||||
it("retracts the shown line when a replacement reduces to suppressed content", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { text: "Draft that gets withdrawn." },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Draft that gets withdrawn.");
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { replace: true, text: "HEARTBEAT_OK" },
|
||||
}),
|
||||
);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("agent", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "run-1",
|
||||
stream: "assistant",
|
||||
data: { replace: true, text: "" },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
expect(updates.at(-1)?.has("agent:main:run")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a stale subscribe completion remove replacement ownership", async () => {
|
||||
const completions: Array<{
|
||||
resolve: (subscription: { key: string; agentId: null }) => void;
|
||||
promise: Promise<{ key: string; agentId: null }>;
|
||||
}> = [];
|
||||
const subscribeMessages = vi.fn(() => {
|
||||
let resolve!: (subscription: { key: string; agentId: null }) => void;
|
||||
const promise = new Promise<{ key: string; agentId: null }>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
completions.push({ resolve, promise });
|
||||
return promise;
|
||||
});
|
||||
const unsubscribeMessages = vi.fn(() => Promise.resolve());
|
||||
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
|
||||
const controller = new SidebarSessionNarrationController(() => undefined);
|
||||
const base = {
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
};
|
||||
|
||||
controller.sync({ ...base, rows: [runningRow("agent:main:run")] });
|
||||
controller.sync({ ...base, rows: [] });
|
||||
controller.sync({ ...base, rows: [runningRow("agent:main:run")] });
|
||||
expect(subscribeMessages).toHaveBeenCalledTimes(2);
|
||||
|
||||
completions[1]?.resolve({ key: "agent:main:run", agentId: null });
|
||||
await Promise.resolve();
|
||||
completions[0]?.resolve({ key: "agent:main:run", agentId: null });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(unsubscribeMessages).not.toHaveBeenCalled();
|
||||
controller.disconnect();
|
||||
expect(unsubscribeMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rebinds an active global session when the selected agent changes", async () => {
|
||||
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
|
||||
Promise.resolve({ key, agentId: options?.agentId ?? null }),
|
||||
);
|
||||
const unsubscribeMessages = vi.fn(() => Promise.resolve());
|
||||
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
const base = {
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("global")],
|
||||
openSessionKey: "",
|
||||
};
|
||||
|
||||
controller.sync({ ...base, agentId: "main" });
|
||||
await Promise.resolve();
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "global",
|
||||
agentId: "main",
|
||||
state: "delta",
|
||||
deltaText: "Old agent work.",
|
||||
message: { role: "assistant", content: "Old agent work." },
|
||||
}),
|
||||
);
|
||||
expect(updates.at(-1)?.get("global")).toBe("Old agent work.");
|
||||
|
||||
controller.sync({ ...base, agentId: "research" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(unsubscribeMessages).toHaveBeenCalledWith({ key: "global", agentId: "main" });
|
||||
expect(subscribeMessages).toHaveBeenLastCalledWith("global", { agentId: "research" });
|
||||
expect(updates.at(-1)?.has("global")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets accumulated deltas when a new run starts for the same session", () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "first",
|
||||
state: "delta",
|
||||
deltaText: "Unfinished old work",
|
||||
message: { role: "assistant", content: "Unfinished old work" },
|
||||
}),
|
||||
);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "second",
|
||||
state: "delta",
|
||||
deltaText: "New run work.",
|
||||
message: { role: "assistant", content: "New run work." },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("New run work.");
|
||||
});
|
||||
|
||||
it("cleans up a pending subscription after a same-connection source swap", async () => {
|
||||
let resolveFirst!: (subscription: { key: string; agentId: null }) => void;
|
||||
const firstSource = {
|
||||
subscribeMessages: vi.fn(
|
||||
() =>
|
||||
new Promise<{ key: string; agentId: null }>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const secondSource = {
|
||||
subscribeMessages: vi.fn(),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const controller = new SidebarSessionNarrationController(() => undefined);
|
||||
const connectionIdentity = {};
|
||||
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity,
|
||||
source: firstSource,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity,
|
||||
source: secondSource,
|
||||
rows: [],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
resolveFirst({ key: "agent:main:run", agentId: null });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(firstSource.unsubscribeMessages).toHaveBeenCalledWith({
|
||||
key: "agent:main:run",
|
||||
agentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not hand an old agent's global subscription to the open chat", async () => {
|
||||
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
|
||||
Promise.resolve({ key, agentId: options?.agentId ?? null }),
|
||||
);
|
||||
const unsubscribeMessages = vi.fn(() => Promise.resolve());
|
||||
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
|
||||
const controller = new SidebarSessionNarrationController(() => undefined);
|
||||
const base = {
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("global")],
|
||||
};
|
||||
|
||||
controller.sync({ ...base, openSessionKey: "", agentId: "main" });
|
||||
await Promise.resolve();
|
||||
controller.sync({ ...base, openSessionKey: "global", agentId: "research" });
|
||||
|
||||
expect(unsubscribeMessages).toHaveBeenCalledWith({ key: "global", agentId: "main" });
|
||||
});
|
||||
|
||||
it("keeps the newest sentence after a response exceeds the retained tail", async () => {
|
||||
const source = {
|
||||
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
|
||||
unsubscribeMessages: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as SessionCapability;
|
||||
const updates: Array<ReadonlyMap<string, string>> = [];
|
||||
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
|
||||
controller.sync({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connectionIdentity: {},
|
||||
source,
|
||||
rows: [runningRow("agent:main:run")],
|
||||
openSessionKey: "",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "long",
|
||||
state: "delta",
|
||||
deltaText: `Preamble ${"x".repeat(20_000)}`,
|
||||
message: { role: "assistant", content: `Preamble ${"x".repeat(20_000)}` },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
|
||||
controller.handleEvent(
|
||||
gatewayEvent("chat", {
|
||||
sessionKey: "agent:main:run",
|
||||
runId: "long",
|
||||
state: "delta",
|
||||
deltaText: ". Final bounded line.",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: `Preamble ${"x".repeat(20_000)}. Final bounded line.`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updates.at(-1)?.get("agent:main:run")).toBe("Final bounded line.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
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 { 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 { extractText } from "../lib/chat/message-extract.ts";
|
||||
import type { SessionCapability } from "../lib/sessions/index.ts";
|
||||
import {
|
||||
areUiSessionKeysEquivalent,
|
||||
isUiGlobalSessionKey,
|
||||
normalizeAgentId,
|
||||
} from "../lib/sessions/session-key.ts";
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
import { deriveSidebarNarrationLine } from "./sidebar-narration-line.ts";
|
||||
|
||||
const SIDEBAR_NARRATION_SUBSCRIPTION_LIMIT = 6;
|
||||
const SIDEBAR_NARRATION_THROTTLE_MS = 2_000;
|
||||
const SIDEBAR_NARRATION_BUFFER_CHARS = 16_384;
|
||||
|
||||
type SessionMessageSubscription = Awaited<ReturnType<SessionCapability["subscribeMessages"]>>;
|
||||
|
||||
type NarrationSubscription = {
|
||||
source: SessionCapability;
|
||||
subscription: SessionMessageSubscription;
|
||||
};
|
||||
|
||||
type PendingSubscription = {
|
||||
agentId: string | null;
|
||||
connectionIdentity: object;
|
||||
source: SessionCapability;
|
||||
operationId: symbol;
|
||||
};
|
||||
|
||||
type NarrationActivity = { kind: "text"; text: string } | { kind: "line"; line: string };
|
||||
|
||||
type ThrottledLine = {
|
||||
lastPublishedAt: number;
|
||||
pending: NarrationActivity | null;
|
||||
timer: ReturnType<typeof globalThis.setTimeout> | null;
|
||||
};
|
||||
|
||||
export type SidebarNarrationSyncInput = {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
connectionIdentity: object | null;
|
||||
source: SessionCapability | null;
|
||||
rows: readonly SidebarRecentSession[];
|
||||
openSessionKey: string;
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
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 (
|
||||
let length = Math.min(text.length, ...tokens.map((token) => token.length - 1));
|
||||
length >= 1;
|
||||
length -= 1
|
||||
) {
|
||||
const suffix = text.slice(-length);
|
||||
if (tokens.some((token) => token.startsWith(suffix))) {
|
||||
return suffix;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function rowIsRunning(row: SidebarRecentSession): boolean {
|
||||
return row.hasActiveRun || row.status === "running";
|
||||
}
|
||||
|
||||
function rowRecency(row: SidebarRecentSession): number {
|
||||
return row.startedAt ?? row.updatedAt ?? 0;
|
||||
}
|
||||
|
||||
function eventAgentMatches(targetAgentId: string, payloadAgentId: unknown): boolean {
|
||||
return (
|
||||
typeof payloadAgentId !== "string" ||
|
||||
!payloadAgentId.trim() ||
|
||||
normalizeAgentId(payloadAgentId) === normalizeAgentId(targetAgentId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Owns the bounded session subscriptions and per-row activity throttles. */
|
||||
export class SidebarSessionNarrationController {
|
||||
private source: SessionCapability | null = null;
|
||||
private connectionIdentity: object | null = null;
|
||||
private connected = false;
|
||||
private enabled = false;
|
||||
private openSessionKey = "";
|
||||
private agentId = "main";
|
||||
private desiredKeys = new Set<string>();
|
||||
private subscriptions = new Map<string, NarrationSubscription>();
|
||||
private pendingSubscriptions = new Map<string, PendingSubscription>();
|
||||
private deferredSubscriptions = new Map<string, ReturnType<typeof globalThis.setTimeout>>();
|
||||
private internalRuntimeBlockDepth = new Map<string, number>();
|
||||
private internalRuntimeDelimiterTails = new Map<string, string>();
|
||||
// Chars of the FULL cumulative assistant stream consumed so far, per session.
|
||||
// Length arithmetic (not stored text) keeps append detection O(delta) even
|
||||
// after the visible buffer trims; storing the raw stream made long responses
|
||||
// fail prefix checks post-trim and reparse the whole snapshot per delta.
|
||||
private consumedStreamLength = new Map<string, number>();
|
||||
private visibleText = new Map<string, string>();
|
||||
private runIds = new Map<string, string>();
|
||||
private throttles = new Map<string, ThrottledLine>();
|
||||
private lines = new Map<string, string>();
|
||||
|
||||
constructor(private readonly onLinesChanged: (lines: ReadonlyMap<string, string>) => void) {}
|
||||
|
||||
sync(input: SidebarNarrationSyncInput): void {
|
||||
const previousOpenSessionKey = this.openSessionKey;
|
||||
const connectionChanged = this.connectionIdentity !== input.connectionIdentity;
|
||||
const sourceChanged = this.source !== input.source;
|
||||
const disconnected = !input.connected || !input.connectionIdentity || !input.source;
|
||||
if (connectionChanged || sourceChanged || disconnected) {
|
||||
// A replaced or closed socket already discarded its server-side set.
|
||||
// Never send cleanup through a new connection for ownership from the old one.
|
||||
this.resetSubscriptions({ unsubscribe: !connectionChanged && this.connected });
|
||||
}
|
||||
|
||||
this.source = input.source;
|
||||
this.connectionIdentity = input.connectionIdentity;
|
||||
this.connected = input.connected;
|
||||
this.enabled = input.enabled;
|
||||
this.openSessionKey = input.openSessionKey.trim();
|
||||
this.agentId = normalizeAgentId(input.agentId);
|
||||
|
||||
if (disconnected || !input.enabled) {
|
||||
this.desiredKeys = new Set();
|
||||
this.resetSubscriptions({ unsubscribe: !disconnected });
|
||||
this.clearAllLines();
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = input.rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(
|
||||
({ row }) => rowIsRunning(row) && !areUiSessionKeysEquivalent(row.key, this.openSessionKey),
|
||||
)
|
||||
.toSorted(
|
||||
(left, right) => rowRecency(right.row) - rowRecency(left.row) || left.index - right.index,
|
||||
)
|
||||
.slice(0, SIDEBAR_NARRATION_SUBSCRIPTION_LIMIT);
|
||||
const nextDesired = new Set(candidates.map(({ row }) => row.key));
|
||||
|
||||
for (const key of this.desiredKeys) {
|
||||
if (nextDesired.has(key)) {
|
||||
continue;
|
||||
}
|
||||
// The chat pane and sidebar share a per-connection Set, not a refcount.
|
||||
// Hand an opening row to chat without deleting the subscription it now owns.
|
||||
const ownedAgentId =
|
||||
this.subscriptions.get(key)?.subscription.agentId ??
|
||||
this.pendingSubscriptions.get(key)?.agentId ??
|
||||
null;
|
||||
const handedToChat = this.subscriptionScopeMatchesOpenChat(key, ownedAgentId);
|
||||
this.releaseKey(key, { unsubscribe: !handedToChat });
|
||||
}
|
||||
|
||||
this.desiredKeys = nextDesired;
|
||||
for (const key of nextDesired) {
|
||||
const targetAgentId = this.subscriptionAgentId(key);
|
||||
const ownedAgentId = this.subscriptions.get(key)?.subscription.agentId ?? null;
|
||||
const pendingAgentId = this.pendingSubscriptions.get(key)?.agentId ?? null;
|
||||
if (
|
||||
(this.subscriptions.has(key) && ownedAgentId !== targetAgentId) ||
|
||||
(this.pendingSubscriptions.has(key) && pendingAgentId !== targetAgentId)
|
||||
) {
|
||||
this.releaseKey(key, { unsubscribe: true });
|
||||
}
|
||||
if (this.subscriptions.has(key) || this.pendingSubscriptions.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const chatJustReleased = areUiSessionKeysEquivalent(key, previousOpenSessionKey);
|
||||
this.scheduleSubscription(key, chatJustReleased);
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(event: GatewayEventFrame): void {
|
||||
if (!this.enabled || !this.connected) {
|
||||
return;
|
||||
}
|
||||
if (event.event === "chat") {
|
||||
this.handleChatEvent(event.payload);
|
||||
return;
|
||||
}
|
||||
if (event.event === "agent" || event.event === "session.tool") {
|
||||
this.handleAgentEvent(event.payload);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.desiredKeys = new Set();
|
||||
this.resetSubscriptions({ unsubscribe: this.connected });
|
||||
this.clearAllLines();
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private scheduleSubscription(key: string, defer: boolean): void {
|
||||
if (!defer) {
|
||||
void this.subscribeKey(key);
|
||||
return;
|
||||
}
|
||||
if (this.deferredSubscriptions.has(key)) {
|
||||
return;
|
||||
}
|
||||
// Chat unsubscribes during the same Lit update. Queueing this request makes
|
||||
// the wire order unsubscribe -> subscribe when a running row leaves chat.
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
this.deferredSubscriptions.delete(key);
|
||||
void this.subscribeKey(key);
|
||||
}, 0);
|
||||
this.deferredSubscriptions.set(key, timer);
|
||||
}
|
||||
|
||||
private async subscribeKey(key: string): Promise<void> {
|
||||
const source = this.source;
|
||||
const connectionIdentity = this.connectionIdentity;
|
||||
if (
|
||||
!source ||
|
||||
!connectionIdentity ||
|
||||
!this.connected ||
|
||||
!this.enabled ||
|
||||
!this.desiredKeys.has(key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const operationId = Symbol(key);
|
||||
const agentId = this.subscriptionAgentId(key);
|
||||
this.pendingSubscriptions.set(key, { agentId, connectionIdentity, operationId, source });
|
||||
try {
|
||||
const subscription = await source.subscribeMessages(key, {
|
||||
agentId: agentId ?? undefined,
|
||||
});
|
||||
const pending = this.pendingSubscriptions.get(key);
|
||||
if (pending?.operationId !== operationId || pending.source !== source) {
|
||||
const completedAgentId = subscription.agentId ?? null;
|
||||
const replacementOwnsSameScope =
|
||||
this.desiredKeys.has(key) &&
|
||||
(this.subscriptions.get(key)?.subscription.agentId === completedAgentId ||
|
||||
this.pendingSubscriptions.get(key)?.agentId === completedAgentId ||
|
||||
(this.deferredSubscriptions.has(key) &&
|
||||
this.subscriptionAgentId(key) === completedAgentId));
|
||||
if (
|
||||
!replacementOwnsSameScope &&
|
||||
connectionIdentity === this.connectionIdentity &&
|
||||
!this.subscriptionScopeMatchesOpenChat(key, completedAgentId)
|
||||
) {
|
||||
await source.unsubscribeMessages(subscription).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.pendingSubscriptions.delete(key);
|
||||
if (
|
||||
source !== this.source ||
|
||||
connectionIdentity !== this.connectionIdentity ||
|
||||
!this.connected ||
|
||||
!this.enabled ||
|
||||
!this.desiredKeys.has(key)
|
||||
) {
|
||||
if (!this.subscriptionScopeMatchesOpenChat(key, subscription.agentId ?? null)) {
|
||||
await source.unsubscribeMessages(subscription).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.subscriptions.set(key, { source, subscription });
|
||||
} catch {
|
||||
const pending = this.pendingSubscriptions.get(key);
|
||||
if (pending?.operationId === operationId) {
|
||||
this.pendingSubscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private subscriptionAgentId(key: string): string | null {
|
||||
return isUiGlobalSessionKey(key) ? this.agentId : null;
|
||||
}
|
||||
|
||||
private subscriptionScopeMatchesOpenChat(key: string, agentId: string | null): boolean {
|
||||
if (!areUiSessionKeysEquivalent(key, this.openSessionKey)) {
|
||||
return false;
|
||||
}
|
||||
return !isUiGlobalSessionKey(key) || agentId === this.subscriptionAgentId(key);
|
||||
}
|
||||
|
||||
private releaseKey(key: string, options: { unsubscribe: boolean }): void {
|
||||
const deferred = this.deferredSubscriptions.get(key);
|
||||
if (deferred) {
|
||||
globalThis.clearTimeout(deferred);
|
||||
this.deferredSubscriptions.delete(key);
|
||||
}
|
||||
this.pendingSubscriptions.delete(key);
|
||||
const owned = this.subscriptions.get(key);
|
||||
this.subscriptions.delete(key);
|
||||
if (owned && options.unsubscribe) {
|
||||
void owned.source.unsubscribeMessages(owned.subscription).catch(() => undefined);
|
||||
}
|
||||
this.clearLine(key);
|
||||
}
|
||||
|
||||
private resetSubscriptions(options: { unsubscribe: boolean }): void {
|
||||
const keys = new Set([
|
||||
...this.subscriptions.keys(),
|
||||
...this.pendingSubscriptions.keys(),
|
||||
...this.deferredSubscriptions.keys(),
|
||||
]);
|
||||
for (const key of keys) {
|
||||
this.releaseKey(key, options);
|
||||
}
|
||||
}
|
||||
|
||||
private matchingDesiredKey(sessionKey: unknown, payloadAgentId: unknown): string | null {
|
||||
if (typeof sessionKey !== "string" || !sessionKey.trim()) {
|
||||
return null;
|
||||
}
|
||||
for (const key of this.desiredKeys) {
|
||||
if (
|
||||
areUiSessionKeysEquivalent(key, sessionKey) &&
|
||||
(!isUiGlobalSessionKey(key) || eventAgentMatches(this.agentId, payloadAgentId))
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private handleChatEvent(payload: unknown): void {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const key = this.matchingDesiredKey(record.sessionKey, record.agentId);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
this.observeRun(key, record.runId);
|
||||
const message = record.message as Record<string, unknown> | undefined;
|
||||
if (message && typeof message.role === "string" && message.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
const deltaText = typeof record.deltaText === "string" ? record.deltaText : "";
|
||||
const messageText = message ? extractText(message) : null;
|
||||
const consumed = this.consumedStreamLength.get(key) ?? 0;
|
||||
// A newly subscribed sidebar can join mid-run. Within one run the server's
|
||||
// cumulative snapshot grows monotonically, so length arithmetic decides
|
||||
// append vs rejoin without storing the raw stream.
|
||||
if (record.replace === true) {
|
||||
// Handle before any truthiness gate: an EMPTY replacement retracts the
|
||||
// narration line (streamLength 0 takes publishText's clearing path).
|
||||
const replacement = deltaText || messageText || "";
|
||||
this.publishText(key, {
|
||||
streamLength: replacement.length,
|
||||
fragment: replacement,
|
||||
reset: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (deltaText) {
|
||||
if (messageText) {
|
||||
const appends = consumed > 0 && messageText.length - deltaText.length === consumed;
|
||||
if (appends) {
|
||||
this.publishText(key, {
|
||||
streamLength: messageText.length,
|
||||
fragment: deltaText,
|
||||
reset: false,
|
||||
});
|
||||
} else {
|
||||
this.publishText(key, {
|
||||
streamLength: messageText.length,
|
||||
fragment: messageText,
|
||||
reset: true,
|
||||
});
|
||||
}
|
||||
} else if (consumed > 0) {
|
||||
this.publishText(key, {
|
||||
streamLength: consumed + deltaText.length,
|
||||
fragment: deltaText,
|
||||
reset: false,
|
||||
});
|
||||
}
|
||||
// consumed === 0 with a bare delta: a mid-run join may sit INSIDE an
|
||||
// internal-context block whose opening delimiter we never saw. Stay
|
||||
// silent until a cumulative snapshot or replacement aligns the stream.
|
||||
return;
|
||||
}
|
||||
if (messageText) {
|
||||
this.publishText(key, {
|
||||
streamLength: messageText.length,
|
||||
fragment: messageText,
|
||||
reset: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private publishText(
|
||||
key: string,
|
||||
update: { streamLength: number; fragment: string; reset: boolean },
|
||||
): void {
|
||||
if (update.streamLength <= 0) {
|
||||
if (update.reset) {
|
||||
// An empty replacement retracts prior content; a stale line must not
|
||||
// outlive it (the draft it showed may have been withdrawn).
|
||||
this.clearLine(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.consumedStreamLength.set(key, update.streamLength);
|
||||
if (update.reset) {
|
||||
this.internalRuntimeBlockDepth.delete(key);
|
||||
this.internalRuntimeDelimiterTails.delete(key);
|
||||
this.visibleText.delete(key);
|
||||
// A replacement supersedes anything still queued behind the throttle;
|
||||
// otherwise a pre-replacement draft could republish after retraction.
|
||||
const throttle = this.throttles.get(key);
|
||||
if (throttle) {
|
||||
throttle.pending = null;
|
||||
}
|
||||
}
|
||||
const visibleFragment = this.stripInternalRuntimeFragment(key, update.fragment);
|
||||
const previousVisibleText = update.reset ? "" : (this.visibleText.get(key) ?? "");
|
||||
const nextVisibleText = `${previousVisibleText}${visibleFragment}`;
|
||||
if (!nextVisibleText) {
|
||||
if (update.reset && this.lines.delete(key)) {
|
||||
this.onLinesChanged(new Map(this.lines));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const boundedVisibleText =
|
||||
nextVisibleText.length > SIDEBAR_NARRATION_BUFFER_CHARS
|
||||
? sliceUtf16Safe(nextVisibleText, -SIDEBAR_NARRATION_BUFFER_CHARS)
|
||||
: nextVisibleText;
|
||||
this.visibleText.set(key, boundedVisibleText);
|
||||
this.publishThrottled(key, { kind: "text", text: boundedVisibleText });
|
||||
}
|
||||
|
||||
private stripInternalRuntimeFragment(key: string, fragment: string): string {
|
||||
const pendingDelimiter = this.internalRuntimeDelimiterTails.get(key) ?? "";
|
||||
this.internalRuntimeDelimiterTails.delete(key);
|
||||
const text = `${pendingDelimiter}${fragment}`;
|
||||
let depth = this.internalRuntimeBlockDepth.get(key) ?? 0;
|
||||
let cursor = 0;
|
||||
let visible = "";
|
||||
|
||||
while (cursor < text.length) {
|
||||
const nextBegin = text.indexOf(INTERNAL_RUNTIME_CONTEXT_BEGIN, cursor);
|
||||
const nextEnd = text.indexOf(INTERNAL_RUNTIME_CONTEXT_END, cursor);
|
||||
if (depth === 0) {
|
||||
if (nextBegin === -1 && nextEnd === -1) {
|
||||
visible += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
if (nextEnd !== -1 && (nextBegin === -1 || nextEnd < nextBegin)) {
|
||||
// A stray closing delimiter means this fragment may start inside an
|
||||
// already-trimmed block. Fail closed until that boundary passes.
|
||||
cursor = nextEnd + INTERNAL_RUNTIME_CONTEXT_END.length;
|
||||
continue;
|
||||
}
|
||||
visible += text.slice(cursor, nextBegin);
|
||||
depth = 1;
|
||||
cursor = nextBegin + INTERNAL_RUNTIME_CONTEXT_BEGIN.length;
|
||||
continue;
|
||||
}
|
||||
if (nextBegin === -1 && nextEnd === -1) {
|
||||
break;
|
||||
}
|
||||
if (nextBegin !== -1 && (nextEnd === -1 || nextBegin < nextEnd)) {
|
||||
depth += 1;
|
||||
cursor = nextBegin + INTERNAL_RUNTIME_CONTEXT_BEGIN.length;
|
||||
continue;
|
||||
}
|
||||
depth -= 1;
|
||||
cursor = nextEnd + INTERNAL_RUNTIME_CONTEXT_END.length;
|
||||
}
|
||||
|
||||
const delimiterPrefix = trailingInternalDelimiterPrefix(text);
|
||||
if (delimiterPrefix) {
|
||||
this.internalRuntimeDelimiterTails.set(key, delimiterPrefix);
|
||||
if (depth === 0 && visible.endsWith(delimiterPrefix)) {
|
||||
visible = visible.slice(0, -delimiterPrefix.length);
|
||||
}
|
||||
}
|
||||
if (depth > 0) {
|
||||
this.internalRuntimeBlockDepth.set(key, depth);
|
||||
} else {
|
||||
this.internalRuntimeBlockDepth.delete(key);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private handleAgentEvent(payload: unknown): void {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const key = this.matchingDesiredKey(record.sessionKey, record.agentId);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
this.observeRun(key, record.runId);
|
||||
const data = record.data as Record<string, unknown> | undefined;
|
||||
if (record.stream === "tool") {
|
||||
const name = typeof data?.name === "string" ? data.name.trim() : "";
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
this.publishThrottled(key, {
|
||||
kind: "line",
|
||||
line: t("chat.sidebar.toolActivity", { tool: name }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (record.stream !== "assistant") {
|
||||
return;
|
||||
}
|
||||
const text = typeof data?.text === "string" ? data.text : "";
|
||||
const delta = typeof data?.delta === "string" ? data.delta : "";
|
||||
const consumed = this.consumedStreamLength.get(key) ?? 0;
|
||||
if (data?.replace === true) {
|
||||
const replacement = text || delta;
|
||||
this.publishText(key, {
|
||||
streamLength: replacement.length,
|
||||
fragment: replacement,
|
||||
reset: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (text) {
|
||||
// Same monotonic-length contract as the chat path: append when the
|
||||
// cumulative snapshot grew by exactly this event's delta, else rejoin.
|
||||
if (delta && consumed > 0 && text.length - delta.length === consumed) {
|
||||
this.publishText(key, { streamLength: text.length, fragment: delta, reset: false });
|
||||
} else if (text.length !== consumed) {
|
||||
this.publishText(key, { streamLength: text.length, fragment: text, reset: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (delta && consumed > 0) {
|
||||
this.publishText(key, {
|
||||
streamLength: consumed + delta.length,
|
||||
fragment: delta,
|
||||
reset: false,
|
||||
});
|
||||
}
|
||||
// consumed === 0 with a bare delta: same mid-run-join hazard as the chat
|
||||
// path — suppress until a cumulative snapshot aligns the stream.
|
||||
}
|
||||
|
||||
private observeRun(key: string, runIdValue: unknown): void {
|
||||
const runId = typeof runIdValue === "string" ? runIdValue.trim() : "";
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
const previousRunId = this.runIds.get(key);
|
||||
if (previousRunId && previousRunId !== runId) {
|
||||
this.clearLine(key);
|
||||
}
|
||||
this.runIds.set(key, runId);
|
||||
}
|
||||
|
||||
private publishThrottled(key: string, activity: NarrationActivity): void {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
throttle.pending = activity;
|
||||
if (throttle.timer) {
|
||||
return;
|
||||
}
|
||||
throttle.timer = globalThis.setTimeout(
|
||||
() => {
|
||||
throttle.timer = null;
|
||||
const pending = throttle.pending;
|
||||
throttle.pending = null;
|
||||
if (!pending || !this.desiredKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
throttle.lastPublishedAt = Date.now();
|
||||
this.publishActivity(key, pending);
|
||||
},
|
||||
SIDEBAR_NARRATION_THROTTLE_MS - (now - throttle.lastPublishedAt),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
: "";
|
||||
if (line) {
|
||||
this.setLine(key, line);
|
||||
return;
|
||||
}
|
||||
// The activity text is the full visible buffer: normalizing it to nothing
|
||||
// means only suppressed content remains (e.g. a replacement that reduced
|
||||
// to REPLY_SKIP or a heartbeat), so retract any previously shown line.
|
||||
if (activity.kind === "text" && this.lines.delete(key)) {
|
||||
this.onLinesChanged(new Map(this.lines));
|
||||
}
|
||||
}
|
||||
|
||||
private setLine(key: string, line: string): void {
|
||||
if (this.lines.get(key) === line) {
|
||||
return;
|
||||
}
|
||||
this.lines.set(key, line);
|
||||
this.onLinesChanged(new Map(this.lines));
|
||||
}
|
||||
|
||||
private clearLine(key: string): void {
|
||||
this.internalRuntimeBlockDepth.delete(key);
|
||||
this.internalRuntimeDelimiterTails.delete(key);
|
||||
this.consumedStreamLength.delete(key);
|
||||
this.visibleText.delete(key);
|
||||
this.runIds.delete(key);
|
||||
const throttle = this.throttles.get(key);
|
||||
if (throttle?.timer) {
|
||||
globalThis.clearTimeout(throttle.timer);
|
||||
}
|
||||
this.throttles.delete(key);
|
||||
if (this.lines.delete(key)) {
|
||||
this.onLinesChanged(new Map(this.lines));
|
||||
}
|
||||
}
|
||||
|
||||
private clearAllLines(): void {
|
||||
for (const throttle of this.throttles.values()) {
|
||||
if (throttle.timer) {
|
||||
globalThis.clearTimeout(throttle.timer);
|
||||
}
|
||||
}
|
||||
this.internalRuntimeBlockDepth.clear();
|
||||
this.internalRuntimeDelimiterTails.clear();
|
||||
this.consumedStreamLength.clear();
|
||||
this.visibleText.clear();
|
||||
this.runIds.clear();
|
||||
this.throttles.clear();
|
||||
if (this.lines.size > 0) {
|
||||
this.lines.clear();
|
||||
this.onLinesChanged(new Map());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
spawnedBy: row.spawnedBy,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
endedAt: row.endedAt,
|
||||
runtimeMs: row.runtimeMs,
|
||||
runtimeSampledAt,
|
||||
|
||||
@@ -71,6 +71,7 @@ export type SidebarRecentSession = {
|
||||
spawnedBy?: string;
|
||||
status?: SessionRunStatus;
|
||||
startedAt?: number;
|
||||
updatedAt?: number | null;
|
||||
endedAt?: number;
|
||||
runtimeMs?: number;
|
||||
runtimeSampledAt?: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts";
|
||||
import "../test-helpers/app-sidebar-cases/child-sessions.ts";
|
||||
import "../test-helpers/app-sidebar-cases/group-mutations.ts";
|
||||
import "../test-helpers/app-sidebar-cases/interactions.ts";
|
||||
import "../test-helpers/app-sidebar-cases/narration.ts";
|
||||
import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts";
|
||||
import "../test-helpers/app-sidebar-cases/sessions.ts";
|
||||
import "../test-helpers/app-sidebar-cases/session-list-sections.ts";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
import { resolveSidebarSessionSubtitle } from "./session-row-subtitle.ts";
|
||||
|
||||
function workSession(): SidebarRecentSession {
|
||||
return {
|
||||
attention: { kind: "none" },
|
||||
hasActiveRun: false,
|
||||
label: "Backing session",
|
||||
status: "done",
|
||||
subtitle: "~/Projects/openclaw",
|
||||
workSession: true,
|
||||
} as unknown as SidebarRecentSession;
|
||||
}
|
||||
|
||||
describe("resolveSidebarSessionSubtitle", () => {
|
||||
it("does not fall back to a backing work subtitle when catalog display omits one", () => {
|
||||
expect(
|
||||
resolveSidebarSessionSubtitle({
|
||||
session: workSession(),
|
||||
hasDisplay: true,
|
||||
displaySubtitle: undefined,
|
||||
sidebarLiveActivity: true,
|
||||
narrationLine: undefined,
|
||||
}),
|
||||
).toEqual({ subtitle: undefined, narration: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
import { sessionAttentionSubtitle } from "./session-attention-presentation.ts";
|
||||
|
||||
type SidebarSessionSubtitle = {
|
||||
subtitle: string | undefined;
|
||||
narration: string | undefined;
|
||||
};
|
||||
|
||||
/** Resolves the single subtitle slot without displacing pending attention. */
|
||||
export function resolveSidebarSessionSubtitle(params: {
|
||||
session: SidebarRecentSession;
|
||||
hasDisplay: boolean;
|
||||
displaySubtitle: string | undefined;
|
||||
sidebarLiveActivity: boolean;
|
||||
narrationLine: string | undefined;
|
||||
}): SidebarSessionSubtitle {
|
||||
const { session } = params;
|
||||
const attention = sessionAttentionSubtitle(session.attention);
|
||||
const running = session.hasActiveRun || session.status === "running";
|
||||
const narration =
|
||||
attention || !params.sidebarLiveActivity || !running ? undefined : params.narrationLine;
|
||||
const workSubtitle = params.hasDisplay
|
||||
? params.displaySubtitle
|
||||
: session.subtitle && session.workSession && session.subtitle !== session.label
|
||||
? session.subtitle
|
||||
: undefined;
|
||||
return { subtitle: attention ?? narration ?? workSubtitle, narration };
|
||||
}
|
||||
|
||||
export function renderSidebarSessionSubtitle(value: SidebarSessionSubtitle) {
|
||||
if (!value.subtitle) {
|
||||
return nothing;
|
||||
}
|
||||
return value.narration
|
||||
? keyed(
|
||||
value.narration,
|
||||
html`<span
|
||||
class="sidebar-recent-session__subtitle sidebar-recent-session__subtitle--narration"
|
||||
>${value.subtitle}</span
|
||||
>`,
|
||||
)
|
||||
: html`<span class="sidebar-recent-session__subtitle">${value.subtitle}</span>`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { clampText } from "../lib/format.ts";
|
||||
|
||||
const SIDEBAR_NARRATION_MAX_LENGTH = 120;
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/```/g, " ")
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]|\d+[.)])\s+/gm, "")
|
||||
.replace(/[*_~]+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Compact the newest prose into one quiet, stable sidebar line. */
|
||||
export function deriveSidebarNarrationLine(text: string): string {
|
||||
const paragraphs = text
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.split(/\n\s*\n/)
|
||||
.map((paragraph) => stripMarkdown(paragraph))
|
||||
.filter(Boolean);
|
||||
const paragraph = paragraphs.at(-1) ?? "";
|
||||
if (!paragraph) {
|
||||
return "";
|
||||
}
|
||||
const fragments = paragraph.match(/[^.!?…]+(?:[.!?…]+(?=\s|$)|$)/g);
|
||||
const newest =
|
||||
fragments?.map((fragment) => fragment.trim()).findLast((fragment) => Boolean(fragment)) ??
|
||||
paragraph;
|
||||
return clampText(newest, SIDEBAR_NARRATION_MAX_LENGTH);
|
||||
}
|
||||
@@ -1279,6 +1279,12 @@ export const en: TranslationMap = {
|
||||
title: "Chat",
|
||||
hint: "Browser-local chat preferences.",
|
||||
},
|
||||
sidebarPrefs: {
|
||||
title: "Sidebar",
|
||||
hint: "Choose what appears while sessions are running.",
|
||||
liveActivity: "Show live agent activity in sidebar",
|
||||
liveActivityHint: "Show the latest assistant or tool activity beneath running sessions.",
|
||||
},
|
||||
connection: {
|
||||
title: "Connection",
|
||||
gateway: "Gateway",
|
||||
@@ -3722,6 +3728,7 @@ export const en: TranslationMap = {
|
||||
sortUpdated: "Last updated",
|
||||
sessionMenu: "Actions for {session}",
|
||||
sessionMenuMany: "Actions for {count} threads",
|
||||
toolActivity: "Using {tool}",
|
||||
},
|
||||
welcome: {
|
||||
hintBeforeShortcut: "Type a message below ·",
|
||||
|
||||
@@ -11,10 +11,10 @@ function escapeRegExp(value: string): string {
|
||||
export function stripHeartbeatTokenForDisplay(
|
||||
raw: string,
|
||||
maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
): { shouldSkip: boolean } {
|
||||
): { shouldSkip: boolean; text: string } {
|
||||
let text = raw.trim();
|
||||
if (!text) {
|
||||
return { shouldSkip: true };
|
||||
return { shouldSkip: true, text: "" };
|
||||
}
|
||||
const strippedMarkup = text
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
@@ -22,7 +22,9 @@ export function stripHeartbeatTokenForDisplay(
|
||||
.replace(/^[*`~_]+/, "")
|
||||
.replace(/[*`~_]+$/, "");
|
||||
if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
|
||||
return { shouldSkip: false };
|
||||
// strippedMarkup exists only to DETECT tokens hidden behind markup; without
|
||||
// a token, returning it would corrupt legitimate angle-bracket prose.
|
||||
return { shouldSkip: false, text };
|
||||
}
|
||||
|
||||
const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
|
||||
@@ -49,9 +51,9 @@ export function stripHeartbeatTokenForDisplay(
|
||||
}
|
||||
|
||||
if (!didStrip) {
|
||||
return { shouldSkip: false };
|
||||
return { shouldSkip: false, text };
|
||||
}
|
||||
return { shouldSkip: !text || text.length <= maxAckChars };
|
||||
return { shouldSkip: !text || text.length <= maxAckChars, text };
|
||||
}
|
||||
|
||||
function isHiddenDisplayBlockType(type: unknown): boolean {
|
||||
|
||||
@@ -63,6 +63,7 @@ type ConfigSelection = { activeSection: string | null; activeSubsection: string
|
||||
// across devices is owned by app/server-prefs.ts, not by this type.
|
||||
type ConfigPageSetting =
|
||||
| "textScale"
|
||||
| "sidebarLiveActivity"
|
||||
| "chatSendShortcut"
|
||||
| "chatFollowUpMode"
|
||||
| "catalogOpenTarget"
|
||||
@@ -614,6 +615,7 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
themeMode: next.themeMode,
|
||||
customTheme: next.customTheme,
|
||||
textScale: next.textScale,
|
||||
sidebarLiveActivity: next.sidebarLiveActivity,
|
||||
chatSendShortcut: next.chatSendShortcut,
|
||||
chatFollowUpMode: next.chatFollowUpMode,
|
||||
catalogOpenTarget: next.catalogOpenTarget,
|
||||
@@ -829,6 +831,8 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
onOpenCustomThemeImport: () => this.openCustomThemeImport(),
|
||||
textScale: this.settings.textScale ?? 100,
|
||||
setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)),
|
||||
sidebarLiveActivity: this.settings.sidebarLiveActivity !== false,
|
||||
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
|
||||
lobsterPetVisits: this.settings.lobsterPetVisits !== false,
|
||||
setLobsterPetVisits: (enabled) =>
|
||||
this.applySettings({ ...this.settings, lobsterPetVisits: enabled }),
|
||||
|
||||
@@ -6,6 +6,7 @@ export const GENERAL_SETTINGS_TARGET_IDS = {
|
||||
export const APPEARANCE_SETTINGS_TARGET_IDS = {
|
||||
theme: "settings-appearance-theme",
|
||||
textSize: "settings-appearance-text-size",
|
||||
sidebar: "settings-appearance-sidebar",
|
||||
chat: "settings-appearance-chat",
|
||||
connection: "settings-appearance-connection",
|
||||
} as const;
|
||||
|
||||
@@ -66,6 +66,8 @@ describe("config view", () => {
|
||||
onOpenCustomThemeImport: vi.fn(),
|
||||
textScale: 100,
|
||||
setTextScale: vi.fn(),
|
||||
sidebarLiveActivity: true,
|
||||
setSidebarLiveActivity: vi.fn(),
|
||||
chatSendShortcut: "enter" as const,
|
||||
setChatSendShortcut: vi.fn(),
|
||||
chatFollowUpMode: undefined,
|
||||
@@ -1347,6 +1349,24 @@ describe("config view", () => {
|
||||
expect(container.textContent).toContain("Hold microphone button to dictate");
|
||||
});
|
||||
|
||||
it("renders and changes the live sidebar activity preference", () => {
|
||||
const setSidebarLiveActivity = vi.fn();
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
sidebarLiveActivity: true,
|
||||
setSidebarLiveActivity,
|
||||
});
|
||||
|
||||
const row = Array.from(container.querySelectorAll<HTMLElement>(".settings-row--toggle")).find(
|
||||
(candidate) => candidate.textContent?.includes("Show live agent activity in sidebar"),
|
||||
);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.querySelector<HTMLElement & { checked: boolean }>("wa-switch")?.checked).toBe(true);
|
||||
row?.click();
|
||||
expect(setSidebarLiveActivity).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("marks browser follow-up overrides and resets them to the server", () => {
|
||||
const setChatFollowUpMode = vi.fn();
|
||||
const { container } = renderConfigView({
|
||||
|
||||
@@ -175,6 +175,8 @@ export type ConfigProps = {
|
||||
onOpenCustomThemeImport?: () => void;
|
||||
textScale: number;
|
||||
setTextScale: (value: number) => void;
|
||||
sidebarLiveActivity: boolean;
|
||||
setSidebarLiveActivity: (enabled: boolean) => void;
|
||||
lobsterPetVisits?: boolean;
|
||||
setLobsterPetVisits?: (enabled: boolean) => void;
|
||||
lobsterPetSounds?: boolean;
|
||||
@@ -1169,6 +1171,27 @@ function renderLobsterPetSection(props: ConfigProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSidebarPreferencesSection(props: ConfigProps) {
|
||||
return html`
|
||||
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.sidebar} class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h2 class="settings-section__heading">${t("configView.sidebarPrefs.title")}</h2>
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
${t("configView.sidebarPrefs.hint")} ${t("configView.syncedHint")}
|
||||
</p>
|
||||
<div class="settings-group">
|
||||
${renderSettingsToggleRow({
|
||||
title: t("configView.sidebarPrefs.liveActivity"),
|
||||
description: t("configView.sidebarPrefs.liveActivityHint"),
|
||||
checked: props.sidebarLiveActivity,
|
||||
onChange: props.setSidebarLiveActivity,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAppearanceSection(props: ConfigProps) {
|
||||
const viewState = props.viewState;
|
||||
const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true;
|
||||
@@ -1363,7 +1386,8 @@ function renderAppearanceSection(props: ConfigProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${renderLobsterPetSection(props)} ${renderChatPreferencesSection(props)}
|
||||
${renderSidebarPreferencesSection(props)} ${renderLobsterPetSection(props)}
|
||||
${renderChatPreferencesSection(props)}
|
||||
|
||||
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.connection} class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
|
||||
@@ -3518,6 +3518,19 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
|
||||
color: color-mix(in srgb, var(--danger) 86%, var(--text));
|
||||
}
|
||||
|
||||
.sidebar-recent-session__subtitle--narration {
|
||||
animation: sidebar-narration-arrive var(--duration-fast) ease-out;
|
||||
}
|
||||
|
||||
@keyframes sidebar-narration-arrive {
|
||||
from {
|
||||
opacity: 0.45;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Kept for the agent-chip menu switcher rows (sessions list is single-agent now). */
|
||||
.sidebar-agent-section__avatar {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import { createGatewayHarness, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
|
||||
import { waitForFast } from "../wait-for.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
const defaults: SessionsListResult["defaults"] = {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
};
|
||||
|
||||
function runningRow(key: string, updatedAt: number): GatewaySessionRow {
|
||||
return {
|
||||
key,
|
||||
kind: "direct",
|
||||
label: `Run ${updatedAt}`,
|
||||
updatedAt,
|
||||
startedAt: updatedAt,
|
||||
status: "running",
|
||||
hasActiveRun: true,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionsResult(rows: GatewaySessionRow[]): SessionsListResult {
|
||||
return {
|
||||
ts: 10,
|
||||
path: "",
|
||||
count: rows.length,
|
||||
defaults,
|
||||
sessions: rows,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AppSidebar live narration", () => {
|
||||
it("subscribes for a running row, renders prose, and cleans up when the run ends", async () => {
|
||||
const key = "agent:main:narrated";
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", [key]);
|
||||
sessions.publishList({ result: sessionsResult([runningRow(key, 5)]), agentId: "main" });
|
||||
const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions);
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(1));
|
||||
expect(sessions.subscribeMessages).toHaveBeenCalledWith(key, { agentId: undefined });
|
||||
|
||||
gateway.publishEvent("chat", {
|
||||
sessionKey: key,
|
||||
state: "delta",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "# Earlier work\n\nChecked the inputs. Final **verification** is running.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitForFast(() =>
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`)
|
||||
?.textContent,
|
||||
).toBe("Final verification is running."),
|
||||
);
|
||||
const link = sidebar.querySelector<HTMLAnchorElement>(
|
||||
`[data-session-key="${key}"] .sidebar-recent-session__link`,
|
||||
);
|
||||
expect(link?.title).toContain("Final verification is running.");
|
||||
expect(link?.querySelector("[aria-live]")).toBeNull();
|
||||
|
||||
sessions.publishList({
|
||||
result: sessionsResult([
|
||||
{ ...runningRow(key, 5), hasActiveRun: false, status: "done", endedAt: 20 },
|
||||
]),
|
||||
agentId: "main",
|
||||
});
|
||||
await waitForFast(() => expect(sessions.unsubscribeMessages).toHaveBeenCalledTimes(1));
|
||||
await sidebar.updateComplete;
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a pending question attention priority over a running narration", async () => {
|
||||
const key = "agent:main:needs-answer";
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", [key]);
|
||||
sessions.publishList({ result: sessionsResult([runningRow(key, 5)]), agentId: "main" });
|
||||
const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions);
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
// Republish inside the wait: the narration controller chunk loads lazily,
|
||||
// so an event raced before its import resolves is intentionally dropped.
|
||||
await waitForFast(() => {
|
||||
gateway.publishEvent("chat", {
|
||||
sessionKey: key,
|
||||
state: "delta",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Checking the remaining files." }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`)
|
||||
?.textContent,
|
||||
).toBe("Checking the remaining files.");
|
||||
});
|
||||
|
||||
gateway.publishEvent("question.requested", {
|
||||
id: "question-narration-priority",
|
||||
agentId: "main",
|
||||
sessionKey: key,
|
||||
questions: [{ id: "confirm", header: "Confirm", question: "Continue?", options: [] }],
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
status: "pending",
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const row = sidebar.querySelector(`[data-session-key="${key}"]`);
|
||||
expect(row?.querySelector("[data-session-attention=question]")).not.toBeNull();
|
||||
expect(row?.querySelector(".sidebar-recent-session__subtitle")?.textContent).toBe(
|
||||
"Waiting for your answer",
|
||||
);
|
||||
expect(row?.textContent).not.toContain("Checking the remaining files.");
|
||||
expect(
|
||||
row?.querySelector<HTMLAnchorElement>(".sidebar-recent-session__link")?.title,
|
||||
).not.toContain("Checking the remaining files.");
|
||||
});
|
||||
|
||||
it("keeps only the six newest running subscriptions and evicts the old boundary", async () => {
|
||||
const keys = Array.from({ length: 7 }, (_, index) => `agent:main:run-${index + 1}`);
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", keys);
|
||||
const rows = keys.map((key, index) => runningRow(key, index + 1));
|
||||
sessions.publishList({ result: sessionsResult(rows), agentId: "main" });
|
||||
const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions);
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(6));
|
||||
expect(sessions.subscribeMessages.mock.calls.map(([key]) => key)).toEqual(
|
||||
expect.arrayContaining(keys.slice(1)),
|
||||
);
|
||||
expect(sessions.subscribeMessages).not.toHaveBeenCalledWith(keys[0], expect.anything());
|
||||
|
||||
sessions.publishList({
|
||||
result: sessionsResult([{ ...rows[0]!, startedAt: 100 }, ...rows.slice(1)]),
|
||||
agentId: "main",
|
||||
});
|
||||
await waitForFast(() => expect(sessions.unsubscribeMessages).toHaveBeenCalledTimes(1));
|
||||
await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(7));
|
||||
expect(sessions.unsubscribeMessages.mock.calls[0]?.[0]).toMatchObject({ key: keys[1] });
|
||||
expect(sessions.subscribeMessages.mock.calls.at(-1)?.[0]).toBe(keys[0]);
|
||||
});
|
||||
|
||||
it("stays inert when the synced preference is off", async () => {
|
||||
const key = "agent:main:quiet";
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", [key]);
|
||||
sessions.publishList({ result: sessionsResult([runningRow(key, 1)]), agentId: "main" });
|
||||
const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions);
|
||||
sidebar.sidebarLiveActivity = false;
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
gateway.publishEvent("chat", {
|
||||
sessionKey: key,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Should stay hidden" }] },
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sessions.subscribeMessages).not.toHaveBeenCalled();
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("skips the open chat subscription and resubscribes background runs after reconnect", async () => {
|
||||
const openKey = "agent:main:open";
|
||||
const backgroundKey = "agent:main:background";
|
||||
const gateway = createGatewayHarness({} as GatewayBrowserClient);
|
||||
const sessions = createSessionsHarness("main", [openKey, backgroundKey]);
|
||||
sessions.publishList({
|
||||
result: sessionsResult([runningRow(openKey, 1), runningRow(backgroundKey, 2)]),
|
||||
agentId: "main",
|
||||
});
|
||||
const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions);
|
||||
sidebar.activeRouteId = "chat";
|
||||
sidebar.sessionKey = openKey;
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(1));
|
||||
expect(sessions.subscribeMessages).toHaveBeenLastCalledWith(backgroundKey, {
|
||||
agentId: undefined,
|
||||
});
|
||||
|
||||
gateway.publish({ connected: false });
|
||||
sidebar.connected = false;
|
||||
await sidebar.updateComplete;
|
||||
await waitForFast(() => expect(sessions.unsubscribeMessages).toHaveBeenCalledTimes(1));
|
||||
|
||||
gateway.publish({ connected: true });
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(2));
|
||||
expect(sessions.subscribeMessages).toHaveBeenLastCalledWith(backgroundKey, {
|
||||
agentId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,13 @@ type SessionDeleteResult = Awaited<ReturnType<SessionCapability["delete"]>>;
|
||||
type SessionState = SessionCapability["state"];
|
||||
|
||||
export type SidebarLifecycleState = HTMLElement & {
|
||||
activeRouteId?: string;
|
||||
connected: boolean;
|
||||
terminalAvailable: boolean;
|
||||
catalogOpenTarget: "viewer" | "terminal";
|
||||
canPairDevice: boolean;
|
||||
sidebarEntries: readonly string[];
|
||||
sidebarLiveActivity: boolean;
|
||||
onUpdateSidebarEntries?: (entries: string[]) => void;
|
||||
pinnedAgentIds: readonly string[];
|
||||
sessionKey: string;
|
||||
@@ -171,6 +173,12 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
);
|
||||
const refresh = vi.fn(() => Promise.resolve());
|
||||
const refreshReplacement = vi.fn(() => Promise.resolve());
|
||||
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
|
||||
Promise.resolve({ key, agentId: options?.agentId ?? null }),
|
||||
);
|
||||
const unsubscribeMessages = vi.fn(
|
||||
(_subscription: Parameters<SessionCapability["unsubscribeMessages"]>[0]) => Promise.resolve(),
|
||||
);
|
||||
const list = vi.fn((_options?: Parameters<SessionCapability["list"]>[0]) =>
|
||||
Promise.resolve<SessionsListResult | null>(null),
|
||||
);
|
||||
@@ -197,6 +205,8 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
list,
|
||||
refresh,
|
||||
refreshReplacement,
|
||||
subscribeMessages,
|
||||
unsubscribeMessages,
|
||||
} as unknown as SessionCapability;
|
||||
const publish = (statePatch: Partial<SessionState>) => {
|
||||
state = { ...state, ...statePatch };
|
||||
@@ -216,6 +226,8 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
list,
|
||||
refresh,
|
||||
refreshReplacement,
|
||||
subscribeMessages,
|
||||
unsubscribeMessages,
|
||||
publish,
|
||||
publishList(statePatch: Partial<SessionState>) {
|
||||
canonicalListRevision += 1;
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createControlUiMockGatewayInitScript } from "./control-ui-e2e.ts";
|
||||
|
||||
type ResponseFrame = { id?: string; type?: string; payload?: Record<string, unknown> };
|
||||
type ResponseFrame = {
|
||||
event?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function flushMockTimers(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -12,6 +17,12 @@ function flushMockTimers(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMockCycle(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 300);
|
||||
});
|
||||
}
|
||||
|
||||
describe("mock gateway stateful config", () => {
|
||||
it("round-trips config.set through config.get with an advancing hash", async () => {
|
||||
const raw = '{\n "logging": {\n "level": "info"\n }\n}\n';
|
||||
@@ -151,6 +162,92 @@ describe("mock gateway stateful config", () => {
|
||||
});
|
||||
|
||||
describe("mock gateway stateful sessions", () => {
|
||||
it("cycles subscription-scoped session events and stops after unsubscribe", async () => {
|
||||
const sessionKey = "agent:main:sidebar-narration-demo";
|
||||
const script = createControlUiMockGatewayInitScript({
|
||||
repeatingSessionEvents: {
|
||||
intervalMs: 250,
|
||||
events: [
|
||||
{
|
||||
event: "agent",
|
||||
payload: {
|
||||
data: {
|
||||
replace: true,
|
||||
text: "Rebasing onto main and rerunning the sidebar suite.",
|
||||
},
|
||||
sessionKey,
|
||||
stream: "assistant",
|
||||
},
|
||||
},
|
||||
{
|
||||
event: "session.tool",
|
||||
payload: { data: { name: "exec" }, sessionKey, stream: "tool" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
// oxlint-disable-next-line typescript/no-implied-eval -- Executes the generated init script standalone, proving it captures no module closures.
|
||||
new Function(script)();
|
||||
|
||||
const socket = new WebSocket("ws://mock-gateway");
|
||||
const frames: ResponseFrame[] = [];
|
||||
socket.addEventListener("message", (event) => {
|
||||
frames.push(JSON.parse(String((event as MessageEvent).data)) as ResponseFrame);
|
||||
});
|
||||
await flushMockTimers();
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "subscribe-1",
|
||||
method: "sessions.messages.subscribe",
|
||||
params: { key: sessionKey },
|
||||
}),
|
||||
);
|
||||
await flushMockTimers();
|
||||
expect(frames.find((frame) => frame.id === "subscribe-1")?.payload).toEqual({
|
||||
key: sessionKey,
|
||||
});
|
||||
expect(frames.find((frame) => frame.event === "agent")?.payload).toMatchObject({
|
||||
sessionKey,
|
||||
stream: "assistant",
|
||||
data: { text: "Rebasing onto main and rerunning the sidebar suite." },
|
||||
});
|
||||
|
||||
await waitForMockCycle();
|
||||
expect(frames.find((frame) => frame.event === "session.tool")?.payload).toMatchObject({
|
||||
sessionKey,
|
||||
stream: "tool",
|
||||
data: { name: "exec" },
|
||||
});
|
||||
|
||||
// Second assistant cycle must repeat: the replayed snapshot carries
|
||||
// replace, so the narration controller re-renders instead of deduping.
|
||||
await waitForMockCycle();
|
||||
const assistantFrames = frames.filter((frame) => frame.event === "agent");
|
||||
expect(assistantFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(assistantFrames.at(-1)?.payload).toMatchObject({
|
||||
sessionKey,
|
||||
stream: "assistant",
|
||||
data: { replace: true, text: "Rebasing onto main and rerunning the sidebar suite." },
|
||||
});
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "unsubscribe-1",
|
||||
method: "sessions.messages.unsubscribe",
|
||||
params: { key: sessionKey },
|
||||
}),
|
||||
);
|
||||
await flushMockTimers();
|
||||
const eventCount = frames.filter((frame) => frame.type === "event").length;
|
||||
await waitForMockCycle();
|
||||
expect(frames.filter((frame) => frame.type === "event")).toHaveLength(eventCount);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("keeps archive filtering opt-in for static session fixtures", async () => {
|
||||
const script = createControlUiMockGatewayInitScript({
|
||||
methodResponses: {
|
||||
|
||||
@@ -65,6 +65,11 @@ export type ControlUiMockGatewayScenario = {
|
||||
methodResponses?: Record<string, unknown>;
|
||||
/** Replayed in-flight run snapshot served by chat.history and chat.startup. */
|
||||
inFlightRun?: { runId: string; text?: string; plan?: unknown } | null;
|
||||
/** Subscription-scoped Gateway events replayed on a fixed browser-side cycle. */
|
||||
repeatingSessionEvents?: {
|
||||
events: Array<{ event: "agent" | "session.tool"; payload: unknown }>;
|
||||
intervalMs?: number;
|
||||
};
|
||||
/** Session run state served alongside history (hasActiveRun/activeRunIds). */
|
||||
sessionInfo?: Record<string, unknown> | null;
|
||||
/** Partition sessions.list fixtures by archived state after applying patches. */
|
||||
@@ -261,6 +266,7 @@ function normalizeScenario(
|
||||
methodResponses: scenario.methodResponses ?? {},
|
||||
inFlightRun: scenario.inFlightRun ?? null,
|
||||
models: scenario.models ?? [{ id: "gpt-5.5", name: "gpt-5.5", provider: "openai" }],
|
||||
repeatingSessionEvents: scenario.repeatingSessionEvents ?? { events: [] },
|
||||
sessionInfo: scenario.sessionInfo ?? null,
|
||||
sessionArchiveFiltering: scenario.sessionArchiveFiltering ?? false,
|
||||
sessionKey,
|
||||
@@ -367,7 +373,10 @@ function installControlUiMockGateway(input: {
|
||||
const requests: BrowserRequest[] = [];
|
||||
const methodResponseSequenceIndexes = new Map<string, number>();
|
||||
const sessionPatches = new Map<string, Record<string, unknown>>();
|
||||
const sessionMessageSubscriptions = new Set<string>();
|
||||
const sockets: Array<{ readonly url: string }> = [];
|
||||
let sessionMessageEventIndex = 0;
|
||||
let sessionMessageEventTimer: number | null = null;
|
||||
const offlineStateKey = "openclaw.control-ui-e2e.gatewayOffline";
|
||||
// Gateway-owned custom group catalog (sessions.groups.*). Persisted in
|
||||
// sessionStorage so a page reload keeps the catalog the way the real
|
||||
@@ -626,6 +635,61 @@ function installControlUiMockGateway(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function stopRepeatingSessionEvents(): void {
|
||||
if (sessionMessageEventTimer !== null) {
|
||||
window.clearInterval(sessionMessageEventTimer);
|
||||
sessionMessageEventTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function emitRepeatingSessionEvent(): void {
|
||||
const events = scenario.repeatingSessionEvents.events;
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
const event = events[sessionMessageEventIndex % events.length];
|
||||
sessionMessageEventIndex += 1;
|
||||
if (!event || !isRecord(event.payload) || typeof event.payload.sessionKey !== "string") {
|
||||
return;
|
||||
}
|
||||
if (!sessionMessageSubscriptions.has(event.payload.sessionKey)) {
|
||||
return;
|
||||
}
|
||||
MockWebSocket.latest?.deliver({
|
||||
event: event.event,
|
||||
payload: event.payload,
|
||||
seq: ++seq,
|
||||
type: "event",
|
||||
});
|
||||
}
|
||||
|
||||
function startRepeatingSessionEvents(): void {
|
||||
if (sessionMessageEventTimer !== null || scenario.repeatingSessionEvents.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
emitRepeatingSessionEvent();
|
||||
const intervalMs = Math.max(250, scenario.repeatingSessionEvents.intervalMs ?? 3_000);
|
||||
sessionMessageEventTimer = window.setInterval(emitRepeatingSessionEvent, intervalMs);
|
||||
}
|
||||
|
||||
function updateSessionMessageSubscription(method: string, params: unknown): void {
|
||||
const sessionKey = isRecord(params) && typeof params.key === "string" ? params.key : "";
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (method === "sessions.messages.subscribe") {
|
||||
sessionMessageSubscriptions.add(sessionKey);
|
||||
startRepeatingSessionEvents();
|
||||
return;
|
||||
}
|
||||
if (method === "sessions.messages.unsubscribe") {
|
||||
sessionMessageSubscriptions.delete(sessionKey);
|
||||
if (sessionMessageSubscriptions.size === 0) {
|
||||
stopRepeatingSessionEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow() {
|
||||
return {
|
||||
contextTokens: null,
|
||||
@@ -906,6 +970,12 @@ function installControlUiMockGateway(input: {
|
||||
}
|
||||
case "sessions.subscribe":
|
||||
return { ok: true };
|
||||
case "sessions.messages.subscribe":
|
||||
return {
|
||||
key: isRecord(params) && typeof params.key === "string" ? params.key : "",
|
||||
};
|
||||
case "sessions.messages.unsubscribe":
|
||||
return { ok: true };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -992,6 +1062,8 @@ function installControlUiMockGateway(input: {
|
||||
return;
|
||||
}
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
sessionMessageSubscriptions.clear();
|
||||
stopRepeatingSessionEvents();
|
||||
this.dispatchEvent(new CloseEvent("close", { code, reason }));
|
||||
}
|
||||
|
||||
@@ -1019,6 +1091,9 @@ function installControlUiMockGateway(input: {
|
||||
? { id, ok: false, error: mockError, type: "res" }
|
||||
: { id, ok: true, payload, type: "res" },
|
||||
);
|
||||
if (!mockError) {
|
||||
updateSessionMessageSubscription(method, frame.params);
|
||||
}
|
||||
if (
|
||||
method === "chat.abort" &&
|
||||
isRecord(frame.params) &&
|
||||
@@ -1153,6 +1228,10 @@ function installControlUiMockGateway(input: {
|
||||
|
||||
(window as unknown as WindowWithGateway).openclawControlUiE2eGateway = exposed;
|
||||
window.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
window.addEventListener("pagehide", () => {
|
||||
sessionMessageSubscriptions.clear();
|
||||
stopRepeatingSessionEvents();
|
||||
});
|
||||
}
|
||||
|
||||
export async function installMockGateway(
|
||||
|
||||
Reference in New Issue
Block a user