mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-26 19:21:11 +00:00
Stabilize WebChat transcript/run-state truth for Codex and selected-session observers. Summary: - Mirror Codex inbound prompts at turn start without duplicating suppressed persisted prompts. - Deliver hidden external-channel live chat/tool/agent updates only to exact selected-session subscribers. - Repair Control UI selected-session subscription state, alias-aware run adoption, and accumulated stream dedupe. - Add focused Codex, gateway/session-event, and Control UI regression coverage. Verification: - Current-head CI: 101 green, 0 pending; stale canceled entries are superseded automation from prior force-pushed heads. - Local focused Vitest shards passed: Codex app-server 2 files / 233 tests, gateway/session 4 files / 116 tests, UI 7 files / 238 tests. - `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo` - `node --import tsx scripts/check-no-extension-test-core-imports.ts` - `git diff --check origin/main..HEAD` Closes #83528. Closes #82611. Refs #83949.
1122 lines
36 KiB
TypeScript
1122 lines
36 KiB
TypeScript
// @vitest-environment node
|
||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
const {
|
||
refreshChatMock,
|
||
refreshChatAvatarMock,
|
||
refreshSlashCommandsMock,
|
||
loadChatHistoryMock,
|
||
createSessionAndRefreshMock,
|
||
loadSessionsMock,
|
||
syncSelectedSessionMessageSubscriptionMock,
|
||
} = vi.hoisted(() => ({
|
||
refreshChatMock: vi.fn(),
|
||
refreshChatAvatarMock: vi.fn(),
|
||
refreshSlashCommandsMock: vi.fn(),
|
||
loadChatHistoryMock: vi.fn(),
|
||
createSessionAndRefreshMock: vi.fn(),
|
||
loadSessionsMock: vi.fn(),
|
||
syncSelectedSessionMessageSubscriptionMock: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("./app-chat.ts", () => ({
|
||
CHAT_SESSIONS_ACTIVE_MINUTES: 120,
|
||
CHAT_SESSIONS_REFRESH_LIMIT: 50,
|
||
createChatSessionsLoadOverrides: () => ({
|
||
activeMinutes: 120,
|
||
limit: 50,
|
||
includeGlobal: true,
|
||
includeUnknown: true,
|
||
showArchived: false,
|
||
}),
|
||
refreshChat: refreshChatMock,
|
||
refreshChatAvatar: refreshChatAvatarMock,
|
||
}));
|
||
|
||
vi.mock("./chat/slash-commands.ts", () => ({
|
||
refreshSlashCommands: (...args: unknown[]) => refreshSlashCommandsMock(...args),
|
||
}));
|
||
|
||
vi.mock("./controllers/chat.ts", () => ({
|
||
loadChatHistory: loadChatHistoryMock,
|
||
}));
|
||
|
||
vi.mock("./controllers/sessions.ts", () => ({
|
||
createSessionAndRefresh: createSessionAndRefreshMock,
|
||
loadSessions: loadSessionsMock,
|
||
syncSelectedSessionMessageSubscription: syncSelectedSessionMessageSubscriptionMock,
|
||
}));
|
||
|
||
import {
|
||
createChatSession,
|
||
dismissChatError,
|
||
handleChatManualRefresh,
|
||
isCronSessionKey,
|
||
parseSessionKey,
|
||
resolveAssistantAttachmentAuthToken,
|
||
resolveDashboardHeaderContext,
|
||
resolveSessionOptionGroups,
|
||
resolveSessionDisplayName,
|
||
switchChatSession,
|
||
} from "./app-render.helpers.ts";
|
||
import type { AppViewState } from "./app-view-state.ts";
|
||
import type { SessionsListResult } from "./types.ts";
|
||
|
||
type SessionRow = SessionsListResult["sessions"][number];
|
||
|
||
beforeEach(() => {
|
||
refreshChatMock.mockReset();
|
||
refreshChatAvatarMock.mockReset();
|
||
refreshSlashCommandsMock.mockReset();
|
||
loadChatHistoryMock.mockReset();
|
||
createSessionAndRefreshMock.mockReset();
|
||
loadSessionsMock.mockReset();
|
||
syncSelectedSessionMessageSubscriptionMock.mockReset();
|
||
});
|
||
|
||
function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
|
||
return { kind: "direct", updatedAt: 0, ...overrides };
|
||
}
|
||
|
||
function labelsForSessionOptions(params: {
|
||
sessionKey: string;
|
||
sessions?: SessionRow[];
|
||
agentsList?: AppViewState["agentsList"];
|
||
}) {
|
||
const groups = resolveSessionOptionGroups(
|
||
{
|
||
sessionsHideCron: true,
|
||
agentsList: params.agentsList ?? null,
|
||
} as AppViewState,
|
||
params.sessionKey,
|
||
{
|
||
ts: 0,
|
||
path: "",
|
||
count: params.sessions?.length ?? 0,
|
||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||
sessions: params.sessions ?? [],
|
||
},
|
||
);
|
||
return groups.flatMap((group) => group.options.map((option) => option.label));
|
||
}
|
||
|
||
function createSettings(): AppViewState["settings"] {
|
||
return {
|
||
gatewayUrl: "",
|
||
token: "",
|
||
locale: "en",
|
||
sessionKey: "main",
|
||
lastActiveSessionKey: "main",
|
||
theme: "claw",
|
||
themeMode: "dark",
|
||
splitRatio: 0.6,
|
||
navWidth: 280,
|
||
navCollapsed: false,
|
||
navGroupsCollapsed: {},
|
||
borderRadius: 50,
|
||
chatFocusMode: false,
|
||
chatShowThinking: false,
|
||
chatShowToolCalls: true,
|
||
};
|
||
}
|
||
|
||
function createChatSessionState(overrides: Partial<AppViewState> = {}) {
|
||
const settings = createSettings();
|
||
const state = {
|
||
sessionKey: "agent:ops:main",
|
||
chatMessage: "draft prompt",
|
||
chatAttachments: [{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" }],
|
||
chatMessages: [{ role: "assistant", content: "old" }],
|
||
chatToolMessages: [{ id: "tool-1" }],
|
||
chatStreamSegments: [],
|
||
chatThinkingLevel: null,
|
||
chatStream: null,
|
||
chatSideResult: null,
|
||
lastError: null,
|
||
compactionStatus: null,
|
||
fallbackStatus: null,
|
||
chatAvatarUrl: null,
|
||
chatAvatarSource: null,
|
||
chatAvatarStatus: null,
|
||
chatAvatarReason: null,
|
||
chatQueue: [],
|
||
chatRunId: null,
|
||
chatSending: false,
|
||
chatLoading: false,
|
||
chatSideResultTerminalRuns: new Set<string>(),
|
||
chatStreamStartedAt: null,
|
||
connected: true,
|
||
client: { request: vi.fn() },
|
||
sessionsLoading: false,
|
||
sessionsError: null,
|
||
sessionsShowArchived: false,
|
||
sessionsResult: {
|
||
ts: 0,
|
||
path: "",
|
||
count: 1,
|
||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||
sessions: [row({ key: "agent:ops:main" })],
|
||
},
|
||
settings,
|
||
applySettings(next: typeof settings) {
|
||
state.settings = next;
|
||
},
|
||
loadAssistantIdentity: vi.fn(),
|
||
resetToolStream: vi.fn(),
|
||
resetChatScroll: vi.fn(),
|
||
resetChatInputHistoryNavigation: vi.fn(),
|
||
...overrides,
|
||
} as unknown as AppViewState;
|
||
return state;
|
||
}
|
||
|
||
/* ================================================================
|
||
* parseSessionKey – low-level key → type / fallback mapping
|
||
* ================================================================ */
|
||
|
||
describe("parseSessionKey", () => {
|
||
it("identifies main session (bare 'main')", () => {
|
||
expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" });
|
||
});
|
||
|
||
it("identifies main session (agent:main:main)", () => {
|
||
expect(parseSessionKey("agent:main:main")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Main Session",
|
||
});
|
||
});
|
||
|
||
it("identifies subagent sessions", () => {
|
||
expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({
|
||
prefix: "Subagent:",
|
||
fallbackName: "Subagent:",
|
||
});
|
||
});
|
||
|
||
it("identifies cron sessions", () => {
|
||
expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({
|
||
prefix: "Cron:",
|
||
fallbackName: "Cron Job:",
|
||
});
|
||
expect(parseSessionKey("cron:daily-briefing-uuid")).toEqual({
|
||
prefix: "Cron:",
|
||
fallbackName: "Cron Job:",
|
||
});
|
||
});
|
||
|
||
it("identifies direct chat with known channel", () => {
|
||
expect(parseSessionKey("agent:main:imessage:direct:+19257864429")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "iMessage · +19257864429",
|
||
});
|
||
});
|
||
|
||
it("identifies direct chat with telegram", () => {
|
||
expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Telegram · user123",
|
||
});
|
||
});
|
||
|
||
it("identifies group chat with known channel", () => {
|
||
expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Discord Group",
|
||
});
|
||
});
|
||
|
||
it("capitalises unknown channels in direct/group patterns", () => {
|
||
expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Mychannel · user1",
|
||
});
|
||
});
|
||
|
||
it("identifies channel-prefixed legacy keys", () => {
|
||
expect(parseSessionKey("imessage:g-agent-main-imessage-direct-+19257864429")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "iMessage Session",
|
||
});
|
||
expect(parseSessionKey("discord:123:456")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Discord Session",
|
||
});
|
||
});
|
||
|
||
it("handles bare channel name as key", () => {
|
||
expect(parseSessionKey("telegram")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Telegram Session",
|
||
});
|
||
});
|
||
|
||
it("returns raw key for unknown parse patterns", () => {
|
||
expect(parseSessionKey("something-unknown")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "something-unknown",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("resolveAssistantAttachmentAuthToken", () => {
|
||
it("prefers the paired device token when present", () => {
|
||
expect(
|
||
resolveAssistantAttachmentAuthToken({
|
||
hello: { auth: { deviceToken: "device-token" } } as AppViewState["hello"],
|
||
settings: { token: "session-token" } as AppViewState["settings"],
|
||
password: "shared-password",
|
||
}),
|
||
).toBe("device-token");
|
||
});
|
||
|
||
it("prefers the explicit gateway token when present", () => {
|
||
expect(
|
||
resolveAssistantAttachmentAuthToken({
|
||
hello: null,
|
||
settings: { token: "session-token" } as AppViewState["settings"],
|
||
password: "shared-password",
|
||
}),
|
||
).toBe("session-token");
|
||
});
|
||
|
||
it("falls back to the shared password when token is blank", () => {
|
||
expect(
|
||
resolveAssistantAttachmentAuthToken({
|
||
hello: null,
|
||
settings: { token: " " } as AppViewState["settings"],
|
||
password: "shared-password",
|
||
}),
|
||
).toBe("shared-password");
|
||
});
|
||
|
||
it("returns null when neither auth secret is available", () => {
|
||
expect(
|
||
resolveAssistantAttachmentAuthToken({
|
||
hello: null,
|
||
settings: { token: "" } as AppViewState["settings"],
|
||
password: " ",
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
/* ================================================================
|
||
* resolveSessionDisplayName – full resolution with row data
|
||
* ================================================================ */
|
||
|
||
describe("resolveSessionDisplayName", () => {
|
||
// ── Key-only fallbacks (no row) ──────────────────
|
||
|
||
it("returns 'Main Session' for agent:main:main key", () => {
|
||
expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session");
|
||
});
|
||
|
||
it("returns 'Main Session' for bare 'main' key", () => {
|
||
expect(resolveSessionDisplayName("main")).toBe("Main Session");
|
||
});
|
||
|
||
it("returns 'Subagent:' for subagent key without row", () => {
|
||
expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:");
|
||
});
|
||
|
||
it("returns 'Cron Job:' for cron key without row", () => {
|
||
expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:");
|
||
});
|
||
|
||
it("parses direct chat key with channel", () => {
|
||
expect(resolveSessionDisplayName("agent:main:imessage:direct:+19257864429")).toBe(
|
||
"iMessage · +19257864429",
|
||
);
|
||
});
|
||
|
||
it("parses channel-prefixed legacy key", () => {
|
||
expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session");
|
||
});
|
||
|
||
it("returns raw key for unknown display-name patterns", () => {
|
||
expect(resolveSessionDisplayName("something-custom")).toBe("something-custom");
|
||
});
|
||
|
||
// ── With row data (label / displayName) ──────────
|
||
|
||
it("returns parsed fallback when row has no label or displayName", () => {
|
||
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
|
||
"Main Session",
|
||
);
|
||
});
|
||
|
||
it("returns parsed fallback when displayName matches key", () => {
|
||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe(
|
||
"mykey",
|
||
);
|
||
});
|
||
|
||
it("returns parsed fallback when label matches key", () => {
|
||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey");
|
||
});
|
||
|
||
it("uses label alone when available", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", label: "General" }),
|
||
),
|
||
).toBe("General");
|
||
});
|
||
|
||
it("falls back to displayName when label is absent", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", displayName: "My Chat" }),
|
||
),
|
||
).toBe("My Chat");
|
||
});
|
||
|
||
it("prefers label over displayName when both are present", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
|
||
),
|
||
).toBe("General");
|
||
});
|
||
|
||
it("ignores whitespace-only label and falls back to displayName", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
|
||
),
|
||
).toBe("My Chat");
|
||
});
|
||
|
||
it("uses parsed fallback when whitespace-only label and no displayName", () => {
|
||
expect(
|
||
resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })),
|
||
).toBe("Discord Session");
|
||
});
|
||
|
||
it("trims label and displayName", () => {
|
||
expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General");
|
||
expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe(
|
||
"My Chat",
|
||
);
|
||
});
|
||
|
||
// ── Type prefixes applied to labels / displayNames ──
|
||
|
||
it("prefixes subagent label with Subagent:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:subagent:abc-123",
|
||
row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
|
||
),
|
||
).toBe("Subagent: maintainer-v2");
|
||
});
|
||
|
||
it("prefixes subagent displayName with Subagent:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:subagent:abc-123",
|
||
row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
|
||
),
|
||
).toBe("Subagent: Task Runner");
|
||
});
|
||
|
||
it("prefixes cron label with Cron:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:cron:abc-123",
|
||
row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
|
||
),
|
||
).toBe("Cron: daily-briefing");
|
||
});
|
||
|
||
it("prefixes cron displayName with Cron:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:cron:abc-123",
|
||
row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
|
||
),
|
||
).toBe("Cron: Nightly Sync");
|
||
});
|
||
|
||
it("does not double-prefix cron labels that already include Cron:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:cron:abc-123",
|
||
row({ key: "agent:main:cron:abc-123", label: "Cron: Nightly Sync" }),
|
||
),
|
||
).toBe("Cron: Nightly Sync");
|
||
});
|
||
|
||
it("does not double-prefix subagent display names that already include Subagent:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:subagent:abc-123",
|
||
row({ key: "agent:main:subagent:abc-123", displayName: "Subagent: Runner" }),
|
||
),
|
||
).toBe("Subagent: Runner");
|
||
});
|
||
|
||
it("does not prefix non-typed sessions with labels", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:imessage:direct:+19257864429",
|
||
row({ key: "agent:main:imessage:direct:+19257864429", label: "Tyler" }),
|
||
),
|
||
).toBe("Tyler");
|
||
});
|
||
});
|
||
|
||
describe("resolveDashboardHeaderContext", () => {
|
||
it("uses the active agent identity name", () => {
|
||
expect(
|
||
resolveDashboardHeaderContext({
|
||
sessionKey: "agent:deep-chat:imessage:sample-thread",
|
||
agentsList: {
|
||
defaultId: "deep-chat",
|
||
mainKey: "main",
|
||
scope: "user",
|
||
agents: [{ id: "deep-chat", identity: { name: "Deep Chat" } }],
|
||
},
|
||
} as unknown as AppViewState),
|
||
).toEqual({ agentLabel: "Deep Chat" });
|
||
});
|
||
|
||
it("falls back to the configured agent name", () => {
|
||
expect(
|
||
resolveDashboardHeaderContext({
|
||
sessionKey: "agent:beta:main",
|
||
agentsList: {
|
||
defaultId: "beta",
|
||
mainKey: "main",
|
||
scope: "user",
|
||
agents: [{ id: "beta", name: "Coding" }],
|
||
},
|
||
} as unknown as AppViewState),
|
||
).toEqual({ agentLabel: "Coding" });
|
||
});
|
||
|
||
it("falls back to the agent id", () => {
|
||
expect(
|
||
resolveDashboardHeaderContext({
|
||
sessionKey: "agent:beta:subagent:maintainer-v2",
|
||
agentsList: {
|
||
defaultId: "main",
|
||
mainKey: "main",
|
||
scope: "user",
|
||
agents: [],
|
||
},
|
||
} as unknown as AppViewState),
|
||
).toEqual({ agentLabel: "beta" });
|
||
});
|
||
});
|
||
|
||
describe("isCronSessionKey", () => {
|
||
it("returns true for cron: prefixed keys", () => {
|
||
expect(isCronSessionKey("cron:abc-123")).toBe(true);
|
||
expect(isCronSessionKey("cron:weekly-agent-roundtable")).toBe(true);
|
||
expect(isCronSessionKey("agent:main:cron:abc-123")).toBe(true);
|
||
expect(isCronSessionKey("agent:main:cron:abc-123:run:run-1")).toBe(true);
|
||
});
|
||
|
||
it("returns false for non-cron keys", () => {
|
||
expect(isCronSessionKey("main")).toBe(false);
|
||
expect(isCronSessionKey("discord:group:eng")).toBe(false);
|
||
expect(isCronSessionKey("agent:main:slack:cron:job:run:uuid")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("resolveSessionOptionGroups", () => {
|
||
it("prefers grouped session labels over display names", () => {
|
||
const sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b";
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey,
|
||
sessions: [
|
||
row({
|
||
key: sessionKey,
|
||
label: "cron-config-check",
|
||
displayName: "webchat:g-agent-main-subagent-4f2146de-887b-4176-9abe-91140082959b",
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["Subagent: cron-config-check"]);
|
||
});
|
||
|
||
it("keeps the active subagent session visible when no row exists yet", () => {
|
||
const sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b";
|
||
|
||
expect(labelsForSessionOptions({ sessionKey })).toEqual([
|
||
"subagent:4f2146de-887b-4176-9abe-91140082959b",
|
||
]);
|
||
expect(
|
||
labelsForSessionOptions({
|
||
sessionKey,
|
||
sessions: [row({ key: sessionKey })],
|
||
}),
|
||
).toEqual(["subagent:4f2146de-887b-4176-9abe-91140082959b"]);
|
||
});
|
||
|
||
it("keeps the active agent main session visible when no row exists yet", () => {
|
||
expect(labelsForSessionOptions({ sessionKey: "agent:main:main" })).toEqual(["main"]);
|
||
});
|
||
|
||
it("hides inactive subagent sessions from the picker", () => {
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b",
|
||
sessions: [
|
||
row({
|
||
key: "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b",
|
||
label: "cron-config-check",
|
||
}),
|
||
row({
|
||
key: "agent:main:subagent:6fb8b84b-c31f-410f-b7df-1553c82e43c9",
|
||
label: "cron-config-check",
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["Subagent: cron-config-check"]);
|
||
});
|
||
|
||
it("filters the chat session options to the active agent", () => {
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: "agent:alpha:main",
|
||
agentsList: {
|
||
defaultId: "alpha",
|
||
mainKey: "agent:alpha:main",
|
||
scope: "all",
|
||
agents: [
|
||
{ id: "alpha", name: "Deep Chat" },
|
||
{ id: "beta", name: "Coding" },
|
||
],
|
||
},
|
||
sessions: [
|
||
row({ key: "agent:alpha:main" }),
|
||
row({ key: "agent:beta:main" }),
|
||
row({
|
||
key: "agent:alpha:named-main",
|
||
label: "Deep Chat (alpha) / main",
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["main", "Deep Chat (alpha) / main"]);
|
||
});
|
||
|
||
it("shows sessions for the selected agent after switching agent scope", () => {
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: "agent:beta:main",
|
||
agentsList: {
|
||
defaultId: "alpha",
|
||
mainKey: "agent:alpha:main",
|
||
scope: "all",
|
||
agents: [
|
||
{ id: "alpha", name: "Deep Chat" },
|
||
{ id: "beta", name: "Coding" },
|
||
],
|
||
},
|
||
sessions: [
|
||
row({ key: "agent:alpha:main" }),
|
||
row({ key: "agent:beta:main" }),
|
||
row({ key: "agent:beta:dashboard:recent", label: "Bug triage" }),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["main", "Bug triage"]);
|
||
});
|
||
|
||
it("keeps bare legacy sessions scoped to the default agent only", () => {
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: "agent:beta:main",
|
||
agentsList: {
|
||
defaultId: "alpha",
|
||
mainKey: "agent:alpha:main",
|
||
scope: "all",
|
||
agents: [
|
||
{ id: "alpha", name: "Deep Chat" },
|
||
{ id: "beta", name: "Coding" },
|
||
],
|
||
},
|
||
sessions: [
|
||
row({ key: "main", label: "Legacy main" }),
|
||
row({ key: "agent:alpha:main", label: "Alpha main" }),
|
||
row({ key: "agent:beta:main", label: "Beta main" }),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["Beta main"]);
|
||
});
|
||
|
||
it("hides spawned subagent sessions under their parent", () => {
|
||
const parentKey = "agent:main:main";
|
||
const subagentKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b";
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: parentKey,
|
||
sessions: [
|
||
row({ key: parentKey, label: "Spock" }),
|
||
row({ key: subagentKey, label: "PLC Coder", spawnedBy: parentKey }),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["Spock"]);
|
||
});
|
||
|
||
it("keeps the active subagent session visible without nesting it", () => {
|
||
const parentKey = "agent:main:main";
|
||
const subagentKey = "agent:main:subagent:f4ac7ef1-1234-5678-9abc-def012345678";
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: subagentKey,
|
||
sessions: [
|
||
row({ key: parentKey, label: "Spock" }),
|
||
row({ key: subagentKey, label: "PLC Coder", spawnedBy: parentKey }),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["Spock", "Subagent: PLC Coder"]);
|
||
});
|
||
|
||
it("hides spawned subagent siblings regardless of row order", () => {
|
||
const parentKey = "agent:main:main";
|
||
const newerSubagentKey = "agent:main:subagent:newer";
|
||
const olderSubagentKey = "agent:main:subagent:older";
|
||
const labels = labelsForSessionOptions({
|
||
sessionKey: parentKey,
|
||
sessions: [
|
||
row({ key: newerSubagentKey, label: "Newer", spawnedBy: parentKey }),
|
||
row({ key: olderSubagentKey, label: "Older", spawnedBy: parentKey }),
|
||
row({ key: parentKey, label: "Spock" }),
|
||
],
|
||
});
|
||
|
||
expect(labels).toEqual(["Spock"]);
|
||
});
|
||
});
|
||
|
||
describe("handleChatManualRefresh", () => {
|
||
it("waits for chat history before scrolling and clearing refresh state", async () => {
|
||
const animationFrame = { callback: undefined as FrameRequestCallback | undefined };
|
||
const previousRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||
Object.defineProperty(globalThis, "requestAnimationFrame", {
|
||
configurable: true,
|
||
writable: true,
|
||
value: vi.fn((callback: FrameRequestCallback) => {
|
||
animationFrame.callback = callback;
|
||
return 1;
|
||
}),
|
||
});
|
||
try {
|
||
let resolveRefresh: (() => void) | undefined;
|
||
refreshChatMock.mockReturnValueOnce(
|
||
new Promise<void>((resolve) => {
|
||
resolveRefresh = resolve;
|
||
}),
|
||
);
|
||
const state = {
|
||
chatManualRefreshInFlight: false,
|
||
chatNewMessagesBelow: true,
|
||
updateComplete: Promise.resolve(),
|
||
resetToolStream: vi.fn(),
|
||
scrollToBottom: vi.fn(),
|
||
} as unknown as Parameters<typeof handleChatManualRefresh>[0];
|
||
|
||
const run = handleChatManualRefresh(state);
|
||
await Promise.resolve();
|
||
|
||
expect(state.scrollToBottom).not.toHaveBeenCalled();
|
||
if (!resolveRefresh) {
|
||
throw new Error("Expected chat refresh resolver to be initialized");
|
||
}
|
||
resolveRefresh();
|
||
await run;
|
||
|
||
expect(refreshChatMock).toHaveBeenCalledWith(state, {
|
||
awaitHistory: true,
|
||
scheduleScroll: false,
|
||
});
|
||
expect(state.scrollToBottom).toHaveBeenCalledWith({ smooth: true });
|
||
expect(state.chatManualRefreshInFlight).toBe(true);
|
||
expect(animationFrame.callback).toBeTypeOf("function");
|
||
|
||
const callback = animationFrame.callback;
|
||
if (!callback) {
|
||
throw new Error("expected manual refresh to schedule a frame callback");
|
||
}
|
||
callback(0);
|
||
|
||
expect(state.chatManualRefreshInFlight).toBe(false);
|
||
expect(state.chatNewMessagesBelow).toBe(false);
|
||
} finally {
|
||
if (previousRequestAnimationFrame === undefined) {
|
||
Reflect.deleteProperty(globalThis, "requestAnimationFrame");
|
||
} else {
|
||
Object.defineProperty(globalThis, "requestAnimationFrame", {
|
||
configurable: true,
|
||
writable: true,
|
||
value: previousRequestAnimationFrame,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("createChatSession", () => {
|
||
it("creates a dashboard session, switches to it, and preserves the current composer", async () => {
|
||
const state = createChatSessionState();
|
||
createSessionAndRefreshMock.mockResolvedValue("agent:ops:dashboard:new-chat");
|
||
refreshChatAvatarMock.mockResolvedValue(undefined);
|
||
refreshSlashCommandsMock.mockResolvedValue(undefined);
|
||
loadChatHistoryMock.mockResolvedValue(undefined);
|
||
loadSessionsMock.mockResolvedValue(undefined);
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(createSessionAndRefreshMock).toHaveBeenCalledWith(
|
||
state,
|
||
{
|
||
agentId: "ops",
|
||
parentSessionKey: "agent:ops:main",
|
||
emitCommandHooks: true,
|
||
},
|
||
{
|
||
activeMinutes: 120,
|
||
limit: 50,
|
||
includeGlobal: true,
|
||
includeUnknown: true,
|
||
showArchived: false,
|
||
},
|
||
);
|
||
expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat");
|
||
expect(state.settings.sessionKey).toBe("agent:ops:dashboard:new-chat");
|
||
expect(state.chatMessage).toBe("draft prompt");
|
||
expect(state.chatAttachments).toEqual([
|
||
{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||
]);
|
||
expect(state.chatMessages).toStrictEqual([]);
|
||
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
|
||
});
|
||
|
||
it("preserves draft and attachment edits made while session creation is in flight", async () => {
|
||
const state = createChatSessionState();
|
||
const updatedAttachments = [
|
||
{ id: "att-2", mimeType: "image/png", dataUrl: "data:image/png;base64,BBB" },
|
||
];
|
||
createSessionAndRefreshMock.mockImplementation(async () => {
|
||
state.chatMessage = "updated draft";
|
||
state.chatAttachments = updatedAttachments;
|
||
return "agent:ops:dashboard:new-chat";
|
||
});
|
||
refreshChatAvatarMock.mockResolvedValue(undefined);
|
||
refreshSlashCommandsMock.mockResolvedValue(undefined);
|
||
loadChatHistoryMock.mockResolvedValue(undefined);
|
||
loadSessionsMock.mockResolvedValue(undefined);
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat");
|
||
expect(state.chatMessage).toBe("updated draft");
|
||
expect(state.chatAttachments).toBe(updatedAttachments);
|
||
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
|
||
});
|
||
|
||
it("ignores a stale create response after the active session changes", async () => {
|
||
const state = createChatSessionState();
|
||
createSessionAndRefreshMock.mockImplementation(async () => {
|
||
state.sessionKey = "agent:ops:other";
|
||
return "agent:ops:dashboard:new-chat";
|
||
});
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(state.sessionKey).toBe("agent:ops:other");
|
||
expect(state.chatMessage).toBe("draft prompt");
|
||
expect(state.chatMessages).toEqual([{ role: "assistant", content: "old" }]);
|
||
expect(loadChatHistoryMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not create or switch while a run is active", async () => {
|
||
const state = createChatSessionState({
|
||
chatRunId: "run-1",
|
||
chatQueue: [{ id: "queued-1", text: "follow up", createdAt: 1 }],
|
||
});
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(createSessionAndRefreshMock).not.toHaveBeenCalled();
|
||
expect(state.sessionKey).toBe("agent:ops:main");
|
||
expect(state.chatMessage).toBe("draft prompt");
|
||
expect(state.chatQueue).toEqual([{ id: "queued-1", text: "follow up", createdAt: 1 }]);
|
||
expect(state.lastError).toBe(
|
||
"Start a new session after the active run or queued messages finish.",
|
||
);
|
||
});
|
||
|
||
it("shows feedback instead of clearing errors when session loading blocks creation", async () => {
|
||
const state = createChatSessionState({
|
||
sessionsLoading: true,
|
||
lastError: "previous error",
|
||
});
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(createSessionAndRefreshMock).not.toHaveBeenCalled();
|
||
expect(state.sessionKey).toBe("agent:ops:main");
|
||
expect(state.chatMessage).toBe("draft prompt");
|
||
expect(state.lastError).toBe(
|
||
"Session list is still refreshing. Try New Chat again in a moment.",
|
||
);
|
||
});
|
||
|
||
it("shows creation failure feedback when creation is skipped without a session error", async () => {
|
||
const state = createChatSessionState({ lastError: "previous error" });
|
||
createSessionAndRefreshMock.mockResolvedValue(null);
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(createSessionAndRefreshMock).toHaveBeenCalledTimes(1);
|
||
expect(state.sessionKey).toBe("agent:ops:main");
|
||
expect(state.chatMessage).toBe("draft prompt");
|
||
expect(state.sessionsError).toBeNull();
|
||
expect(state.lastError).toBe("New Chat could not create a new session. Try again in a moment.");
|
||
expect(loadChatHistoryMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps refresh feedback when a queued session refresh skips creation", async () => {
|
||
const state = createChatSessionState({ lastError: "previous error" });
|
||
createSessionAndRefreshMock.mockImplementation(async () => {
|
||
state.sessionsLoading = true;
|
||
return null;
|
||
});
|
||
|
||
await createChatSession(state);
|
||
|
||
expect(createSessionAndRefreshMock).toHaveBeenCalledTimes(1);
|
||
expect(state.sessionKey).toBe("agent:ops:main");
|
||
expect(state.chatMessage).toBe("draft prompt");
|
||
expect(state.sessionsError).toBeNull();
|
||
expect(state.lastError).toBe(
|
||
"Session list is still refreshing. Try New Chat again in a moment.",
|
||
);
|
||
expect(loadChatHistoryMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("switchChatSession", () => {
|
||
it("refreshes the chat avatar after clearing session-scoped state", async () => {
|
||
const settings = createSettings();
|
||
const state = {
|
||
sessionKey: "main",
|
||
chatMessage: "draft",
|
||
chatAttachments: [
|
||
{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||
],
|
||
chatMessages: [{ role: "assistant", content: "old" }],
|
||
chatToolMessages: [{ id: "tool-1" }],
|
||
chatStreamSegments: [{ text: "segment", ts: 1 }],
|
||
chatThinkingLevel: "high",
|
||
chatStream: "stream",
|
||
chatSideResult: {
|
||
kind: "btw",
|
||
runId: "btw-run-1",
|
||
sessionKey: "main",
|
||
question: "what changed?",
|
||
text: "draft answer",
|
||
isError: false,
|
||
ts: 1,
|
||
},
|
||
lastError: "oops",
|
||
compactionStatus: { phase: "active" },
|
||
fallbackStatus: { phase: "active" },
|
||
chatAvatarUrl: "/avatar/old",
|
||
chatQueue: [{ id: "queued", text: "message B", createdAt: 1 }],
|
||
chatQueueBySession: {},
|
||
chatRunId: "run-1",
|
||
sessionsShowArchived: false,
|
||
chatSideResultTerminalRuns: new Set(["btw-run-1"]),
|
||
chatStreamStartedAt: 1,
|
||
sessionsResult: {
|
||
ts: 0,
|
||
path: "",
|
||
count: 2,
|
||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||
sessions: [
|
||
row({ key: "main" }),
|
||
row({ key: "agent:main:test-b", label: "Review Session" }),
|
||
],
|
||
},
|
||
settings,
|
||
announceSessionSwitch: vi.fn(),
|
||
applySettings(next: typeof settings) {
|
||
state.settings = next;
|
||
},
|
||
loadAssistantIdentity: vi.fn(),
|
||
resetToolStream: vi.fn(),
|
||
resetChatScroll: vi.fn(),
|
||
resetChatInputHistoryNavigation: vi.fn(),
|
||
} as unknown as AppViewState;
|
||
|
||
refreshChatAvatarMock.mockResolvedValue(undefined);
|
||
refreshSlashCommandsMock.mockResolvedValue(undefined);
|
||
loadChatHistoryMock.mockResolvedValue(undefined);
|
||
loadSessionsMock.mockResolvedValue(undefined);
|
||
|
||
switchChatSession(state, "agent:main:test-b");
|
||
await Promise.resolve();
|
||
|
||
expect(state.chatQueue).toStrictEqual([]);
|
||
expect(state.chatQueueBySession.main).toEqual([
|
||
{ id: "queued", text: "message B", createdAt: 1 },
|
||
]);
|
||
expect(state.chatSideResult).toBeNull();
|
||
expect(state.chatSideResultTerminalRuns.size).toBe(0);
|
||
expect(
|
||
(state as unknown as { resetChatInputHistoryNavigation: ReturnType<typeof vi.fn> })
|
||
.resetChatInputHistoryNavigation,
|
||
).toHaveBeenCalled();
|
||
expect(refreshChatAvatarMock).toHaveBeenCalledWith(state);
|
||
expect(refreshSlashCommandsMock).toHaveBeenCalledWith({
|
||
client: undefined,
|
||
agentId: "main",
|
||
});
|
||
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
|
||
expect(syncSelectedSessionMessageSubscriptionMock).toHaveBeenCalledWith(state);
|
||
expect(loadSessionsMock).toHaveBeenCalledWith(state, {
|
||
activeMinutes: 120,
|
||
limit: 50,
|
||
includeGlobal: true,
|
||
includeUnknown: true,
|
||
showArchived: false,
|
||
});
|
||
expect(
|
||
(state as unknown as { announceSessionSwitch: ReturnType<typeof vi.fn> })
|
||
.announceSessionSwitch,
|
||
).toHaveBeenCalledWith("agent:main:test-b", "Review Session");
|
||
});
|
||
|
||
it("restores queued messages when switching back to their session", () => {
|
||
const settings = createSettings();
|
||
const state = {
|
||
sessionKey: "main",
|
||
chatMessage: "",
|
||
chatAttachments: [],
|
||
chatMessages: [],
|
||
chatToolMessages: [],
|
||
chatStreamSegments: [],
|
||
chatThinkingLevel: null,
|
||
chatStream: "stream",
|
||
chatSideResult: null,
|
||
lastError: null,
|
||
compactionStatus: null,
|
||
fallbackStatus: null,
|
||
chatAvatarUrl: null,
|
||
chatQueue: [{ id: "queued-1", text: "message B", createdAt: 1 }],
|
||
chatQueueBySession: {},
|
||
chatRunId: "run-1",
|
||
sessionsShowArchived: false,
|
||
chatSideResultTerminalRuns: new Set<string>(),
|
||
chatStreamStartedAt: 1,
|
||
settings,
|
||
announceSessionSwitch: vi.fn(),
|
||
applySettings(next: typeof settings) {
|
||
state.settings = next;
|
||
},
|
||
loadAssistantIdentity: vi.fn(),
|
||
resetToolStream: vi.fn(),
|
||
resetChatScroll: vi.fn(),
|
||
resetChatInputHistoryNavigation: vi.fn(),
|
||
} as unknown as AppViewState;
|
||
|
||
refreshChatAvatarMock.mockResolvedValue(undefined);
|
||
refreshSlashCommandsMock.mockResolvedValue(undefined);
|
||
loadChatHistoryMock.mockResolvedValue(undefined);
|
||
loadSessionsMock.mockResolvedValue(undefined);
|
||
|
||
switchChatSession(state, "agent:main:other");
|
||
expect(state.chatQueue).toStrictEqual([]);
|
||
|
||
switchChatSession(state, "main");
|
||
|
||
expect(state.chatQueue).toEqual([{ id: "queued-1", text: "message B", createdAt: 1 }]);
|
||
});
|
||
|
||
it("does not force agentId=main for plain session keys", async () => {
|
||
const settings = createSettings();
|
||
const state = {
|
||
sessionKey: "main",
|
||
chatMessage: "",
|
||
chatAttachments: [],
|
||
chatMessages: [],
|
||
chatToolMessages: [],
|
||
chatStreamSegments: [],
|
||
chatThinkingLevel: null,
|
||
chatStream: null,
|
||
chatSideResult: null,
|
||
lastError: null,
|
||
compactionStatus: null,
|
||
fallbackStatus: null,
|
||
chatAvatarUrl: null,
|
||
chatQueue: [],
|
||
chatQueueBySession: {},
|
||
chatRunId: null,
|
||
sessionsShowArchived: false,
|
||
chatSideResultTerminalRuns: new Set<string>(),
|
||
chatStreamStartedAt: null,
|
||
settings,
|
||
announceSessionSwitch: vi.fn(),
|
||
applySettings(next: typeof settings) {
|
||
state.settings = next;
|
||
},
|
||
loadAssistantIdentity: vi.fn(),
|
||
resetToolStream: vi.fn(),
|
||
resetChatScroll: vi.fn(),
|
||
resetChatInputHistoryNavigation: vi.fn(),
|
||
client: { request: vi.fn() },
|
||
} as unknown as AppViewState;
|
||
|
||
refreshChatAvatarMock.mockResolvedValue(undefined);
|
||
refreshSlashCommandsMock.mockResolvedValue(undefined);
|
||
loadChatHistoryMock.mockResolvedValue(undefined);
|
||
loadSessionsMock.mockResolvedValue(undefined);
|
||
|
||
switchChatSession(state, "main");
|
||
await Promise.resolve();
|
||
|
||
expect(
|
||
(state as unknown as { announceSessionSwitch: ReturnType<typeof vi.fn> })
|
||
.announceSessionSwitch,
|
||
).not.toHaveBeenCalled();
|
||
expect(refreshSlashCommandsMock).toHaveBeenCalledWith({
|
||
client: state.client,
|
||
agentId: undefined,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("dismissChatError", () => {
|
||
it("clears persistent Talk error state", () => {
|
||
const stop = vi.fn();
|
||
const state = {
|
||
lastError: 'Realtime voice provider "openai" is not configured',
|
||
lastErrorCode: "UNAVAILABLE",
|
||
realtimeTalkActive: true,
|
||
realtimeTalkSession: { stop },
|
||
realtimeTalkStatus: "error",
|
||
realtimeTalkDetail: 'Realtime voice provider "openai" is not configured',
|
||
realtimeTalkTranscript: "partial transcript",
|
||
} as unknown as AppViewState & { realtimeTalkSession: { stop(): void } | null };
|
||
|
||
dismissChatError(state);
|
||
|
||
expect(state.lastError).toBeNull();
|
||
expect(state.lastErrorCode).toBeNull();
|
||
expect(stop).toHaveBeenCalledOnce();
|
||
expect(state.realtimeTalkSession).toBeNull();
|
||
expect(state.realtimeTalkActive).toBe(false);
|
||
expect(state.realtimeTalkStatus).toBe("idle");
|
||
expect(state.realtimeTalkDetail).toBeNull();
|
||
expect(state.realtimeTalkTranscript).toBeNull();
|
||
});
|
||
});
|