feat(sessions): persist shared unread state

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 19:36:16 +02:00
parent a20a8c8c09
commit 115d74e79a
37 changed files with 4272 additions and 66 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep session unread indicators and counts synchronized across browser clients and daemon restarts, and clear them when the completed chat is viewed. Tracked sub-sessions remain excluded from unread counts.
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+23
View File
@@ -196,6 +196,29 @@ describe("Pi package API", () => {
});
describe("session API compatibility", () => {
it("reads and acknowledges daemon-owned unread state through encoded machine routes", async () => {
const unread = {
catalogId: "catalog-a",
catalogRevision: 1,
sessions: [{ sessionId: "s /?", cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:01.000Z" }],
};
const cleared = { catalogId: "catalog-a", catalogRevision: 2, sessions: [] };
const fetchMock = stubSequenceFetch([jsonResponse(unread), jsonResponse(cleared)]);
await expect(sessionsApi.unreadCatalog("remote a")).resolves.toEqual(unread);
await expect(sessionsApi.acknowledgeUnread({ id: "s /?", cwd: "/repo" }, "catalog-a", 1, "remote a")).resolves.toEqual(cleared);
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/unread");
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%20%2F%3F/unread/acknowledge");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: 1,
});
});
it("posts session cleanup preview and execute requests through the selected machine", async () => {
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
const executed = { ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: [] };
+7 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl";
import { request } from "./http";
import {
@@ -42,6 +42,7 @@ import {
parseSessionInfo,
parseSessionNotificationInboxSnapshot,
parseSessionStatus,
parseSessionUnreadCatalogSnapshot,
parseSessionStreamSnapshot,
parseSessionTreeNavigateResult,
parseSlashCommand,
@@ -205,6 +206,11 @@ export const workspacesApi = {
export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
unreadCatalog: (machineId = "local") => request(`${machinePrefix(machineId)}/sessions/unread`, parseSessionUnreadCatalogSnapshot, { cache: "no-store" }),
acknowledgeUnread: (session: SessionRef, catalogId: string, throughCompletionOrder: number, machineId = "local") => {
const body: SessionUnreadAcknowledgeRequest = { cwd: session.cwd, catalogId, throughCompletionOrder };
return request(sessionPath(session, "unread/acknowledge", machineId), parseSessionUnreadCatalogSnapshot, { method: "POST", body: JSON.stringify(body) });
},
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
@@ -36,6 +36,14 @@ describe("federated route contract", () => {
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("notifications"))).toBe(false);
});
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
{ method: "GET", path: "/sessions/unread" },
{ method: "POST", path: "/sessions/:sessionId/unread/acknowledge" },
]);
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("unread"))).toBe(false);
});
it("allowlists session tree navigation with a long model-operation timeout and no new WebSocket", () => {
expect(FEDERATED_HTTP_ROUTES.find((route) => route.path === "/sessions/:sessionId/tree/navigate")).toEqual({
method: "POST",
@@ -77,6 +85,8 @@ describe("federated route contract", () => {
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
ignoreParseFailure(sessionsApi.unreadCatalog(machineId)),
ignoreParseFailure(sessionsApi.acknowledgeUnread(session, "catalog-a", 7, machineId)),
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
+74 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../../shared/apiTypes";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
@@ -217,6 +217,78 @@ describe("API parsers", () => {
expect(() => parseSessionStreamSnapshot({ partial: null })).toThrow("Expected number field: seq");
});
it("strictly parses unread snapshots and identity-matched deltas", () => {
const newest = { sessionId: "session-2", cwd: "/repo", completionOrder: 2, completedAt: "2026-07-20T00:00:02.000Z" };
const oldest = { sessionId: "session-1", cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:01.000Z" };
expect(parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 2, sessions: [newest, oldest] })).toEqual({
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [newest, oldest],
});
expect(parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 3,
sessionId: newest.sessionId,
cwd: newest.cwd,
unread: newest,
})).toMatchObject({ type: "sessions.unread", unread: newest });
expect(parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 4,
sessionId: newest.sessionId,
cwd: newest.cwd,
unread: null,
})).toMatchObject({ type: "sessions.unread", unread: null });
});
it("rejects malformed, duplicate, unsorted, and mismatched unread payloads", () => {
const summary = { sessionId: "session-1", cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:01.000Z" };
expect(() => parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 2, sessions: [summary, summary] })).toThrow("Duplicate session unread identity");
expect(() => parseSessionUnreadCatalogSnapshot({
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary, { ...summary, sessionId: "session-2", completionOrder: 2 }],
})).toThrow("not newest-first");
expect(() => parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 1, sessions: [{ ...summary, completedAt: "never" }] })).toThrow("Invalid canonical session unread completion time");
expect(() => parseSessionUnreadCatalogSnapshot({ catalogId: "catalog-a", catalogRevision: 1, sessions: [{ ...summary, completedAt: "2026-07-20" }] })).toThrow("Invalid canonical session unread completion time");
expect(() => parseSessionUnreadCatalogSnapshot({
catalogId: "x".repeat(SESSION_UNREAD_CATALOG_ID_MAX_LENGTH + 1),
catalogRevision: 0,
sessions: [],
})).toThrow("String field exceeds limit: catalogId");
expect(() => parseSessionUnreadCatalogSnapshot({
catalogId: "catalog-a",
catalogRevision: 0,
sessions: [summary],
})).toThrow("completion order exceeds catalog revision");
expect(() => parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "session-1",
cwd: "/repo",
unread: { ...summary, completionOrder: 2 },
})).toThrow("completion order exceeds catalog revision");
expect(() => parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "other-session",
cwd: "/repo",
unread: summary,
})).toThrow("identity mismatch");
expect(() => parseSessionUnreadEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 0,
sessionId: "session-1",
cwd: "/repo",
unread: null,
})).toThrow("positive safe integer");
});
it("parses session cleanup preview and execute responses", () => {
const preview = {
generatedAt: "2026-06-25T12:00:00.000Z",
+88 -1
View File
@@ -1,4 +1,4 @@
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -233,6 +233,93 @@ export function parseSessionStreamSnapshot(value: unknown): SessionStreamSnapsho
};
}
export function parseSessionUnreadCatalogSnapshot(value: unknown): SessionUnreadCatalogSnapshot {
const record = requireRecord(value);
const catalogRevision = requireNonNegativeSafeInteger(record, "catalogRevision");
const sessions = boundedArrayOf(record["sessions"], parseSessionUnreadSummary, SESSION_UNREAD_LIMIT, "sessions");
assertUniqueUnreadSummaries(sessions);
assertUnreadNewestFirst(sessions);
if (sessions.some((summary) => summary.completionOrder > catalogRevision)) {
throw new Error("Session unread completion order exceeds catalog revision");
}
return {
catalogId: requireBoundedNonEmptyString(record, "catalogId", SESSION_UNREAD_CATALOG_ID_MAX_LENGTH),
catalogRevision,
sessions,
};
}
export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
const record = requireRecord(value);
if (record["type"] !== "sessions.unread") throw new Error("Invalid session unread event type");
const sessionId = requireBoundedNonEmptyString(record, "sessionId", SESSION_UNREAD_SESSION_ID_MAX_LENGTH);
const cwd = requireBoundedNonEmptyString(record, "cwd", SESSION_UNREAD_CWD_MAX_LENGTH);
const catalogRevision = requirePositiveSafeInteger(record, "catalogRevision");
const unread = record["unread"] === null ? null : parseSessionUnreadSummary(record["unread"]);
if (unread !== null && (unread.sessionId !== sessionId || unread.cwd !== cwd)) {
throw new Error("Session unread event identity mismatch");
}
if (unread !== null && unread.completionOrder > catalogRevision) {
throw new Error("Session unread completion order exceeds catalog revision");
}
return {
type: "sessions.unread",
catalogId: requireBoundedNonEmptyString(record, "catalogId", SESSION_UNREAD_CATALOG_ID_MAX_LENGTH),
catalogRevision,
sessionId,
cwd,
unread,
};
}
function parseSessionUnreadSummary(value: unknown): SessionUnreadSummary {
const record = requireRecord(value);
const completedAt = requireBoundedNonEmptyString(
record,
"completedAt",
SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH,
);
const completedDate = new Date(completedAt);
if (!Number.isFinite(completedDate.getTime()) || completedDate.toISOString() !== completedAt) {
throw new Error("Invalid canonical session unread completion time");
}
return {
sessionId: requireBoundedNonEmptyString(record, "sessionId", SESSION_UNREAD_SESSION_ID_MAX_LENGTH),
cwd: requireBoundedNonEmptyString(record, "cwd", SESSION_UNREAD_CWD_MAX_LENGTH),
completionOrder: requirePositiveSafeInteger(record, "completionOrder"),
completedAt,
};
}
function assertUniqueUnreadSummaries(summaries: readonly SessionUnreadSummary[]): void {
const identities = summaries.map((summary) => JSON.stringify([summary.sessionId, summary.cwd]));
if (new Set(identities).size !== identities.length) throw new Error("Duplicate session unread identity");
const completionOrders = summaries.map((summary) => summary.completionOrder);
if (new Set(completionOrders).size !== completionOrders.length) throw new Error("Duplicate session unread completion order");
}
function assertUnreadNewestFirst(summaries: readonly SessionUnreadSummary[]): void {
for (let index = 1; index < summaries.length; index += 1) {
const previous = summaries[index - 1];
const current = summaries[index];
if (previous === undefined || current === undefined || previous.completionOrder <= current.completionOrder) {
throw new Error("Session unread summaries are not newest-first");
}
}
}
function requireBoundedNonEmptyString(record: Record<string, unknown>, key: string, maxLength: number): string {
const value = requireNonEmptyString(record, key);
if (value.length > maxLength) throw new Error(`String field exceeds limit: ${key}`);
return value;
}
function requirePositiveSafeInteger(record: Record<string, unknown>, key: string): number {
const value = requireNonNegativeSafeInteger(record, key);
if (value === 0) throw new Error(`Expected positive safe integer field: ${key}`);
return value;
}
export function parseSessionNotificationInboxSnapshot(value: unknown): SessionNotificationInboxSnapshot {
const record = requireRecord(value);
const summary = parseSessionNotificationSummary(record["summary"]);
+180 -7
View File
@@ -26,6 +26,7 @@ import { machineSessionKey } from "../machineKeys";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import { selectedNotificationView } from "../sessionNotifications";
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
import { SessionUnreadController } from "../sessionUnread";
import { collapseSessionWarnings, initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, restoreSessionWarnings } from "../sessionWarningVisibility";
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
@@ -48,7 +49,7 @@ import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorksp
import "./MachineList";
import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
import { unreadSessionCount } from "./SessionList";
import "./SessionCleanupDialog";
import "./SessionTreeNavigator";
import "./ChatView";
@@ -106,6 +107,21 @@ export class PiWebApp extends LitElement {
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
private readonly sessionUnread = new SessionUnreadController({
onChange: (machineId) => {
if (selectedMachineId(this.state) !== machineId) return;
this.syncUnreadSessionIds();
this.syncSelectedSessionReadState();
},
onBackgroundError: (operation, machineId, error) => {
console.warn(`Failed to ${operation} session unread state for ${machineId}`, error);
},
});
@state() private unreadSessionIds: ReadonlySet<string> = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions);
private unreadConnected = false;
private committedChatIdentity: string | undefined;
private readyChatIdentity: string | undefined;
private readonly notifications = new SessionNotificationController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -118,6 +134,9 @@ export class PiWebApp extends LitElement {
new SessionStorageSessionSelectionMemory(),
{
notifications: this.notifications,
onSelectedSessionReady: ({ machineId, session }) => {
void this.commitReadyChatAfterRender(machineId, session);
},
replacePromptEditorText: async ({ machineId, sessionId, text }) => {
await this.updateComplete;
if (selectedMachineId(this.state) !== machineId || this.state.selectedSession?.id !== sessionId) return;
@@ -182,6 +201,7 @@ export class PiWebApp extends LitElement {
private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket();
private readonly machineRealtimeSockets = new Map<string, RealtimeSocket>();
private readonly unreadRuntimeRefreshes = new Map<string, Promise<void>>();
private readonly activeTerminalIds = new Set<string>();
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
@@ -232,6 +252,7 @@ export class PiWebApp extends LitElement {
await this.restoreRoute(false);
});
private readonly onPageShow = () => {
void this.renegotiateUnreadMachines();
this.appShell.repairViewportPosition();
this.retryPendingRemoteRouteRestoreSoon();
};
@@ -255,6 +276,14 @@ export class PiWebApp extends LitElement {
this.syncSessionWarningVisibility();
}
protected override updated(): void {
// Lit has now committed the selected chat and app-shell visibility state.
// Recheck after every rendered transition; the unread controller
// deduplicates acknowledgements for the observed completion order.
this.committedChatIdentity = selectedChatIdentity(this.state);
this.syncSelectedSessionReadState();
}
private syncSessionWarningVisibility(): void {
const session = this.state.selectedSession;
this.sessionWarningVisibility = reconcileSessionWarningVisibility(
@@ -264,8 +293,60 @@ export class PiWebApp extends LitElement {
);
}
private syncSelectedSessionReadState(): void {
const session = this.state.selectedSession;
if (session === undefined) return;
const machineId = selectedMachineId(this.state);
if (!this.isSessionSeen(machineId, session)) return;
void this.sessionUnread.acknowledge(machineId, session);
}
private async commitReadyChatAfterRender(machineId: string, session: SessionInfo): Promise<void> {
const identity = unreadChatIdentity(machineId, session);
await this.updateComplete;
if (!this.unreadConnected || selectedChatIdentity(this.state) !== identity) return;
this.readyChatIdentity = identity;
this.syncSelectedSessionReadState();
}
private syncUnreadSessionIds(): void {
const next = this.sessionUnread.unreadSessionIds(selectedMachineId(this.state), this.state.sessions);
if (!sameStringSet(next, this.unreadSessionIds)) this.unreadSessionIds = next;
}
private isSessionSeen(machineId: string, session: SessionInfo): boolean {
if (!this.unreadConnected) return false;
const identity = unreadChatIdentity(machineId, session);
if (selectedChatIdentity(this.state) !== identity
|| this.committedChatIdentity !== identity
|| this.readyChatIdentity !== identity) return false;
if (typeof document !== "undefined") {
if (document.visibilityState !== "visible") return false;
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
}
if (this.isChatObscured()) return false;
if (this.state.mainView === "chat") return true;
if (this.state.mainView === "navigation") return !this.appShell.isMobileNavigationLayout;
return this.isDesktopSideBySideLayout();
}
private isChatObscured(): boolean {
return this.settingsSection !== undefined
|| this.sessionCleanupDialog !== undefined
|| this.state.actionPaletteOpen
|| this.state.projectDialogOpen
|| this.state.machineDialogOpen
|| this.state.commandDialog !== undefined
|| this.state.treeDialog !== undefined
|| this.state.modelDialog !== undefined
|| this.state.thinkingDialog !== undefined
|| this.state.themeDialog !== undefined
|| this.state.authDialog !== undefined;
}
override connectedCallback(): void {
super.connectedCallback();
this.unreadConnected = true;
window.addEventListener("popstate", this.onPopState);
window.addEventListener("pageshow", this.onPageShow);
this.browserResume.connect();
@@ -273,6 +354,7 @@ export class PiWebApp extends LitElement {
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
this.applyPreferredTheme(false);
this.connectRealtime();
void this.renegotiateUnreadMachines();
this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshWorkspaceActivity();
void this.loadClientConfig();
@@ -281,6 +363,11 @@ export class PiWebApp extends LitElement {
}
override disconnectedCallback(): void {
this.unreadConnected = false;
this.committedChatIdentity = undefined;
this.readyChatIdentity = undefined;
this.unreadRuntimeRefreshes.clear();
this.sessionUnread.retainMachines(new Set<string>());
window.removeEventListener("popstate", this.onPopState);
window.removeEventListener("pageshow", this.onPageShow);
this.browserResume.disconnect();
@@ -306,6 +393,12 @@ export class PiWebApp extends LitElement {
if (!patchChangesState(this.state, patch)) return;
const previous = this.state;
this.state = { ...this.state, ...patch };
if (selectedChatIdentity(previous) !== selectedChatIdentity(this.state)) {
this.committedChatIdentity = undefined;
this.readyChatIdentity = undefined;
}
if (machineUnreadInputsChanged(previous, this.state)) this.syncSessionUnreadMachines();
this.syncUnreadSessionIds();
this.handleActivityTransition(previous, this.state);
this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state);
@@ -337,6 +430,7 @@ export class PiWebApp extends LitElement {
}
private async refreshAfterBrowserResume(): Promise<void> {
await this.renegotiateUnreadMachines();
await Promise.all([
this.sessions.refreshSelectedSession(),
this.refreshMachineActivities(),
@@ -825,11 +919,58 @@ export class PiWebApp extends LitElement {
this.git.updatePolling();
}
private syncSessionUnreadMachines(): void {
if (!this.unreadConnected) {
this.sessionUnread.retainMachines(new Set<string>());
return;
}
const machineIds = new Set(this.state.machines.map((machine) => machine.id));
this.sessionUnread.retainMachines(machineIds);
for (const machineId of machineIds) {
const runtime = this.state.machineRuntimes[machineId];
if (runtime === undefined) continue;
const capability = this.unreadRuntimeRefreshes.has(machineId) || !runtime.ok
? "unknown"
: supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsUnread)
? "supported"
: "unsupported";
if (this.sessionUnread.setCapability(machineId, capability)) void this.sessionUnread.refresh(machineId);
}
}
private async renegotiateUnreadMachines(): Promise<void> {
if (!this.unreadConnected) return;
const machineIds = new Set(this.state.machines.map((machine) => machine.id));
machineIds.add(selectedMachineId(this.state));
await Promise.all([...machineIds].map(async (machineId) => { await this.renegotiateUnreadMachine(machineId); }));
}
private renegotiateUnreadMachine(machineId: string): Promise<void> {
const existing = this.unreadRuntimeRefreshes.get(machineId);
if (existing !== undefined) return existing;
this.sessionUnread.setCapability(machineId, "unknown");
let refreshed = false;
const refresh = Promise.resolve().then(async () => {
refreshed = await this.machines.refreshMachineRuntime(machineId) !== undefined;
});
this.unreadRuntimeRefreshes.set(machineId, refresh);
const finishRefresh = () => {
if (this.unreadRuntimeRefreshes.get(machineId) !== refresh) return;
this.unreadRuntimeRefreshes.delete(machineId);
if (!this.unreadConnected) return;
if (refreshed) this.syncSessionUnreadMachines();
else this.sessionUnread.setCapability(machineId, "unknown");
};
void refresh.then(finishRefresh, finishRefresh);
return refresh;
}
private connectRealtime(): void {
const machineId = selectedMachineId(this.state);
this.realtime.connect(
(event) => { this.handleRealtimeEvent(event); },
(event) => { this.handleRealtimeEvent(machineId, event); },
() => {
void this.renegotiateUnreadMachine(machineId);
const workspace = this.state.selectedWorkspace;
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity(machineId);
@@ -851,6 +992,7 @@ export class PiWebApp extends LitElement {
socket.connect(
(event) => { this.handleMachineActivityEvent(machineId, event); },
() => {
void this.renegotiateUnreadMachine(machineId);
void this.refreshWorkspaceActivity(machineId);
},
machineId,
@@ -873,11 +1015,13 @@ export class PiWebApp extends LitElement {
}
private handleMachineActivityEvent(machineId: string, event: BrowserRealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId);
if (event.type === "sessions.unread") this.sessionUnread.applyEvent(machineId, event);
else if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId);
}
private handleRealtimeEvent(event: BrowserRealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
private handleRealtimeEvent(machineId: string, event: BrowserRealtimeEvent): void {
if (event.type === "sessions.unread") this.sessionUnread.applyEvent(machineId, event);
else if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (isTerminalEvent(event)) {
this.applyTerminalEvent(event);
if (event.type === "terminal.exited") void this.refreshWorkspaceDeletionRuns();
@@ -1174,6 +1318,7 @@ export class PiWebApp extends LitElement {
.sessionStatuses=${this.state.sessionStatuses}
.sessionActivities=${this.state.sessionActivities}
.sendingPrompts=${this.state.sendingPrompts}
.unreadSessionIds=${this.unreadSessionIds}
.selectedSession=${this.state.selectedSession}
.startingSessionCount=${this.state.startingSessionCount}
.canStartSession=${!!this.state.selectedWorkspace}
@@ -2027,8 +2172,19 @@ export class PiWebApp extends LitElement {
}
private mobileMainTabs(): AppMobileMainTab[] {
const unreadCount = unreadSessionCount(this.state.sessions, this.unreadSessionIds, {
statuses: this.state.sessionStatuses,
activities: this.state.sessionActivities,
sending: this.state.sendingPrompts,
});
return [
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
{
id: "navigation",
label: "Sessions",
icon: "navigation",
className: "navigation-tab",
...(unreadCount === 0 ? {} : { badge: unreadCount, badgeLabel: `${String(unreadCount)} unread`, badgeTone: "unread" }),
},
{ id: "chat", label: "Chat", icon: "chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
const icon = panel.icon ?? this.mobilePanelIcon(panel);
@@ -2075,7 +2231,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${async (machineId: string) => { await this.machines.refreshMachineRuntime(machineId); }}></settings-dialog>` : null}
</div>
`;
}
@@ -2096,6 +2252,19 @@ function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): Plugi
return { id: "local", name: "local", kind: "local" };
}
function unreadChatIdentity(machineId: string, session: Pick<SessionInfo, "id" | "cwd">): string {
return JSON.stringify([machineId, session.id, session.cwd]);
}
function selectedChatIdentity(state: Pick<AppState, "selectedMachine" | "selectedSession">): string | undefined {
const session = state.selectedSession;
return session === undefined ? undefined : unreadChatIdentity(selectedMachineId(state), session);
}
function machineUnreadInputsChanged(previous: AppState, next: AppState): boolean {
return previous.machines !== next.machines || previous.machineRuntimes !== next.machineRuntimes;
}
function machineActivitySubscriptionInputsChanged(previous: AppState, next: AppState): boolean {
return previous.machines !== next.machines
|| previous.machineStatuses !== next.machineStatuses
@@ -2116,6 +2285,10 @@ function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
}
function sameStringSet(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
return left.size === right.size && [...left].every((value) => right.has(value));
}
function isActive(state: Pick<AppState, "status" | "activity">): boolean {
return isSessionActive(state.status, state.activity);
}
@@ -0,0 +1,459 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { SessionInfo, SessionUnreadEvent, SessionUnreadSummary } from "../api";
import { initialAppState, type AppState } from "../appState";
import type { BrowserRealtimeEvent } from "../sessionSocket";
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
// Template inspection is proportionate here because this node-environment test
// verifies only PiWebApp's unread-state property wiring into navigation.
import { templateValueAfterMarker } from "../templateInspection.testSupport";
import { PiWebApp } from "./PiWebApp";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp session unread wiring", () => {
it("shows a server completion for a background chat and acknowledges the exact observed order when viewed", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const foreground = session("foreground");
const background = session("background");
setAppState(app, { ...initialAppState(), sessions: [foreground, background], selectedSession: foreground, mainView: "chat" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(background, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([background.id]);
expect(mobileNavigationTab(app)).toMatchObject({ badge: 1, badgeLabel: "1 unread", badgeTone: "unread" });
setState(app, { selectedSession: background });
// Selection alone cannot clear unread before the new transcript is ready
// and Lit has committed the corresponding chat.
expect([...navigationUnreadSessionIds(app)]).toEqual([background.id]);
expect(fetchMock).not.toHaveBeenCalled();
exposeSelectedChat(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(mobileNavigationTab(app)).not.toHaveProperty("badge");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/background/unread/acknowledge");
const init = fetchMock.mock.calls[0]?.[1];
expect(JSON.parse(typeof init?.body === "string" ? init.body : "{}")).toEqual({
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: 1,
});
});
it("keeps the selected chat unread while hidden or unfocused, then acknowledges on a visible focus check", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const documentState = { visible: false, focused: false };
vi.stubGlobal("document", {
baseURI: "https://pi.example.test/",
get visibilityState() { return documentState.visible ? "visible" : "hidden"; },
hasFocus: () => documentState.focused,
});
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "chat" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([selected.id]);
expect(fetchMock).not.toHaveBeenCalled();
documentState.visible = true;
documentState.focused = true;
setState(app, { error: "refresh" });
invokeUpdated(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("keeps a focused mobile chat unread while navigation is stacked over it, then acknowledges on a layout-only reveal", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp({}, true);
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "navigation" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([selected.id]);
expect(fetchMock).not.toHaveBeenCalled();
setMobileNavigationLayout(app, false);
invokeUpdated(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("does not acknowledge a selected chat hidden behind a full-screen dialog", async () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "chat" });
exposeSelectedChat(app);
if (!Reflect.set(app, "settingsSection", "general")) throw new Error("Could not open settings");
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(selected, 1)));
expect([...navigationUnreadSessionIds(app)]).toEqual([selected.id]);
expect(fetchMock).not.toHaveBeenCalled();
if (!Reflect.set(app, "settingsSection", undefined)) throw new Error("Could not close settings");
invokeUpdated(app);
await vi.waitFor(() => { expect(navigationUnreadSessionIds(app).size).toBe(0); });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("applies another client's authoritative clear without issuing a redundant acknowledgement", () => {
const fetchMock = stubJsonFetch({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
const app = createApp();
enableUnread(app);
const foreground = session("foreground");
const background = session("background");
setAppState(app, { ...initialAppState(), sessions: [foreground, background], selectedSession: foreground, mainView: "chat" });
exposeSelectedChat(app);
handleRealtimeEvent(app, unreadEvent(1, unreadSummary(background, 1)));
handleRealtimeEvent(app, unreadEvent(2, null, background));
expect(navigationUnreadSessionIds(app).size).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
});
it("fetches initial state only after the selected runtime advertises unread support", async () => {
const background = session("background");
const durable = { catalogId: "catalog-a", catalogRevision: 1, sessions: [unreadSummary(background, 1)] };
const fetchMock = stubJsonFetch(durable);
const app = createApp();
enableUnread(app);
const foreground = session("foreground");
const localMachine = {
id: "local",
name: "Local",
kind: "local" as const,
createdAt: "2026-07-20T00:00:00.000Z",
updatedAt: "2026-07-20T00:00:00.000Z",
};
setAppState(app, {
...initialAppState(),
machines: [localMachine],
selectedMachine: localMachine,
sessions: [foreground, background],
selectedSession: foreground,
mainView: "chat",
});
setState(app, { machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } });
expect(fetchMock).not.toHaveBeenCalled();
setState(app, {
machineRuntimes: {
local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsUnread] },
},
});
await vi.waitFor(() => { expect([...navigationUnreadSessionIds(app)]).toEqual([background.id]); });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/unread");
});
it("drops a deferred unread snapshot on disconnect without acknowledging it", async () => {
const response = deferred<Response>();
const fetchMock = vi.fn((input: RequestInfo | URL) => {
void input;
return response.promise;
});
vi.stubGlobal("fetch", fetchMock);
const app = createApp();
enableUnread(app);
const selected = session("selected");
setAppState(app, { ...initialAppState(), sessions: [selected], selectedSession: selected, mainView: "chat" });
exposeSelectedChat(app);
const refreshing = refreshUnread(app, "local");
invokeDisconnected(app);
response.resolve(new Response(JSON.stringify({
catalogId: "catalog-a",
catalogRevision: 1,
sessions: [unreadSummary(selected, 1)],
}), { status: 200, headers: { "Content-Type": "application/json" } }));
await refreshing;
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://pi.example.test/api/machines/local/sessions/unread");
});
it("renegotiates cached support before refreshing after a runtime rollback", async () => {
const fetchMock = vi.fn(() => Promise.reject(new Error("Unexpected unread request")));
vi.stubGlobal("fetch", fetchMock);
const app = createApp();
enableUnread(app);
const localMachine = {
id: "local",
name: "Local",
kind: "local" as const,
createdAt: "2026-07-20T00:00:00.000Z",
updatedAt: "2026-07-20T00:00:00.000Z",
};
setAppState(app, {
...initialAppState(),
machines: [localMachine],
selectedMachine: localMachine,
machineRuntimes: {
local: { machineId: "local", ok: true, checkedAt: "before", capabilities: [PI_WEB_CAPABILITIES.sessionsUnread] },
},
});
const machines: unknown = Reflect.get(app, "machines");
if (typeof machines !== "object" || machines === null) throw new Error("PiWebApp machine controller is unavailable");
if (!Reflect.set(machines, "refreshMachineRuntime", () => {
const runtime = { machineId: "local", ok: true as const, checkedAt: "after", capabilities: [] };
setState(app, { machineRuntimes: { local: runtime } });
return Promise.resolve(runtime);
})) throw new Error("Could not stub machine runtime refresh");
await renegotiateUnreadMachine(app, "local");
await refreshUnread(app, "local");
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not treat the legacy browser-local key as unread authority", () => {
const app = createApp({ "pi-web-session-unread-v1": JSON.stringify([["local", "background"]]) });
const foreground = session("foreground");
const background = session("background");
setAppState(app, { ...initialAppState(), sessions: [foreground, background], selectedSession: foreground, mainView: "chat" });
expect(navigationUnreadSessionIds(app).size).toBe(0);
});
});
type RenderNavigationPanel = (this: PiWebApp) => TemplateResult;
type SetAppState = (this: PiWebApp, patch: Partial<AppState>) => void;
type HandleRealtimeEvent = (this: PiWebApp, machineId: string, event: BrowserRealtimeEvent) => void;
type MobileMainTabs = (this: PiWebApp) => AppMobileMainTab[];
type UpdatedHook = (this: PiWebApp) => void;
type DisconnectedHook = (this: PiWebApp) => void;
type RenegotiateUnreadMachine = (this: PiWebApp, machineId: string) => Promise<void>;
type RefreshUnread = (machineId: string) => Promise<void>;
function createApp(storedValues: Record<string, string> = {}, mobileNavigation = false): PiWebApp {
const values = new Map(Object.entries(storedValues));
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
const matchMedia = (query: string) => ({
matches: mobileNavigation && query.includes("max-width: 760px"),
media: query,
addEventListener: () => undefined,
removeEventListener: () => undefined,
});
vi.stubGlobal("window", {
location: { search: "" },
localStorage: storage,
matchMedia,
addEventListener: () => undefined,
removeEventListener: () => undefined,
clearInterval: () => undefined,
clearTimeout: () => undefined,
});
if (typeof document === "undefined") {
vi.stubGlobal("document", { baseURI: "https://pi.example.test/", visibilityState: "visible", hasFocus: () => true });
}
vi.stubGlobal("requestAnimationFrame", () => 1);
return new PiWebApp();
}
function setAppState(app: PiWebApp, state: AppState): void {
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
}
function setState(app: PiWebApp, patch: Partial<AppState>): void {
const method: unknown = Reflect.get(app, "setState");
if (!isSetAppState(method)) throw new Error("PiWebApp.setState is not callable");
method.call(app, patch);
}
function handleRealtimeEvent(app: PiWebApp, event: BrowserRealtimeEvent): void {
const method: unknown = Reflect.get(app, "handleRealtimeEvent");
if (!isHandleRealtimeEvent(method)) throw new Error("PiWebApp.handleRealtimeEvent is not callable");
method.call(app, "local", event);
}
function enableUnread(app: PiWebApp): void {
if (!Reflect.set(app, "unreadConnected", true)) throw new Error("Could not connect PiWebApp unread state");
const controller: unknown = Reflect.get(app, "sessionUnread");
if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable");
const setCapability: unknown = Reflect.get(controller, "setCapability");
if (typeof setCapability !== "function") throw new Error("PiWebApp unread capability setter is unavailable");
Reflect.apply(setCapability, controller, ["local", "supported"]);
}
function exposeSelectedChat(app: PiWebApp): void {
const state: unknown = Reflect.get(app, "state");
if (typeof state !== "object" || state === null) throw new Error("PiWebApp state is unavailable");
const selectedSession: unknown = Reflect.get(state, "selectedSession");
if (typeof selectedSession !== "object" || selectedSession === null) throw new Error("Expected a selected chat");
const sessionId: unknown = Reflect.get(selectedSession, "id");
const cwd: unknown = Reflect.get(selectedSession, "cwd");
if (typeof sessionId !== "string" || typeof cwd !== "string") throw new Error("Selected chat identity is invalid");
const selectedMachine: unknown = Reflect.get(state, "selectedMachine");
const machineId = typeof selectedMachine === "object" && selectedMachine !== null && typeof Reflect.get(selectedMachine, "id") === "string"
? String(Reflect.get(selectedMachine, "id"))
: "local";
if (!Reflect.set(app, "readyChatIdentity", JSON.stringify([machineId, sessionId, cwd]))) {
throw new Error("Could not mark selected chat ready");
}
invokeUpdated(app);
}
function setMobileNavigationLayout(app: PiWebApp, mobile: boolean): void {
const appShell: unknown = Reflect.get(app, "appShell");
if (typeof appShell !== "object" || appShell === null || !Reflect.set(appShell, "isMobileNavigationLayout", mobile)) {
throw new Error("Could not update the app-shell layout");
}
}
function invokeUpdated(app: PiWebApp): void {
const method: unknown = Reflect.get(app, "updated");
if (!isUpdatedHook(method)) throw new Error("PiWebApp.updated is not callable");
method.call(app);
}
function invokeDisconnected(app: PiWebApp): void {
const method: unknown = Reflect.get(app, "disconnectedCallback");
if (!isDisconnectedHook(method)) throw new Error("PiWebApp.disconnectedCallback is not callable");
method.call(app);
}
async function renegotiateUnreadMachine(app: PiWebApp, machineId: string): Promise<void> {
const method: unknown = Reflect.get(app, "renegotiateUnreadMachine");
if (!isRenegotiateUnreadMachine(method)) throw new Error("PiWebApp unread renegotiation is not callable");
await method.call(app, machineId);
}
function refreshUnread(app: PiWebApp, machineId: string): Promise<void> {
const controller: unknown = Reflect.get(app, "sessionUnread");
if (typeof controller !== "object" || controller === null) throw new Error("PiWebApp unread controller is unavailable");
const refresh: unknown = Reflect.get(controller, "refresh");
if (!isRefreshUnread(refresh)) throw new Error("PiWebApp unread refresh is not callable");
return refresh.call(controller, machineId);
}
function mobileNavigationTab(app: PiWebApp): AppMobileMainTab {
const method: unknown = Reflect.get(app, "mobileMainTabs");
if (!isMobileMainTabs(method)) throw new Error("PiWebApp.mobileMainTabs is not callable");
const tab = method.call(app).find((candidate) => candidate.id === "navigation");
if (tab === undefined) throw new Error("Expected the mobile Sessions tab");
return tab;
}
function navigationUnreadSessionIds(app: PiWebApp): ReadonlySet<string> {
const method: unknown = Reflect.get(app, "renderNavigationPanel");
if (!isRenderNavigationPanel(method)) throw new Error("PiWebApp.renderNavigationPanel is not callable");
const value = templateValueAfterMarker(method.call(app), ".unreadSessionIds=");
if (!(value instanceof Set) || ![...value].every((entry: unknown) => typeof entry === "string")) {
throw new Error("Expected unread session ids in navigation");
}
return value;
}
function session(id: string): SessionInfo {
return {
id,
cwd: "/repo",
path: `/repo/${id}.jsonl`,
created: "2026-07-20T00:00:00.000Z",
modified: "2026-07-20T00:00:00.000Z",
messageCount: 1,
firstMessage: id,
};
}
function unreadSummary(target: SessionInfo, completionOrder: number): SessionUnreadSummary {
return {
sessionId: target.id,
cwd: target.cwd,
completionOrder,
completedAt: `2026-07-20T00:00:0${String(completionOrder)}.000Z`,
};
}
function unreadEvent(
catalogRevision: number,
unread: SessionUnreadSummary | null,
target: SessionInfo = unread === null ? session("selected") : session(unread.sessionId),
): SessionUnreadEvent {
return {
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision,
sessionId: unread?.sessionId ?? target.id,
cwd: unread?.cwd ?? target.cwd,
unread,
};
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolvePromise: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => { resolvePromise = resolve; });
return {
promise,
resolve(value) {
if (resolvePromise === undefined) throw new Error("Deferred promise is unavailable");
resolvePromise(value);
},
};
}
function stubJsonFetch(body: unknown) {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
void input;
void init;
return Promise.resolve(new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function isRenderNavigationPanel(value: unknown): value is RenderNavigationPanel {
return typeof value === "function";
}
function isSetAppState(value: unknown): value is SetAppState {
return typeof value === "function";
}
function isHandleRealtimeEvent(value: unknown): value is HandleRealtimeEvent {
return typeof value === "function";
}
function isMobileMainTabs(value: unknown): value is MobileMainTabs {
return typeof value === "function";
}
function isUpdatedHook(value: unknown): value is UpdatedHook {
return typeof value === "function";
}
function isDisconnectedHook(value: unknown): value is DisconnectedHook {
return typeof value === "function";
}
function isRenegotiateUnreadMachine(value: unknown): value is RenegotiateUnreadMachine {
return typeof value === "function";
}
function isRefreshUnread(value: unknown): value is RefreshUnread {
return typeof value === "function";
}
+25 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SessionInfo, SessionStatus } from "../api";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList";
import { sessionRowActivityKind, sessionRowsForCurrentTree, unreadSessionCount } from "./SessionList";
describe("sessionRowActivityKind", () => {
const idle = sessionStatus("s");
@@ -16,13 +16,33 @@ describe("sessionRowActivityKind", () => {
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
});
it("reports undefined when idle and not sending", () => {
it("reports unread only while the session is idle", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
expect(sessionRowActivityKind(session("s"), idle, undefined, true, true)).toBe("sending");
});
it("reports undefined when idle, read, and not sending", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined();
});
it("never shows an indicator for archived or cached-new sessions, even while sending", () => {
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true)).toBeUndefined();
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true)).toBeUndefined();
it("never shows an indicator for archived or cached-new sessions, even while sending or unread", () => {
expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true, true)).toBeUndefined();
expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true, true)).toBeUndefined();
});
});
describe("unreadSessionCount", () => {
it("counts only current persisted sessions", () => {
const current = session("current");
const archived = { ...session("archived"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
const cached = markCachedNewSessionInfo(session("cached"));
const unreadIds = new Set([current.id, archived.id, cached.id]);
expect(unreadSessionCount([current, archived, cached], unreadIds)).toBe(1);
expect(unreadSessionCount([current, archived, cached], unreadIds, {
statuses: { [current.id]: sessionStatus(current.id, { isStreaming: true }) },
})).toBe(0);
});
});
+51 -9
View File
@@ -30,6 +30,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sending: Record<string, true> = {};
@property({ attribute: false }) unreadSessionIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) selected?: SessionInfo;
@property({ type: Number }) startingCount = 0;
@property({ type: Boolean }) canStart = false;
@@ -107,9 +108,14 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current");
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
const descendantCounts = unarchivedDescendantCounts(this.sessions);
const unreadCount = unreadSessionCount(currentSelectableSessions, this.unreadSessionIds, {
statuses: this.statuses,
activities: this.activities,
sending: this.sending,
});
return html`
<section>
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions)}
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions, unreadCount)}
${this.collapsed ? null : html`
<div class="list-body">
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
@@ -128,12 +134,13 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`;
}
private renderHeading(sessionCount: number, currentSessions: SessionInfo[]) {
private renderHeading(sessionCount: number, currentSessions: SessionInfo[], unreadCount: number) {
if (!this.collapsible) {
return html`
<h2>
<span class="plain-heading">Sessions</span>
${this.renderCurrentSelectionButton(currentSessions)}
${this.renderUnreadCount(unreadCount)}
${this.renderCleanupButton()}
${this.renderStartButton()}
</h2>
@@ -145,6 +152,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<h2>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
${this.renderCurrentSelectionButton(currentSessions)}
${this.renderUnreadCount(unreadCount)}
<small class="section-count">${sessionCount}</small>
${this.renderCleanupButton()}
${this.renderStartButton()}
@@ -152,6 +160,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`;
}
private renderUnreadCount(unreadCount: number) {
if (unreadCount === 0) return null;
const label = `${String(unreadCount)} unread`;
return html`<small class="section-unread-count" title=${label}>${label}</small>`;
}
private renderCurrentSelectionButton(currentSessions: SessionInfo[]) {
if (this.collapsed || currentSessions.length === 0) return null;
const active = this.selectionScopes.has("current");
@@ -235,13 +249,14 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id];
const activity = this.activities[session.id];
const indicatorKind = sessionRowActivityKind(session, status, activity, this.sending[session.id] === true, this.unreadSessionIds.has(session.id));
const persistenceOptions = this.sessionPersistenceOptions();
const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
const canReloadSession = canArchive && this.canReload;
return html`
<div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""} ${indicatorKind === "unread" ? "unread" : ""}"
style=${`--depth:${String(cappedDepth)}`}
tabindex="0"
title=${session.path}
@@ -251,7 +266,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<div class="action-main ${selectionActive ? "selecting" : ""}">
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
<span class="action-name-line"><span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span></span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)}
${this.renderActivity(indicatorKind)}
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
@@ -410,14 +425,19 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return { authoritative: this.authoritativeSessionPersistence };
}
private renderActivity(session: SessionInfo) {
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
private renderActivity(kind: ActivityIndicatorKind | undefined) {
const label = kind === "sending"
? "Sending message"
: kind === "unread"
? "Unread session activity"
: "Session active";
return renderActionActivityIndicator(kind, label);
}
static override styles = [listStyles, css`
h2 { min-height: 30px; }
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
h2 > .section-unread-count { flex: 0 0 auto; display: inline; color: var(--pi-accent); font-size: inherit; text-transform: none; }
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
.start-session-button { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 9px; }
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
@@ -425,6 +445,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
.bulk-row button { padding: 5px 7px; font-size: 12px; }
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
.action-name, .section-selected { text-align: start; unicode-bidi: plaintext; }
.action-row.unread .action-name { color: var(--pi-text-bright); font-weight: 650; }
.plain-heading { min-width: 0; }
.action-name-line { min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
.action-name-line .action-name { flex: 1 1 auto; min-width: 0; }
@@ -442,6 +463,24 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`];
}
export function unreadSessionCount(
sessions: readonly SessionInfo[],
unreadSessionIds: ReadonlySet<string>,
runtime: {
statuses?: Record<string, SessionStatus> | undefined;
activities?: Record<string, SessionActivity> | undefined;
sending?: Record<string, true> | undefined;
} = {},
): number {
return sessions.filter((session) => sessionRowActivityKind(
session,
runtime.statuses?.[session.id],
runtime.activities?.[session.id],
runtime.sending?.[session.id] === true,
unreadSessionIds.has(session.id),
) === "unread").length;
}
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
return session.archived === true ? "archived" : "current";
}
@@ -482,17 +521,20 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
*
* "sending" (client-side upload in flight) is reported with its own kind, and
* takes precedence over server activity, so it can be colored distinctly to
* signal that it is not yet propagated to workspace/machine activity.
* signal that it is not yet propagated to workspace/machine activity. Unread is
* the idle fallback, so it never replaces an indicator for ongoing work.
*/
export function sessionRowActivityKind(
session: SessionInfo,
status: SessionStatus | undefined,
activity: SessionActivity | undefined,
sending: boolean,
unread = false,
): ActivityIndicatorKind | undefined {
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
if (sending) return "sending";
return isSessionActive(status, activity) ? "session" : undefined;
if (isSessionActive(status, activity)) return "session";
return unread ? "unread" : undefined;
}
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
+1 -1
View File
@@ -1,6 +1,6 @@
import { html, type TemplateResult } from "lit";
export type ActivityIndicatorKind = "session" | "terminal" | "sending";
export type ActivityIndicatorKind = "session" | "terminal" | "sending" | "unread";
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
if (kind === undefined) return undefined;
@@ -11,6 +11,8 @@ export interface AppMobileMainTab {
label: string;
icon?: AppMobileMainTabIcon;
badge?: unknown;
badgeLabel?: string | undefined;
badgeTone?: "unread" | undefined;
className?: string | undefined;
}
@@ -53,7 +55,7 @@ export class AppMobileMainTabs extends LitElement {
<button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}>
${this.renderTabMark(tab, fallbackLabels)}
<span class="tab-label">${tab.label}</span>
${this.renderBadge(tab.badge)}
${this.renderBadge(tab.badge, tab.badgeTone)}
</button>
`;
})}
@@ -74,14 +76,13 @@ export class AppMobileMainTabs extends LitElement {
}
private tabAriaLabel(tab: AppMobileMainTab): string {
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
const badge = String(tab.badge).trim();
const badge = tab.badgeLabel ?? (typeof tab.badge === "string" || typeof tab.badge === "number" ? String(tab.badge).trim() : "");
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
}
private renderBadge(badge: unknown) {
private renderBadge(badge: unknown, tone: AppMobileMainTab["badgeTone"]) {
if (badge === undefined || badge === "") return null;
return html`<span class="tab-badge">${badge}</span>`;
return html`<span class=${`tab-badge${tone === undefined ? "" : ` ${tone}`}`}>${badge}</span>`;
}
private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) {
@@ -165,6 +166,7 @@ export class AppMobileMainTabs extends LitElement {
.tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; }
.tab-label { min-width: 0; }
.tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
.tab-badge.unread { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); color: var(--pi-accent); }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
@media (max-width: 760px) {
.mobile-tabs { gap: 4px; padding: 6px 8px; }
@@ -29,6 +29,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) sessionActivities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
@property({ attribute: false }) unreadSessionIds: ReadonlySet<string> = new Set();
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@@ -160,6 +161,7 @@ export class AppNavigationPanel extends LitElement {
.statuses=${this.sessionStatuses}
.activities=${this.sessionActivities}
.sending=${this.sendingPrompts}
.unreadSessionIds=${this.unreadSessionIds}
.selected=${this.selectedSession}
.startingCount=${this.startingSessionCount}
.canStart=${this.canStartSession}
+2
View File
@@ -280,6 +280,8 @@ export const listStyles = css`
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
/* Client-side sending (upload in flight); distinct from server activity, which propagates to workspace/machine rows. */
.activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); }
/* Unread is a stable state, not ongoing work: keep it static and accent-colored. */
.activity-indicator.unread { border-radius: 50%; background: var(--pi-accent); animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--pi-accent) 20%, transparent); }
.action-menu { position: relative; align-self: stretch; }
.action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }
@@ -197,6 +197,25 @@ describe("MachineController", () => {
expect(updateUrl).not.toHaveBeenCalled();
});
it("does not let an older runtime response overwrite a newer capability negotiation", async () => {
let state: AppState = { ...initialAppState(), machines: [localMachine], selectedMachine: localMachine };
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
let resolveOlder: ((runtime: Awaited<ReturnType<typeof api.runtime>>) => void) | undefined;
const older = new Promise<Awaited<ReturnType<typeof api.runtime>>>((resolve) => { resolveOlder = resolve; });
vi.spyOn(api, "runtime")
.mockImplementationOnce(() => older)
.mockResolvedValueOnce({ machineId: "local", ok: true, checkedAt: "new", capabilities: [] });
const controller = new MachineController(() => state, setState, vi.fn(), { loadProjects: vi.fn() });
const first = controller.refreshMachineRuntime("local");
const second = controller.refreshMachineRuntime("local");
await second;
resolveOlder?.({ machineId: "local", ok: true, checkedAt: "old", capabilities: ["sessions.unread"] });
await first;
expect(state.machineRuntimes["local"]).toMatchObject({ checkedAt: "new", capabilities: [] });
});
it("selects the fallback machine after deleting the selected machine by default", async () => {
let state: AppState = { ...initialAppState(), machines: [localMachine, remoteMachine], selectedMachine: remoteMachine, selectedProject: { id: "p1", name: "Project", path: "/repo", createdAt: "now" } };
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
@@ -1,9 +1,11 @@
import { api, type Machine, type MachineHealth } from "../api";
import { api, type Machine, type MachineHealth, type MachineRuntime } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import type { GetState, SetState, UpdateUrl } from "./types";
import type { ProjectController } from "./projectController";
export class MachineController {
private readonly runtimeRefreshSeqByMachine = new Map<string, number>();
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: Pick<ProjectController, "loadProjects">) {}
async loadMachines(routeMachineId?: string): Promise<void> {
@@ -100,12 +102,17 @@ export class MachineController {
}
}
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<MachineRuntime | undefined> {
const seq = (this.runtimeRefreshSeqByMachine.get(machineId) ?? 0) + 1;
this.runtimeRefreshSeqByMachine.set(machineId, seq);
try {
const runtime = await api.runtime(machineId, true);
if (this.runtimeRefreshSeqByMachine.get(machineId) !== seq) return undefined;
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
return runtime;
} catch (error) {
this.setState({ error: String(error) });
if (this.runtimeRefreshSeqByMachine.get(machineId) === seq) this.setState({ error: String(error) });
return undefined;
}
}
@@ -8,6 +8,41 @@ function page(text: string, total: number): MessagePage {
}
describe("SessionController selected-session refresh", () => {
it("signals selection readiness only after the initial transcript join succeeds", async () => {
const messages = deferred<MessagePage>();
const selectedStatus = deferred<SessionStatus>();
const ready: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const api: typeof defaultApi = {
...defaultApi,
messages: () => messages.promise,
status: () => selectedStatus.promise,
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
thinkingLevels: () => Promise.resolve({ levels: [] }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{
api,
socket: new FakeSocket(),
onSelectedSessionReady: ({ machineId, session }) => { ready.push(`${machineId}:${session.id}`); },
},
);
const selecting = controller.selectSession(oldSession, { updateUrl: false });
await Promise.resolve();
expect(ready).toEqual([]);
messages.resolve(page("ready", 1));
selectedStatus.resolve(status(oldSession.id));
await selecting;
expect(ready).toEqual([`local:${oldSession.id}`]);
});
it("shares same-turn requests and runs one trailing refresh requested during the active fetch", async () => {
const firstPage = deferred<MessagePage>();
const firstStatus = deferred<SessionStatus>();
@@ -45,12 +45,18 @@ export interface PromptEditorTextReplacement {
text: string;
}
export interface SelectedSessionReady {
machineId: string;
session: SessionInfo;
}
export interface SessionControllerDependencies {
api?: typeof defaultApi;
socket?: SessionEventSocket;
transcripts?: ChatTranscriptStore;
notifications?: SessionNotificationSessionBridge;
replacePromptEditorText?: (replacement: PromptEditorTextReplacement) => void | Promise<void>;
onSelectedSessionReady?: (selection: SelectedSessionReady) => void;
}
interface BulkSessionMutationResult {
@@ -95,6 +101,7 @@ export class SessionController {
private readonly transcripts: ChatTranscriptStore;
private readonly notifications: SessionNotificationSessionBridge | undefined;
private readonly replacePromptEditorText: SessionControllerDependencies["replacePromptEditorText"];
private readonly onSelectedSessionReady: SessionControllerDependencies["onSelectedSessionReady"];
private selectionSeq = 0;
private disposed = false;
// Join-time stream watermark for the selected session. `seq` is the
@@ -125,6 +132,7 @@ export class SessionController {
this.transcripts = deps.transcripts ?? new ChatTranscriptStore();
this.notifications = deps.notifications;
this.replacePromptEditorText = deps.replacePromptEditorText;
this.onSelectedSessionReady = deps.onSelectedSessionReady;
}
applyGlobalEvent(event: GlobalSessionEvent): void {
@@ -218,6 +226,7 @@ export class SessionController {
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page);
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
this.onSelectedSessionReady?.({ machineId, session });
if (options?.updateUrl !== false) this.updateUrl();
return;
}
@@ -235,6 +244,7 @@ export class SessionController {
void this.refreshAvailableThinkingLevels();
for (const event of socketBuffer) this.applyEvent(event);
this.socket.setHandler((event) => { this.applyEvent(event); });
this.onSelectedSessionReady?.({ machineId, session });
if (options?.updateUrl !== false) this.updateUrl();
} catch (error) {
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) {
+33
View File
@@ -57,6 +57,39 @@ describe("notification socket guards", () => {
})).toBeUndefined();
});
it("accepts only strictly validated global unread deltas", () => {
const unread = {
sessionId: "session-1",
cwd: "/repo",
completionOrder: 1,
completedAt: "2026-07-20T00:00:01.000Z",
};
expect(parseRealtimeSocketEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: unread.sessionId,
cwd: unread.cwd,
unread,
})).toMatchObject({ type: "sessions.unread", unread });
expect(parseRealtimeSocketEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "other-session",
cwd: unread.cwd,
unread,
})).toBeUndefined();
expect(parseRealtimeSocketEvent({
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 3.5,
sessionId: unread.sessionId,
cwd: unread.cwd,
unread: null,
})).toBeUndefined();
});
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
+6 -1
View File
@@ -1,5 +1,5 @@
import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionNotificationInboxEvent } from "./api/parsers";
import { parseSessionNotificationInboxEvent, parseSessionUnreadEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
@@ -159,6 +159,7 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
}
export function parseRealtimeSocketEvent(event: unknown): BrowserRealtimeEvent | undefined {
if (eventType(event) === "sessions.unread") return safelyParseValidatedEvent(() => parseSessionUnreadEvent(event));
if (isLegacyGlobalSessionEvent(event) || isLegacyRealtimeEvent(event)) return event;
return undefined;
}
@@ -178,6 +179,10 @@ function isLegacyRealtimeEvent(event: unknown): event is NonGlobalBrowserRealtim
}
function safelyParseNotificationEvent<T>(parse: () => T): T | undefined {
return safelyParseValidatedEvent(parse);
}
function safelyParseValidatedEvent<T>(parse: () => T): T | undefined {
try {
return parse();
} catch {
+353
View File
@@ -0,0 +1,353 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionRef, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary } from "../../shared/apiTypes";
import { SessionUnreadController, type SessionUnreadApi } from "./sessionUnread";
describe("SessionUnreadController", () => {
it("restores the durable server snapshot for a new browser controller", async () => {
const api = fakeApi({ snapshots: [snapshot("catalog-a", 2, [summary("session-2", 2), summary("session-1", 1)])] });
const controller = new SessionUnreadController({ api });
controller.setCapability("local", "supported");
await controller.refresh("local");
expect([...controller.unreadSessionIds("local", [ref("session-1"), ref("session-2")])]).toEqual(["session-1", "session-2"]);
expect(controller.projection("local")).toMatchObject({ status: "fresh", catalogId: "catalog-a", catalogRevision: 2 });
const restarted = new SessionUnreadController({
api: fakeApi({ snapshots: [snapshot("catalog-a", 2, [summary("session-2", 2), summary("session-1", 1)])] }),
});
restarted.setCapability("local", "supported");
await restarted.refresh("local");
expect(restarted.isUnread("local", ref("session-2"))).toBe(true);
});
it("refreshes the projection again on reconnect", async () => {
const api = fakeApi({
snapshots: [
snapshot("catalog-a", 1, [summary("session-1", 1)]),
snapshot("catalog-a", 2, [summary("session-2", 2), summary("session-1", 1)]),
],
});
const controller = new SessionUnreadController({ api });
controller.setCapability("local", "supported");
await controller.refresh("local");
await controller.refresh("local");
expect(controller.projection("local")).toMatchObject({ catalogRevision: 2 });
expect([...controller.unreadSessionIds("local", [ref("session-1"), ref("session-2")])]).toEqual(["session-1", "session-2"]);
});
it("replays a contiguous event that races the initial snapshot join and performs a trailing refresh", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn(() => response.promise);
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
controller.setCapability("local", "supported");
const refreshing = controller.refresh("local");
controller.applyEvent("local", unreadEvent("catalog-a", 2, summary("session-2", 2)));
response.resolve(snapshot("catalog-a", 1, [summary("session-1", 1)]));
await refreshing;
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary("session-2", 2), summary("session-1", 1)],
});
});
it("drains reconnect refreshes that overlap an active request", async () => {
const firstResponse = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn()
.mockImplementationOnce(() => firstResponse.promise)
.mockResolvedValueOnce(snapshot("catalog-a", 2, [summary("session-2", 2)]));
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
controller.setCapability("local", "supported");
const firstRefresh = controller.refresh("local");
const reconnectRefresh = controller.refresh("local");
expect(reconnectRefresh).toBe(firstRefresh);
firstResponse.resolve(snapshot("catalog-a", 1, [summary("session-1", 1)]));
await firstRefresh;
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary("session-2", 2)],
});
});
it("retries a queued gap refresh after the active snapshot request fails", async () => {
const firstResponse = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn()
.mockImplementationOnce(() => firstResponse.promise)
.mockResolvedValueOnce(snapshot("catalog-a", 3, [summary("session-3", 3), summary("session-1", 1)]));
const onBackgroundError = vi.fn();
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }), onBackgroundError });
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary("session-1", 1)));
const refreshing = controller.refresh("local");
controller.applyEvent("local", unreadEvent("catalog-a", 3, summary("session-3", 3)));
const error = new Error("disconnected");
firstResponse.reject(error);
await refreshing;
expect(onBackgroundError).toHaveBeenCalledWith("snapshot", "local", error);
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.projection("local")).toMatchObject({ status: "fresh", catalogRevision: 3 });
});
it("resnapshots revision gaps and replaces state on a new catalog epoch", async () => {
const unreadCatalog = vi.fn()
.mockResolvedValueOnce(snapshot("catalog-a", 3, [summary("session-3", 3), summary("session-1", 1)]))
.mockResolvedValueOnce(snapshot("catalog-b", 1, [summary("new-epoch", 1)]));
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary("session-1", 1)));
controller.applyEvent("local", unreadEvent("catalog-a", 3, summary("session-3", 3)));
await vi.waitFor(() => {
expect(controller.projection("local")).toMatchObject({ status: "fresh", catalogRevision: 3 });
});
expect(unreadCatalog).toHaveBeenCalledOnce();
controller.applyEvent("local", unreadEvent("catalog-b", 1, summary("new-epoch", 1)));
await vi.waitFor(() => {
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-b",
catalogRevision: 1,
sessions: [summary("new-epoch", 1)],
});
});
expect(unreadCatalog).toHaveBeenCalledTimes(2);
});
it("keeps a newer completion when an acknowledgement of the observed order is in flight", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const acknowledgeUnread = vi.fn(() => response.promise);
const controller = new SessionUnreadController({ api: fakeApi({ acknowledgeUnread }) });
const session = ref("session-1");
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary(session.id, 1)));
const acknowledging = controller.acknowledge("local", session);
controller.applyEvent("local", unreadEvent("catalog-a", 2, summary(session.id, 2)));
// Even a delayed response that only represents the old revision is merged
// with socket events observed while the request was pending.
response.resolve(snapshot("catalog-a", 1, []));
await acknowledging;
expect(acknowledgeUnread).toHaveBeenCalledWith(session, "catalog-a", 1, "local");
expect(controller.projection("local")).toEqual({
status: "fresh",
catalogId: "catalog-a",
catalogRevision: 2,
sessions: [summary(session.id, 2)],
});
});
it("does not let a delayed acknowledgement response regress an epoch installed by another request", async () => {
const acknowledgementResponse = deferred<SessionUnreadCatalogSnapshot>();
const unreadCatalog = vi.fn().mockResolvedValue(snapshot("catalog-b", 1, [summary("epoch-b", 1)]));
const acknowledgeUnread = vi.fn(() => acknowledgementResponse.promise);
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog, acknowledgeUnread }) });
const session = ref("session-1");
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary(session.id, 1)));
const acknowledging = controller.acknowledge("local", session);
await controller.refresh("local");
expect(controller.projection("local")).toMatchObject({ catalogId: "catalog-b", status: "fresh" });
acknowledgementResponse.resolve(snapshot("catalog-a", 2, []));
await acknowledging;
await vi.waitFor(() => {
expect(controller.projection("local")).toMatchObject({ catalogId: "catalog-b", status: "fresh" });
});
expect(unreadCatalog).toHaveBeenCalledTimes(2);
expect(controller.isUnread("local", ref("epoch-b"))).toBe(true);
});
it("deduplicates acknowledgements and converges another client from the authoritative clear event", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const acknowledgeUnread = vi.fn(() => response.promise);
const first = new SessionUnreadController({ api: fakeApi({ acknowledgeUnread }) });
const second = new SessionUnreadController({ api: fakeApi() });
const session = ref("session-1");
first.setCapability("local", "supported");
second.setCapability("local", "supported");
const completion = unreadEvent("catalog-a", 1, summary(session.id, 1));
first.applyEvent("local", completion);
second.applyEvent("local", completion);
const firstAttempt = first.acknowledge("local", session);
const duplicateAttempt = first.acknowledge("local", session);
expect(duplicateAttempt).toBe(firstAttempt);
expect(acknowledgeUnread).toHaveBeenCalledOnce();
response.resolve(snapshot("catalog-a", 2, []));
await firstAttempt;
second.applyEvent("local", unreadEvent("catalog-a", 2, null, session));
expect(first.isUnread("local", session)).toBe(false);
expect(second.isUnread("local", session)).toBe(false);
expect(acknowledgeUnread).toHaveBeenCalledOnce();
});
it("keeps canonical identities machine- and cwd-scoped and prunes removed machines", () => {
const controller = new SessionUnreadController({ api: fakeApi() });
controller.setCapability("machine-a", "supported");
controller.setCapability("machine-b", "supported");
controller.applyEvent("machine-a", unreadEvent("catalog-a", 1, summary("shared", 1, "/repo-a")));
controller.applyEvent("machine-b", unreadEvent("catalog-b", 1, summary("shared", 1, "/repo-b")));
expect(controller.isUnread("machine-a", ref("shared", "/repo-a"))).toBe(true);
expect(controller.isUnread("machine-a", ref("shared", "/repo-b"))).toBe(false);
expect(controller.isUnread("machine-b", ref("shared", "/repo-b"))).toBe(true);
controller.retainMachines(new Set(["machine-b"]));
expect(controller.projection("machine-a")).toBeUndefined();
expect(controller.isUnread("machine-b", ref("shared", "/repo-b"))).toBe(true);
});
it("invalidates an in-flight snapshot when its machine is removed", async () => {
const response = deferred<SessionUnreadCatalogSnapshot>();
const onChange = vi.fn();
const controller = new SessionUnreadController({
api: fakeApi({ unreadCatalog: () => response.promise }),
onChange,
});
controller.setCapability("local", "supported");
const refreshing = controller.refresh("local");
onChange.mockClear();
controller.retainMachines(new Set());
response.resolve(snapshot("catalog-a", 1, [summary("session-1", 1)]));
await refreshing;
expect(controller.projection("local")).toBeUndefined();
expect(onChange).not.toHaveBeenCalled();
});
it("ignores socket deltas and endpoints until joint support is known, then clears state on downgrade", async () => {
const unreadCatalog = vi.fn().mockResolvedValue(snapshot("catalog-a", 0, []));
const controller = new SessionUnreadController({ api: fakeApi({ unreadCatalog }) });
const completion = unreadEvent("catalog-a", 1, summary("session-1", 1));
controller.applyEvent("legacy", completion);
await controller.refresh("legacy");
controller.setCapability("legacy", "unsupported");
controller.applyEvent("legacy", completion);
await controller.refresh("legacy");
expect(unreadCatalog).not.toHaveBeenCalled();
expect(controller.projection("legacy")).toBeUndefined();
expect(controller.setCapability("legacy", "supported")).toBe(true);
await controller.refresh("legacy");
expect(unreadCatalog).toHaveBeenCalledOnce();
controller.applyEvent("legacy", completion);
controller.setCapability("legacy", "unsupported");
expect(controller.projection("legacy")).toBeUndefined();
expect(controller.unreadSessionIds("legacy", [ref("session-1")]).size).toBe(0);
});
it("preserves unread state when acknowledgement fails so a later visible check can retry", async () => {
const error = new Error("offline");
const onBackgroundError = vi.fn();
const acknowledgeUnread = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(snapshot("catalog-a", 2, []));
const controller = new SessionUnreadController({ api: fakeApi({ acknowledgeUnread }), onBackgroundError });
const session = ref("session-1");
controller.setCapability("local", "supported");
controller.applyEvent("local", unreadEvent("catalog-a", 1, summary(session.id, 1)));
await controller.acknowledge("local", session);
expect(controller.isUnread("local", session)).toBe(true);
expect(onBackgroundError).toHaveBeenCalledWith("acknowledge", "local", error);
await controller.acknowledge("local", session);
expect(controller.isUnread("local", session)).toBe(false);
expect(acknowledgeUnread).toHaveBeenCalledTimes(2);
});
});
interface FakeApiOptions {
snapshots?: SessionUnreadCatalogSnapshot[] | undefined;
unreadCatalog?: SessionUnreadApi["unreadCatalog"] | undefined;
acknowledgeUnread?: SessionUnreadApi["acknowledgeUnread"] | undefined;
}
function fakeApi(options: FakeApiOptions = {}): SessionUnreadApi {
const snapshots = [...(options.snapshots ?? [])];
return {
unreadCatalog: options.unreadCatalog ?? (() => {
const next = snapshots.shift();
return next === undefined
? Promise.reject(new Error("Unexpected unread snapshot request"))
: Promise.resolve(next);
}),
acknowledgeUnread: options.acknowledgeUnread ?? (() => (
Promise.reject(new Error("Unexpected unread acknowledgement"))
)),
};
}
function snapshot(catalogId: string, catalogRevision: number, sessions: SessionUnreadSummary[]): SessionUnreadCatalogSnapshot {
return { catalogId, catalogRevision, sessions };
}
function summary(sessionId: string, completionOrder: number, cwd = "/repo"): SessionUnreadSummary {
return {
sessionId,
cwd,
completionOrder,
completedAt: `2026-07-20T00:00:${String(completionOrder).padStart(2, "0")}.000Z`,
};
}
function unreadEvent(
catalogId: string,
catalogRevision: number,
unread: SessionUnreadSummary | null,
identity: SessionRef = unread === null ? ref("session-1") : { id: unread.sessionId, cwd: unread.cwd },
): SessionUnreadEvent {
return {
type: "sessions.unread",
catalogId,
catalogRevision,
sessionId: identity.id,
cwd: identity.cwd,
unread,
};
}
function ref(id: string, cwd = "/repo"): SessionRef {
return { id, cwd };
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void; reject(error: unknown): void } {
let resolvePromise: ((value: T) => void) | undefined;
let rejectPromise: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return {
promise,
resolve: (value) => {
if (resolvePromise === undefined) throw new Error("Deferred promise is unavailable");
resolvePromise(value);
},
reject: (error) => {
if (rejectPromise === undefined) throw new Error("Deferred promise is unavailable");
rejectPromise(error);
},
};
}
+479
View File
@@ -0,0 +1,479 @@
import { sessionsApi } from "./api";
import { SESSION_UNREAD_LIMIT, type SessionRef, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary } from "../../shared/apiTypes";
const EMPTY_SESSION_IDS: ReadonlySet<string> = new Set();
const MAX_BUFFERED_NETWORK_EVENTS = SESSION_UNREAD_LIMIT + 1;
export type SessionUnreadCapabilityState = "unknown" | "supported" | "unsupported";
export type SessionUnreadProjectionStatus = "loading" | "fresh" | "stale";
export interface SessionUnreadApi {
unreadCatalog(machineId: string): Promise<SessionUnreadCatalogSnapshot>;
acknowledgeUnread(session: SessionRef, catalogId: string, throughCompletionOrder: number, machineId: string): Promise<SessionUnreadCatalogSnapshot>;
}
export interface SessionUnreadControllerOptions {
api?: SessionUnreadApi | undefined;
onChange?: ((machineId: string) => void) | undefined;
onBackgroundError?: ((operation: "snapshot" | "acknowledge", machineId: string, error: unknown) => void) | undefined;
}
export interface SessionUnreadProjectionView extends SessionUnreadCatalogSnapshot {
status: SessionUnreadProjectionStatus;
}
interface ProjectionData {
catalogId: string;
catalogRevision: number;
summariesByIdentity: Map<string, SessionUnreadSummary>;
}
interface NetworkObserver {
generation: number;
projectionVersion: number;
projectionStatus: SessionUnreadProjectionStatus;
events: SessionUnreadEvent[];
overflowed: boolean;
}
interface MachineUnreadState {
readonly machineId: string;
capability: SessionUnreadCapabilityState;
status: SessionUnreadProjectionStatus;
projection: ProjectionData | undefined;
projectionVersion: number;
generation: number;
readonly observers: Set<NetworkObserver>;
readonly acknowledgements: Map<string, Promise<void>>;
refreshPromise: Promise<void> | undefined;
refreshQueued: boolean;
}
interface ProjectionTransition {
projection: ProjectionData;
status: SessionUnreadProjectionStatus;
requiresRefresh: boolean;
}
const defaultApi: SessionUnreadApi = {
unreadCatalog: (machineId) => sessionsApi.unreadCatalog(machineId),
acknowledgeUnread: (session, catalogId, throughCompletionOrder, machineId) => (
sessionsApi.acknowledgeUnread(session, catalogId, throughCompletionOrder, machineId)
),
};
/**
* Per-machine browser projection of the daemon-owned unread catalog.
*
* HTTP snapshots establish join state, while contiguous socket revisions keep
* it current. Network responses observe socket events that race their request;
* same-epoch events are replayed and ambiguous epoch/gap races force a trailing
* snapshot. Read acknowledgements always carry the exact epoch and completion
* order shown to the user, so the daemon can reject stale clears.
*/
export class SessionUnreadController {
private readonly api: SessionUnreadApi;
private readonly onChange: (machineId: string) => void;
private readonly onBackgroundError: (operation: "snapshot" | "acknowledge", machineId: string, error: unknown) => void;
private readonly machines = new Map<string, MachineUnreadState>();
constructor(options: SessionUnreadControllerOptions = {}) {
this.api = options.api ?? defaultApi;
this.onChange = options.onChange ?? (() => undefined);
this.onBackgroundError = options.onBackgroundError ?? (() => undefined);
}
/** Remove projections and invalidate pending responses for deleted machines. */
retainMachines(machineIds: ReadonlySet<string>): void {
for (const [machineId, state] of this.machines) {
if (machineIds.has(machineId)) continue;
const changed = state.projection !== undefined;
state.generation += 1;
state.observers.clear();
state.acknowledgements.clear();
this.machines.delete(machineId);
if (changed) this.onChange(machineId);
}
}
/** Returns true when capability discovery newly makes a snapshot eligible. */
setCapability(machineId: string, capability: SessionUnreadCapabilityState): boolean {
const state = this.machine(machineId);
const previous = state.capability;
if (previous === capability) return false;
state.capability = capability;
if (capability === "unsupported") {
const changed = state.projection !== undefined;
state.generation += 1;
state.projection = undefined;
if (changed) state.projectionVersion += 1;
state.status = "stale";
state.refreshPromise = undefined;
state.refreshQueued = false;
state.observers.clear();
state.acknowledgements.clear();
if (changed) this.onChange(machineId);
return false;
}
if (capability === "unknown") {
if (state.projection !== undefined && state.status !== "stale") {
state.status = "stale";
this.onChange(machineId);
}
return false;
}
return previous !== "supported";
}
capability(machineId: string): SessionUnreadCapabilityState {
return this.machines.get(machineId)?.capability ?? "unknown";
}
projection(machineId: string): SessionUnreadProjectionView | undefined {
const state = this.machines.get(machineId);
const projection = state?.projection;
if (state === undefined || projection === undefined) return undefined;
return {
status: state.status,
catalogId: projection.catalogId,
catalogRevision: projection.catalogRevision,
sessions: [...projection.summariesByIdentity.values()]
.sort((left, right) => right.completionOrder - left.completionOrder)
.map((summary) => ({ ...summary })),
};
}
unreadSessionIds(machineId: string, sessions: readonly SessionRef[]): ReadonlySet<string> {
const projection = this.machines.get(machineId)?.projection;
if (projection === undefined || sessions.length === 0) return EMPTY_SESSION_IDS;
const unread = new Set<string>();
for (const session of sessions) {
if (projection.summariesByIdentity.has(sessionIdentityKey(session))) unread.add(session.id);
}
return unread.size === 0 ? EMPTY_SESSION_IDS : unread;
}
isUnread(machineId: string, session: SessionRef): boolean {
return this.machines.get(machineId)?.projection?.summariesByIdentity.has(sessionIdentityKey(session)) === true;
}
applyEvent(machineId: string, event: SessionUnreadEvent): void {
const state = this.machine(machineId);
// Events alone do not prove that the web/API hop exposes the matching HTTP
// routes (for example, a new sessiond behind an older federated web host).
// Only jointly negotiated support may activate this projection.
if (state.capability !== "supported") return;
this.recordObservedEvent(state, event);
const transition = applyUnreadEvent(state.projection, state.status, event);
const changed = this.installProjection(state, transition.projection, transition.status);
if (changed) this.onChange(machineId);
if (transition.requiresRefresh) {
if (state.refreshPromise === undefined) void this.refresh(machineId);
else state.refreshQueued = true;
}
}
refresh(machineId: string): Promise<void> {
const state = this.machine(machineId);
if (state.capability !== "supported") return Promise.resolve();
if (state.refreshPromise !== undefined) {
state.refreshQueued = true;
return state.refreshPromise;
}
const generation = state.generation;
const refreshPromise = this.runRefreshLoop(state, generation);
state.refreshPromise = refreshPromise;
void refreshPromise.finally(() => {
if (!this.isCurrent(state, generation) || state.refreshPromise !== refreshPromise) return;
state.refreshPromise = undefined;
const refreshAgain = state.refreshQueued && state.capability === "supported";
state.refreshQueued = false;
if (refreshAgain) void this.refresh(state.machineId);
});
return refreshPromise;
}
async refreshAll(): Promise<void> {
await Promise.all([...this.machines.values()]
.filter((state) => state.capability === "supported")
.map(async (state) => { await this.refresh(state.machineId); }));
}
acknowledge(machineId: string, session: SessionRef): Promise<void> {
const state = this.machines.get(machineId);
const projection = state?.projection;
if (state?.capability !== "supported" || projection === undefined) return Promise.resolve();
const summary = projection.summariesByIdentity.get(sessionIdentityKey(session));
if (summary === undefined) return Promise.resolve();
const key = acknowledgementKey(projection.catalogId, summary);
const pending = state.acknowledgements.get(key);
if (pending !== undefined) return pending;
const generation = state.generation;
const acknowledgement = this.runAcknowledgement(state, generation, session, projection.catalogId, summary.completionOrder);
state.acknowledgements.set(key, acknowledgement);
void acknowledgement.finally(() => {
if (state.acknowledgements.get(key) === acknowledgement) state.acknowledgements.delete(key);
});
return acknowledgement;
}
private async runRefreshLoop(state: MachineUnreadState, generation: number): Promise<void> {
do {
state.refreshQueued = false;
if (!this.isCurrentSupported(state, generation)) return;
const projectionStatus = state.status;
const changed = this.markRefreshStarted(state);
if (changed) this.onChange(state.machineId);
const observer = this.beginNetworkObservation(state, generation, projectionStatus);
try {
const snapshot = await this.api.unreadCatalog(state.machineId);
if (!this.isCurrentSupported(state, generation)) return;
const requiresRefresh = this.applyNetworkSnapshot(state, snapshot, observer);
if (requiresRefresh) state.refreshQueued = true;
} catch (error: unknown) {
if (this.isCurrent(state, generation)) {
const becameStale = state.status !== "stale";
state.status = "stale";
if (becameStale) this.onChange(state.machineId);
this.reportError("snapshot", state.machineId, error);
}
} finally {
state.observers.delete(observer);
}
} while (state.refreshQueued && this.isCurrentSupported(state, generation));
}
private async runAcknowledgement(
state: MachineUnreadState,
generation: number,
session: SessionRef,
catalogId: string,
throughCompletionOrder: number,
): Promise<void> {
const observer = this.beginNetworkObservation(state, generation);
try {
const snapshot = await this.api.acknowledgeUnread(
session,
catalogId,
throughCompletionOrder,
state.machineId,
);
if (!this.isCurrentSupported(state, generation)) return;
if (this.applyNetworkSnapshot(state, snapshot, observer)) void this.refresh(state.machineId);
} catch (error: unknown) {
if (this.isCurrent(state, generation)) this.reportError("acknowledge", state.machineId, error);
} finally {
state.observers.delete(observer);
}
}
private applyNetworkSnapshot(
state: MachineUnreadState,
snapshot: SessionUnreadCatalogSnapshot,
observer: NetworkObserver,
): boolean {
const current = state.projection;
const ambiguousConcurrentEpoch = state.projectionVersion !== observer.projectionVersion
&& current !== undefined
&& current.catalogId !== snapshot.catalogId;
if (ambiguousConcurrentEpoch
|| observer.overflowed
|| observer.events.some((event) => event.catalogId !== snapshot.catalogId)) {
const changed = state.status !== "stale";
state.status = "stale";
if (changed) this.onChange(state.machineId);
return true;
}
let candidate = projectionFromSnapshot(snapshot);
let candidateStatus: SessionUnreadProjectionStatus = "fresh";
let requiresRefresh = false;
for (const event of observer.events) {
const transition = applyUnreadEvent(candidate, candidateStatus, event);
candidate = transition.projection;
candidateStatus = transition.status;
requiresRefresh ||= transition.requiresRefresh;
}
if (current?.catalogId === candidate.catalogId
&& current.catalogRevision > candidate.catalogRevision) {
if (state.projectionVersion === observer.projectionVersion && observer.projectionStatus === "fresh") {
const changed = state.status !== "fresh";
state.status = "fresh";
if (changed) this.onChange(state.machineId);
}
requiresRefresh ||= state.status === "stale";
} else {
const changed = this.installProjection(state, candidate, candidateStatus);
if (changed) this.onChange(state.machineId);
}
return requiresRefresh;
}
private beginNetworkObservation(
state: MachineUnreadState,
generation: number,
projectionStatus = state.status,
): NetworkObserver {
const observer: NetworkObserver = {
generation,
projectionVersion: state.projectionVersion,
projectionStatus,
events: [],
overflowed: false,
};
state.observers.add(observer);
return observer;
}
private recordObservedEvent(state: MachineUnreadState, event: SessionUnreadEvent): void {
for (const observer of state.observers) {
if (observer.generation !== state.generation || observer.overflowed) continue;
if (observer.events.length >= MAX_BUFFERED_NETWORK_EVENTS) {
observer.events.length = 0;
observer.overflowed = true;
} else {
observer.events.push(event);
}
}
}
private markRefreshStarted(state: MachineUnreadState): boolean {
const status: SessionUnreadProjectionStatus = state.projection === undefined ? "loading" : "stale";
if (state.status === status) return false;
state.status = status;
return true;
}
private installProjection(
state: MachineUnreadState,
projection: ProjectionData,
status: SessionUnreadProjectionStatus,
): boolean {
const projectionChanged = !projectionsEqual(state.projection, projection);
if (state.status === status && !projectionChanged) return false;
if (projectionChanged) {
state.projection = projection;
state.projectionVersion += 1;
}
state.status = status;
return true;
}
private machine(machineId: string): MachineUnreadState {
const existing = this.machines.get(machineId);
if (existing !== undefined) return existing;
const state: MachineUnreadState = {
machineId,
capability: "unknown",
status: "stale",
projection: undefined,
projectionVersion: 0,
generation: 0,
observers: new Set(),
acknowledgements: new Map(),
refreshPromise: undefined,
refreshQueued: false,
};
this.machines.set(machineId, state);
return state;
}
private isCurrent(state: MachineUnreadState, generation: number): boolean {
return this.machines.get(state.machineId) === state && state.generation === generation;
}
private isCurrentSupported(state: MachineUnreadState, generation: number): boolean {
return this.isCurrent(state, generation) && state.capability === "supported";
}
private reportError(operation: "snapshot" | "acknowledge", machineId: string, error: unknown): void {
this.onBackgroundError(operation, machineId, error);
}
}
function applyUnreadEvent(
projection: ProjectionData | undefined,
status: SessionUnreadProjectionStatus,
event: SessionUnreadEvent,
): ProjectionTransition {
if (projection === undefined) {
const empty = emptyProjection(event.catalogId);
if (event.catalogRevision !== 1) return { projection: empty, status: "stale", requiresRefresh: true };
return applyContiguousEvent(empty, "fresh", event);
}
if (projection.catalogId !== event.catalogId) {
const empty = emptyProjection(event.catalogId);
if (event.catalogRevision !== 1) return { projection: empty, status: "stale", requiresRefresh: true };
return applyContiguousEvent(empty, "stale", event);
}
if (event.catalogRevision <= projection.catalogRevision) {
return { projection, status, requiresRefresh: status === "stale" };
}
if (event.catalogRevision !== projection.catalogRevision + 1) {
return { projection, status: "stale", requiresRefresh: true };
}
return applyContiguousEvent(projection, status, event);
}
function applyContiguousEvent(
projection: ProjectionData,
status: SessionUnreadProjectionStatus,
event: SessionUnreadEvent,
): ProjectionTransition {
const summariesByIdentity = new Map(projection.summariesByIdentity);
const key = sessionIdentityKey(event);
if (event.unread === null) summariesByIdentity.delete(key);
else summariesByIdentity.set(key, { ...event.unread });
const nextStatus: SessionUnreadProjectionStatus = status === "stale" ? "stale" : "fresh";
return {
projection: {
catalogId: event.catalogId,
catalogRevision: event.catalogRevision,
summariesByIdentity,
},
status: nextStatus,
requiresRefresh: nextStatus === "stale",
};
}
function projectionFromSnapshot(snapshot: SessionUnreadCatalogSnapshot): ProjectionData {
return {
catalogId: snapshot.catalogId,
catalogRevision: snapshot.catalogRevision,
summariesByIdentity: new Map(snapshot.sessions.map((summary) => [sessionIdentityKey(summary), { ...summary }])),
};
}
function emptyProjection(catalogId: string): ProjectionData {
return { catalogId, catalogRevision: 0, summariesByIdentity: new Map() };
}
function projectionsEqual(left: ProjectionData | undefined, right: ProjectionData): boolean {
if (left?.catalogId !== right.catalogId || left.catalogRevision !== right.catalogRevision) return false;
if (left.summariesByIdentity.size !== right.summariesByIdentity.size) return false;
for (const [key, summary] of left.summariesByIdentity) {
const candidate = right.summariesByIdentity.get(key);
if (candidate?.completionOrder !== summary.completionOrder
|| candidate.completedAt !== summary.completedAt) return false;
}
return true;
}
function sessionIdentityKey(session: Pick<SessionRef, "id" | "cwd"> | Pick<SessionUnreadSummary, "sessionId" | "cwd"> | Pick<SessionUnreadEvent, "sessionId" | "cwd">): string {
const sessionId = "id" in session ? session.id : session.sessionId;
return JSON.stringify([sessionId, session.cwd]);
}
function acknowledgementKey(catalogId: string, summary: SessionUnreadSummary): string {
return JSON.stringify([catalogId, summary.sessionId, summary.cwd, summary.completionOrder]);
}
@@ -133,6 +133,27 @@ describe("SessionEventHub", () => {
expect(sessionSocket.send).not.toHaveBeenCalled();
});
it("publishes authoritative unread deltas only to global sockets", () => {
const hub = new SessionEventHub();
const globalSocket = new FakeSocket();
const sessionSocket = new FakeSocket();
hub.addGlobal(globalSocket);
hub.add("s1", sessionSocket);
const event = {
type: "sessions.unread" as const,
catalogId: "catalog-test",
catalogRevision: 3,
sessionId: "s1",
cwd: "/workspace",
unread: { sessionId: "s1", cwd: "/workspace", completionOrder: 2, completedAt: "2026-07-20T00:00:00.000Z" },
};
hub.publishGlobal(event);
expect(globalSocket.send).toHaveBeenCalledWith(JSON.stringify(event));
expect(sessionSocket.send).not.toHaveBeenCalled();
});
it("publishes notification summaries only to global sockets", () => {
const hub = new SessionEventHub();
const globalSocket = new FakeSocket();
+24 -6
View File
@@ -12,6 +12,7 @@ import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { SessionNotificationStore } from "./sessions/sessionNotificationStore.js";
import { FileSessionUnreadPersistence, SessionUnreadStore } from "./sessions/sessionUnreadStore.js";
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
@@ -40,6 +41,13 @@ await runSessionDaemonStartup({
async createRuntime() {
const eventHub = new SessionEventHub();
const notificationStore = new SessionNotificationStore();
const unreadStore = new SessionUnreadStore({
persistence: new FileSessionUnreadPersistence(),
onPersistenceError(operation, error) {
app.log.error({ err: error, operation }, "session unread persistence failed");
},
});
await unreadStore.load();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
const spawnTargets = config.spawnSessions
@@ -53,6 +61,7 @@ await runSessionDaemonStartup({
...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
notificationStore,
unreadStore,
sessionManager: createPiSessionManagerGateway({
agentDir: activeAgentProfile.dir,
env: daemonEnvironment,
@@ -65,7 +74,7 @@ await runSessionDaemonStartup({
...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
activeAgentProfile,
});
return { eventHub, workspaceActivity, auth, sessions, terminals, activeAgentProfile, runtimeComponent };
return { eventHub, workspaceActivity, auth, sessions, terminals, unreadStore, activeAgentProfile, runtimeComponent };
},
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }) {
registerWorkspaceActivityRoutes(app, workspaceActivity);
@@ -88,16 +97,25 @@ await runSessionDaemonStartup({
app.get("/runtime", () => runtimeComponent);
},
async listen({ auth, sessions, terminals }) {
async listen({ auth, sessions, terminals, unreadStore }) {
let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
app.log.info({ signal }, "shutting down session daemon");
terminals.dispose();
auth.dispose();
await sessions.dispose();
await app.close();
const attempt = async (operation: string, run: () => void | Promise<void>): Promise<void> => {
try {
await run();
} catch (error: unknown) {
process.exitCode = 1;
app.log.error({ err: error, operation }, "session daemon shutdown operation failed");
}
};
await attempt("dispose terminals", () => { terminals.dispose(); });
await attempt("dispose auth", () => { auth.dispose(); });
await attempt("dispose sessions", () => sessions.dispose());
await attempt("flush session unread state", () => unreadStore.flush());
await attempt("close server", () => app.close());
}
process.once("SIGINT", (signal) => { void shutdown(signal); });
@@ -39,6 +39,21 @@ describe("machine-scoped session proxy routes", () => {
expect(daemon.requests).toEqual([{ method: "POST", path: "/sessions/session-1/queue/clear", body: { cwd: "/repo" } }]);
});
it("forwards unread snapshots and acknowledgement cutoffs unchanged", async () => {
const catalog = await app.inject({ method: "GET", url: "/api/machines/local/sessions/unread" });
const acknowledge = await app.inject({
method: "POST",
url: "/api/machines/local/sessions/session-1/unread/acknowledge",
payload: { cwd: "/repo", catalogId: "catalog-test", throughCompletionOrder: 9 },
});
expect([catalog.statusCode, acknowledge.statusCode]).toEqual([200, 200]);
expect(daemon.requests).toEqual([
{ method: "GET", path: "/sessions/unread", body: undefined },
{ method: "POST", path: "/sessions/session-1/unread/acknowledge", body: { cwd: "/repo", catalogId: "catalog-test", throughCompletionOrder: 9 } },
]);
});
it("forwards notification snapshots and dismissal bodies unchanged", async () => {
const catalog = await app.inject({ method: "GET", url: "/api/machines/local/sessions/notifications" });
const inbox = await app.inject({ method: "GET", url: `/api/machines/local/sessions/session-1/notifications?cwd=${encodeURIComponent("/repo")}` });
+253 -21
View File
@@ -33,7 +33,7 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
import type {
SavedPromptAttachment,
SessionBulkArchiveResponse,
@@ -45,6 +45,8 @@ import type {
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
SessionUnreadAcknowledgeRequest,
SessionUnreadCatalogSnapshot,
SessionWarning,
} from "../../shared/apiTypes.js";
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
@@ -63,6 +65,7 @@ import {
type SessionNotificationMutation,
} from "./sessionNotificationStore.js";
import { plainTextTheme } from "./plainTextTheme.js";
import { SessionUnreadStore, type SessionUnreadMutation } from "./sessionUnreadStore.js";
/**
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
@@ -74,6 +77,9 @@ export interface PiSessionLogger {
}
const noopLogger: PiSessionLogger = { info() { /* no-op */ } };
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
function noop(): void {
// Intentionally empty default unsubscribe callback.
@@ -648,6 +654,10 @@ export interface PiSessionServiceDependencies {
now?: () => Date;
/** Daemon-lifetime notification state, injected by sessiond in production. */
notificationStore?: SessionNotificationStore;
/** Durable daemon-owned unread state; defaults to an in-memory store in tests. */
unreadStore?: SessionUnreadStore;
/** Initial retry delay for durable unread publication failures. */
unreadPublicationRetryDelayMs?: number;
}
export class PiSessionService implements SessionRouteService {
@@ -694,6 +704,15 @@ export class PiSessionService implements SessionRouteService {
private readonly now: () => Date;
private readonly notificationStore: SessionNotificationStore;
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
private readonly unreadStore: SessionUnreadStore;
private readonly unreadPublicationRetryInitialMs: number;
private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
private unreadPublication: Promise<void> | undefined;
private unreadPublicationFailure: unknown;
private unreadPublicationFlushRequested = false;
private unreadPublicationRetryTimer: NodeJS.Timeout | undefined;
private unreadPublicationRetryDelayMs: number;
private unreadPublicationStopped = false;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
@@ -704,6 +723,12 @@ export class PiSessionService implements SessionRouteService {
this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
this.unreadPublicationRetryInitialMs = Math.max(
0,
deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS,
);
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
// Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -761,6 +786,20 @@ export class PiSessionService implements SessionRouteService {
return this.notificationStore.catalogSnapshot();
}
async unreadCatalog(): Promise<SessionUnreadCatalogSnapshot> {
await this.publishUnreadMutations([]);
return this.unreadStore.durableCatalogSnapshot();
}
async acknowledgeUnread(sessionId: string, request: SessionUnreadAcknowledgeRequest): Promise<SessionUnreadCatalogSnapshot> {
const result = this.unreadStore.acknowledge(sessionId, {
...request,
cwd: canonicalizeStoredCwd(request.cwd),
});
await this.publishUnreadMutations(result.mutations);
return this.unreadStore.durableCatalogSnapshot();
}
notificationInbox(ref: PiSessionRef): SessionNotificationInboxSnapshot {
return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
}
@@ -818,6 +857,7 @@ export class PiSessionService implements SessionRouteService {
}
await this.archiveStoreArchiveMany(readyArchiveInputs);
archiveInputs.push(...readyArchiveInputs);
await this.forgetUnreadSessions(readyArchiveInputs);
for (const record of plan.deleteRecords) {
if (this.activeSessionHasWork(record.sessionId)) {
@@ -830,6 +870,7 @@ export class PiSessionService implements SessionRouteService {
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
await this.forgetUnreadSessions(deleteRecords);
return summarizeSessionCleanupExecution({
archiveInputs,
@@ -841,11 +882,14 @@ export class PiSessionService implements SessionRouteService {
}
async dispose(): Promise<void> {
this.unreadPublicationStopped = true;
this.clearUnreadPublicationRetry();
clearInterval(this.heartbeat);
this.clearCompactionDrainTimers();
const pendingOpens = this.pendingSessionOpenPromises();
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const activeSessions = Array.from(new Set(this.active.values()));
for (const active of activeSessions) this.forgetUnreadActivity(active.runtime.session);
this.active.clear();
this.pendingSessionOpens.clear();
this.activities.clear();
@@ -867,6 +911,7 @@ export class PiSessionService implements SessionRouteService {
await active.runtime.dispose();
}
}));
await this.publishUnreadMutations([]);
}
async list(cwd: string): Promise<ClientSession[]> {
@@ -882,7 +927,9 @@ export class PiSessionService implements SessionRouteService {
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
}
const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById));
const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
const archivedSessions = archivedForCwd
.sort(compareArchivedRecords)
.map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
@@ -964,7 +1011,7 @@ export class PiSessionService implements SessionRouteService {
...(parentSessionFile === undefined ? {} : { parentSessionFile }),
cwd: decision.cwd,
};
this.registerVerifiedSubsession(link);
await this.registerVerifiedSubsession(link);
this.persistSubsessionLink(link);
this.persistSubsessionChildMarker(input.parentSessionId, created.id);
await this.prompt(created.id, input.prompt);
@@ -1046,7 +1093,7 @@ export class PiSessionService implements SessionRouteService {
return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
}
private registerVerifiedSubsession(link: TrackedSubsessionLink): void {
private async registerVerifiedSubsession(link: TrackedSubsessionLink): Promise<void> {
const { childSessionId, parentSessionId } = link;
const previousParentId = this.subsessionParents.get(childSessionId);
if (previousParentId !== undefined && previousParentId !== parentSessionId) {
@@ -1062,6 +1109,25 @@ export class PiSessionService implements SessionRouteService {
this.subsessionLinks.set(childSessionId, link);
if (!this.subsessionNotifyArmed.has(childSessionId)) this.subsessionNotifyArmed.set(childSessionId, false);
const cwd = this.cwdForVerifiedSubsession(link);
await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
}
private cwdForVerifiedSubsession(link: TrackedSubsessionLink): string {
const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
if (linkedCwd !== undefined) return canonicalizeStoredCwd(linkedCwd);
const childSessionFile = link.childSessionFile;
if (childSessionFile !== undefined) {
try {
return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
} catch (error: unknown) {
throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
}
}
throw new Error("Could not resolve cwd for verified tracked sub-session");
}
private unregisterSubsession(childSessionId: string): void {
@@ -1110,39 +1176,45 @@ export class PiSessionService implements SessionRouteService {
const activeParent = this.active.get(parentSessionId);
if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
this.subsessionHydratedParents.add(hydrationKey);
const complete = await this.registerPersistedSubsessionLinks(
parentSessionId,
activeParent.runtime.session.sessionManager,
activeParentFile,
);
if (complete) this.subsessionHydratedParents.add(hydrationKey);
return;
}
if (parentSessionFile === undefined) return;
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
this.subsessionHydratedParents.add(hydrationKey);
return;
}
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) return;
let parentManager: PiSessionManager;
try {
parentManager = this.sessionManager.open(parentSessionFile);
} catch {
this.subsessionHydratedParents.add(hydrationKey);
return;
}
await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
this.subsessionHydratedParents.add(hydrationKey);
const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
if (complete) this.subsessionHydratedParents.add(hydrationKey);
}
private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise<void> {
private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise<boolean> {
// Parent custom links are the authoritative recovery record: verify the
// exact live child file/header before tracking.
// exact live child file/header before tracking. Do not negatively cache a
// scan while a candidate child is temporarily unavailable.
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
let complete = true;
for (const entry of entries) {
const link = parsePersistedParentSubsessionLink(entry);
if (link === undefined) continue;
if (link?.spawnedBySessionId !== parentSessionId) continue;
const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
if (verified === undefined) continue;
this.registerVerifiedSubsession(verified);
if (verified === undefined) {
complete = false;
continue;
}
await this.registerVerifiedSubsession(verified);
}
return complete;
}
private async verifiedSubsessionLinkFromParentLink(parentSessionId: string, parentSessionFile: string | undefined, link: PersistedParentSubsessionLink): Promise<TrackedSubsessionLink | undefined> {
@@ -1160,7 +1232,7 @@ export class PiSessionService implements SessionRouteService {
private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise<void> {
const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
if (link === undefined) return;
this.registerVerifiedSubsession(link);
await this.registerVerifiedSubsession(link);
}
private verifiedSubsessionLinkFromOpenedChild(session: PiAgentSession): Promise<TrackedSubsessionLink | undefined> {
@@ -1611,6 +1683,7 @@ export class PiSessionService implements SessionRouteService {
const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStore.archive(archiveInput);
await this.forgetUnreadSessions([archiveInput]);
},
);
}
@@ -1623,6 +1696,7 @@ export class PiSessionService implements SessionRouteService {
]);
const failures: SessionBulkFailure[] = [];
const alreadyArchivedSessionIds: string[] = [];
const unreadArchivedIdentities: { sessionId: string; cwd: string }[] = [];
const planItems: BulkArchivePlanItem[] = [];
for (const ref of uniqueRefs) {
@@ -1630,6 +1704,7 @@ export class PiSessionService implements SessionRouteService {
if (archived !== undefined) {
this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
alreadyArchivedSessionIds.push(archived.sessionId);
unreadArchivedIdentities.push(archived);
continue;
}
@@ -1685,11 +1760,13 @@ export class PiSessionService implements SessionRouteService {
try {
const archived = await this.archiveStoreArchiveMany(readyInputs);
archivedSessionIds.push(...archived.map((record) => record.sessionId));
unreadArchivedIdentities.push(...archived);
} catch (error: unknown) {
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
}
},
);
await this.forgetUnreadSessions(unreadArchivedIdentities);
return {
archived: true,
@@ -1722,6 +1799,7 @@ export class PiSessionService implements SessionRouteService {
await this.archiveStoreArchiveMany(archiveInputs);
},
);
await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
return {
archived: true,
@@ -1736,6 +1814,7 @@ export class PiSessionService implements SessionRouteService {
if (archived === undefined) throw new Error("Session not found");
await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
await this.archiveStore.restore(archived.sessionId);
await this.forgetUnreadSessions([archived]);
}
async deleteArchived(ref: PiSessionLookup): Promise<void> {
@@ -1746,6 +1825,7 @@ export class PiSessionService implements SessionRouteService {
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
await this.archiveStore.deleteArchived(record.sessionId);
await this.forgetUnreadSessions([record]);
}
async deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
@@ -1794,6 +1874,8 @@ export class PiSessionService implements SessionRouteService {
} catch (error: unknown) {
for (const sessionId of deleteIds) failures.push({ sessionId, error: errorMessage(error) });
}
const deletedIdSet = new Set(deletedSessionIds);
await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
return {
deleted: true,
@@ -1846,6 +1928,7 @@ export class PiSessionService implements SessionRouteService {
await clearParentSession(sessionFile);
clearParentSessionHeader(session.sessionManager);
this.unregisterSubsession(session.sessionId);
await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
}
async clearQueue(ref: PiSessionLookup): Promise<ClientSessionStatus> {
@@ -2090,6 +2173,7 @@ export class PiSessionService implements SessionRouteService {
this.publishNotificationMutations(mutations);
}
if (!active) return;
this.forgetUnreadActivity(active.runtime.session);
this.active.delete(sessionId);
this.activities.delete(sessionId);
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
@@ -2226,6 +2310,7 @@ export class PiSessionService implements SessionRouteService {
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
});
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
let boundSession = runtime.session;
let notificationGeneration = options.notificationGeneration;
let notificationOwnership: "disabled" | "external" | "registered" | "replacement" = options.notifications === "disabled"
? "disabled"
@@ -2254,19 +2339,29 @@ export class PiSessionService implements SessionRouteService {
if (notificationGeneration !== undefined) this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
try {
if (options.creationProvenance === "tracked-subsession") {
await this.publishUnreadMutations(this.unreadStore.excludeSession(
runtime.session.sessionId,
canonicalizeStoredCwd(runtime.session.sessionManager.getCwd()),
));
} else {
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
}
await this.bindSessionExtensions(runtime.session, notificationGeneration);
this.bindRuntime(active);
runtime.setRebindSession(async (session) => {
const priorGeneration = notificationGeneration;
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await this.prepareUnreadRuntimeRebind(boundSession, session);
await this.recoverSubsessionTrackingForOpenedSession(session);
if (priorGeneration !== undefined) {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
this.notificationGenerationBySession.set(session, candidateGeneration);
}
this.bindRuntime(active, session);
boundSession = session;
await this.bindSessionExtensions(session, candidateGeneration);
await this.recoverSubsessionTrackingForOpenedSession(session);
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
notificationGeneration = candidateGeneration;
@@ -2281,7 +2376,6 @@ export class PiSessionService implements SessionRouteService {
}
});
this.active.set(runtime.session.sessionId, active);
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
notificationOwnership = "external";
@@ -2297,6 +2391,7 @@ export class PiSessionService implements SessionRouteService {
}
}
active.unsubscribe();
this.forgetUnreadActivity(boundSession);
let removedActive = false;
for (const [sessionId, candidate] of this.active.entries()) {
if (candidate !== active) continue;
@@ -2382,6 +2477,133 @@ export class PiSessionService implements SessionRouteService {
}
}
private async prepareUnreadRuntimeRebind(previous: PiAgentSession, next: PiAgentSession): Promise<void> {
const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd)) return;
await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
}
private forgetUnreadActivity(session: PiAgentSession): void {
this.unreadStore.forgetActivity(
session.sessionId,
canonicalizeStoredCwd(session.sessionManager.getCwd()),
);
}
private async forgetUnreadSessions(identities: readonly { sessionId: string; cwd: string }[]): Promise<void> {
const mutations: SessionUnreadMutation[] = [];
for (const identity of identities) {
mutations.push(...this.unreadStore.forgetSession(
identity.sessionId,
canonicalizeStoredCwd(identity.cwd),
));
}
await this.publishUnreadMutations(mutations);
}
private observeUnreadActivityState(session: PiAgentSession): void {
const mutations = this.unreadStore.observeActivityState(
session.sessionId,
canonicalizeStoredCwd(session.sessionManager.getCwd()),
this.hasActiveWork(session),
);
if (mutations.length === 0) return;
void this.publishUnreadMutations(mutations).catch(() => undefined);
}
private publishUnreadMutations(mutations: readonly SessionUnreadMutation[]): Promise<void> {
this.enqueueUnreadMutations(mutations);
this.unreadPublicationFlushRequested = true;
if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
const failure = this.unreadPublicationFailure;
return Promise.reject(failure instanceof Error
? failure
: new Error("Session unread publication is awaiting retry", { cause: failure }));
}
return this.ensureUnreadPublication();
}
private ensureUnreadPublication(): Promise<void> {
const existing = this.unreadPublication;
if (existing !== undefined) return existing;
const publication = this.drainUnreadPublication();
this.unreadPublication = publication;
void publication.then(
() => {
if (this.unreadPublication === publication) this.unreadPublication = undefined;
},
(error: unknown) => {
if (this.unreadPublication === publication) this.unreadPublication = undefined;
this.unreadPublicationFailure = error;
this.logger.info(
{ error: error instanceof Error ? error.message : String(error) },
"failed to publish durable session unread mutations",
);
this.scheduleUnreadPublicationRetry();
},
);
return publication;
}
private async drainUnreadPublication(): Promise<void> {
while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
this.unreadPublicationFlushRequested = false;
const batch = this.pendingUnreadMutations.splice(0);
let publishedCount = 0;
try {
await this.unreadStore.flush();
for (const mutation of batch) {
this.events.publishGlobal(mutation.event);
publishedCount += 1;
}
} catch (error: unknown) {
this.prependUnreadMutations(batch.slice(publishedCount));
this.unreadPublicationFlushRequested = true;
throw error;
}
this.unreadPublicationFailure = undefined;
this.clearUnreadPublicationRetry();
}
}
private enqueueUnreadMutations(mutations: readonly SessionUnreadMutation[]): void {
this.pendingUnreadMutations.push(...mutations);
this.trimPendingUnreadMutations();
}
private prependUnreadMutations(mutations: readonly SessionUnreadMutation[]): void {
this.pendingUnreadMutations.unshift(...mutations);
this.trimPendingUnreadMutations();
}
private trimPendingUnreadMutations(): void {
const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
if (excess > 0) this.pendingUnreadMutations.splice(0, excess);
}
private scheduleUnreadPublicationRetry(): void {
if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined) return;
const delay = this.unreadPublicationRetryDelayMs;
this.unreadPublicationRetryDelayMs = Math.min(
Math.max(delay * 2, this.unreadPublicationRetryInitialMs),
Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs),
);
this.unreadPublicationRetryTimer = setTimeout(() => {
this.unreadPublicationRetryTimer = undefined;
void this.ensureUnreadPublication().catch(() => undefined);
}, delay);
this.unreadPublicationRetryTimer.unref();
}
private clearUnreadPublicationRetry(): void {
if (this.unreadPublicationRetryTimer !== undefined) clearTimeout(this.unreadPublicationRetryTimer);
this.unreadPublicationRetryTimer = undefined;
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
}
private bindRuntime(active: ActiveSession<PiSessionRuntime>, session: PiAgentSession = active.runtime.session): void {
active.unsubscribe();
for (const [sessionId, candidate] of this.active.entries()) {
@@ -2602,12 +2824,14 @@ export class PiSessionService implements SessionRouteService {
): Promise<T> {
const sessionIds = new Set<string>();
const runtimes = new Set<PiSessionRuntime>();
const sessions = new Set<PiAgentSession>();
for (const target of targets) {
const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
const session = target.session ?? runtime?.session;
if (session !== undefined && this.hasActiveWork(session)) throw new Error(activeError);
sessionIds.add(target.sessionId);
if (runtime !== undefined) runtimes.add(runtime);
if (session !== undefined) sessions.add(session);
}
for (const sessionId of sessionIds) {
@@ -2616,12 +2840,16 @@ export class PiSessionService implements SessionRouteService {
for (const runtime of runtimes) {
this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
}
for (const session of sessions) this.observeUnreadActivityState(session);
try {
return await operation();
} finally {
for (const runtime of runtimes) decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
for (const sessionId of sessionIds) decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
for (const session of sessions) {
if (this.isCurrentActiveSession(session)) this.observeUnreadActivityState(session);
}
}
}
@@ -2658,12 +2886,14 @@ export class PiSessionService implements SessionRouteService {
private beginSessionEntryMutation(session: PiAgentSession, action: string): void {
this.assertTreeNavigationInactive(session, action);
this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
this.observeUnreadActivityState(session);
}
private endSessionEntryMutation(session: PiAgentSession): void {
const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
if (remaining <= 0) this.sessionEntryMutationCounts.delete(session);
else this.sessionEntryMutationCounts.set(session, remaining);
this.observeUnreadActivityState(session);
}
private isSessionEntryMutationActive(session: PiAgentSession): boolean {
@@ -2705,6 +2935,7 @@ export class PiSessionService implements SessionRouteService {
this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
this.events.publish(session.sessionId, { type: "activity.update", activity });
this.events.publishGlobal({ type: "activity.update", activity });
this.observeUnreadActivityState(session);
}
private publishStatus(session: PiAgentSession): void {
@@ -2713,6 +2944,7 @@ export class PiSessionService implements SessionRouteService {
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
this.events.publish(session.sessionId, { type: "status.update", status });
this.events.publishGlobal({ type: "status.update", status });
this.observeUnreadActivityState(session);
}
private clearStaleActiveActivity(session: PiAgentSession): void {
@@ -0,0 +1,675 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import {
CapturingSessionEventHub,
emptyArchiveStore,
fakeRuntime,
fakeSessionManager,
runtimeCreator,
sessionGateway,
sessionRecord,
sessionRef,
testModelRuntime,
type RuntimeCreator,
} from "./piSessionService.testSupport.js";
import {
SessionUnreadStore,
type SessionUnreadPersistedState,
type SessionUnreadPersistence,
} from "./sessionUnreadStore.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("PiSessionService daemon-owned unread state", () => {
it("records one durable completion and keeps stale acknowledgements from clearing newer work", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("session-1"));
completeRuntimeWork(fake);
completeRuntimeWork(fake);
const secondSnapshot = await service.unreadCatalog();
const current = secondSnapshot.sessions[0];
expect(current).toMatchObject({ sessionId: "session-1", cwd: "/workspace", completionOrder: 2 });
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
const staleSnapshot = await service.acknowledgeUnread("session-1", {
cwd: "/workspace",
catalogId: "catalog-test",
throughCompletionOrder: 1,
});
expect(staleSnapshot.sessions).toEqual(secondSnapshot.sessions);
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
const acknowledged = await service.acknowledgeUnread("session-1", {
cwd: "/workspace",
catalogId: "catalog-test",
throughCompletionOrder: current?.completionOrder ?? 0,
});
expect(acknowledged.sessions).toEqual([]);
expect(unreadEvents(hub).at(-1)).toMatchObject({ catalogRevision: 3, sessionId: "session-1", unread: null });
} finally {
await service.dispose();
}
});
it("tracks service-owned activity even while runtime status flags look idle", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
const fake = fakeRuntime("session-1");
let finishBash: (() => void) | undefined;
fake.session.executeBash = () => new Promise((resolve) => {
finishBash = () => { resolve({ output: "done", exitCode: 0, cancelled: false, truncated: false }); };
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("session-1"));
await service.shell(sessionRef("session-1"), "!echo done");
expect(fake.session.isStreaming).toBe(false);
expect(fake.session.isBashRunning).toBe(false);
expect((await service.unreadCatalog()).sessions).toEqual([]);
finishBash?.();
await Promise.resolve();
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "session-1", cwd: "/workspace", completionOrder: 1 }]);
} finally {
await service.dispose();
}
});
it("publishes completion revisions in order only after their captured state is durable", async () => {
const persistence = new BlockingUnreadPersistence();
const unreadStore = new SessionUnreadStore({ persistence, createCatalogId: () => "catalog-test" });
await unreadStore.load();
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("session-1"));
const blockedSave = persistence.blockNextSave();
completeRuntimeWork(fake);
completeRuntimeWork(fake);
await Promise.resolve();
expect(unreadEvents(hub)).toEqual([]);
blockedSave.resolve();
const snapshot = await service.unreadCatalog();
expect(snapshot.sessions).toMatchObject([{ sessionId: "session-1", completionOrder: 2 }]);
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
expect(persistence.savedStates.at(-1)).toMatchObject({ catalogRevision: 2, nextCompletionOrder: 2 });
} finally {
await service.dispose();
}
});
it("does not publish a mutation queued after the current batch became durable", async () => {
const persistence = new BlockingUnreadPersistence();
const unreadStore = new SessionUnreadStore({ persistence, createCatalogId: () => "catalog-test" });
await unreadStore.load();
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
const flush = unreadStore.flush.bind(unreadStore);
let blockedSecondSave: Deferred | undefined;
let injectedSecondCompletion = false;
vi.spyOn(unreadStore, "flush").mockImplementation(async () => {
await flush();
if (injectedSecondCompletion) return;
injectedSecondCompletion = true;
blockedSecondSave = persistence.blockNextSave();
completeRuntimeWork(fake);
});
try {
await service.status(sessionRef("session-1"));
completeRuntimeWork(fake);
await drainMicrotasks();
expect(persistence.savedStates.at(-1)).toMatchObject({ catalogRevision: 1, nextCompletionOrder: 1 });
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1]);
if (blockedSecondSave === undefined) throw new Error("Expected the second unread save to be blocked");
blockedSecondSave.resolve();
await service.unreadCatalog();
expect(persistence.savedStates.at(-1)).toMatchObject({ catalogRevision: 2, nextCompletionOrder: 2 });
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
} finally {
await service.dispose();
}
});
it("retries failed durable publication without waiting for another client request", async () => {
vi.useFakeTimers();
const persistence = new RecoveringUnreadPersistence(2);
const unreadStore = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" });
await unreadStore.load();
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
unreadPublicationRetryDelayMs: 100,
});
try {
await service.status(sessionRef("session-1"));
completeRuntimeWork(fake);
await drainMicrotasks();
expect(persistence.saveCalls).toBe(2);
expect(unreadEvents(hub)).toEqual([]);
await vi.advanceTimersByTimeAsync(100);
await drainMicrotasks();
expect(persistence.saveCalls).toBe(3);
expect(persistence.persistedState()).toMatchObject({ catalogRevision: 1, nextCompletionOrder: 1 });
expect(unreadEvents(hub)).toMatchObject([{ catalogRevision: 1, sessionId: "session-1" }]);
} finally {
try {
await service.dispose();
} finally {
vi.useRealTimers();
}
}
});
it("forgets a closing runtime latch without manufacturing a stop completion and preserves unread across reload work", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
const hub = new CapturingSessionEventHub();
const runtimes = [fakeRuntime("session-1"), fakeRuntime("session-1"), fakeRuntime("session-1")];
let runtimeIndex = 0;
const createAgentRuntime: RuntimeCreator = () => {
const next = runtimes[runtimeIndex++];
if (next === undefined) throw new Error("Unexpected extra runtime creation");
return Promise.resolve(next.runtime);
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("session-1")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("session-1"));
const initial = runtimes[0];
if (initial === undefined) throw new Error("Expected an initial runtime");
initial.session.isStreaming = true;
initial.emit({ type: "agent_start" });
await service.stop(sessionRef("session-1"));
initial.session.isStreaming = false;
expect((await service.unreadCatalog()).sessions).toEqual([]);
completeStoreWork(unreadStore, "session-1", "/workspace");
const beforeReload = (await service.unreadCatalog()).sessions[0];
await service.reload(sessionRef("session-1"));
const afterReload = (await service.unreadCatalog()).sessions[0];
expect(beforeReload).toBeDefined();
expect(afterReload).toMatchObject({ sessionId: "session-1", cwd: "/workspace" });
expect(afterReload?.completionOrder).toBeGreaterThan(beforeReload?.completionOrder ?? 0);
} finally {
await service.dispose();
}
});
it("clears stale unread when a runtime rebind changes logical session identity", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "session-old", "/workspace");
const original = fakeRuntime("session-old");
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
original.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const replacement = fakeRuntime("session-new");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(original.runtime),
sessionManager: sessionGateway([sessionRecord("session-old")]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("session-old"));
if (rebindSession === undefined) throw new Error("Expected runtime rebind callback");
await rebindSession(replacement.session);
expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally {
await service.dispose();
}
});
it("cleans unread state through archive, restore, delete, and cwd reconciliation", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
for (const sessionId of ["archive-me", "restore-me", "delete-me", "orphan"]) {
completeStoreWork(unreadStore, sessionId, "/workspace");
}
const archived = new Map([
["restore-me", { sessionId: "restore-me", cwd: "/workspace", archivedAt: "2026-07-01T00:00:00.000Z", archivePath: "/archive/restore-me.jsonl" }],
["delete-me", { sessionId: "delete-me", cwd: "/workspace", archivedAt: "2026-07-01T00:00:00.000Z", archivePath: "/archive/delete-me.jsonl" }],
]);
const fake = fakeRuntime("archive-me");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("archive-me")]),
archiveStore: {
list: () => Promise.resolve([...archived.values()]),
get: (sessionId) => Promise.resolve([...archived.values()].find((record) => record.sessionId.startsWith(sessionId))),
archive: (input) => {
const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-07-20T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` };
archived.set(input.sessionId, record);
return Promise.resolve(record);
},
restore: (sessionId) => { archived.delete(sessionId); return Promise.resolve(); },
deleteArchived: (sessionId) => { archived.delete(sessionId); return Promise.resolve(); },
isArchived: (sessionId) => Promise.resolve(archived.has(sessionId)),
},
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("archive-me"));
await service.archive(sessionRef("archive-me"));
expect((await service.unreadCatalog()).sessions.map((summary) => summary.sessionId)).toEqual([
"orphan",
"delete-me",
"restore-me",
]);
await service.restore(sessionRef("restore-me"));
expect((await service.unreadCatalog()).sessions.map((summary) => summary.sessionId)).toEqual([
"orphan",
"delete-me",
]);
await service.deleteArchived(sessionRef("delete-me"));
expect((await service.unreadCatalog()).sessions.map((summary) => summary.sessionId)).toEqual(["orphan"]);
await service.list("/workspace");
expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally {
await service.dispose();
}
});
it("excludes live tracked sub-sessions, then restores ordinary tracking after detach", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-unread-live-subsessions-"));
tempRoots.push(root);
const parentFile = join(root, "parent.jsonl");
const childFile = join(root, "child.jsonl");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
const hub = new CapturingSessionEventHub();
const parent = fakeRuntime("parent-1", { sessionFile: parentFile });
const child = fakeRuntime("child-1", {
sessionFile: childFile,
sessionManager: fakeSessionManager("/workspace-feature"),
});
child.session.prompt = () => {
completeRuntimeWork(child);
return Promise.resolve();
};
const runtimes = [parent.runtime, child.runtime];
let runtimeIndex = 0;
const createAgentRuntime: RuntimeCreator = () => Promise.resolve(runtimes[runtimeIndex++] ?? child.runtime);
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.start("/workspace");
await service.spawnSubsession({
spawningCwd: "/workspace",
parentSessionId: "parent-1",
parentSessionFile: parentFile,
prompt: "do the slice",
cwd: "/workspace-feature",
});
completeRuntimeWork(child);
expect((await service.unreadCatalog()).sessions.some((summary) => summary.sessionId === "child-1")).toBe(false);
expect(unreadEvents(hub).some((event) => event.sessionId === "child-1" && event.unread !== null)).toBe(false);
await service.detachParent(sessionRef("child-1", "/workspace-feature"));
completeRuntimeWork(child);
expect((await service.unreadCatalog()).sessions).toContainEqual(expect.objectContaining({
sessionId: "child-1",
cwd: "/workspace-feature",
}));
} finally {
await service.dispose();
}
});
it("clears accidental unread when a reciprocal persisted tracked link is verified after restart", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-unread-subsessions-"));
tempRoots.push(root);
const parentFile = join(root, "parent.jsonl");
const childFile = join(root, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "child-1", "/workspace-feature");
const hub = new CapturingSessionEventHub();
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{
type: "custom",
customType: "pi-web.subsession.link",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" },
}],
});
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: {
create: () => parentManager,
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([]),
open: () => fakeSessionManager("/workspace-feature"),
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.start("/workspace");
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
expect((await service.unreadCatalog()).sessions).toEqual([]);
expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "child-1", cwd: "/workspace-feature", unread: null });
} finally {
await service.dispose();
}
});
it("retries tracked-child hydration after a linked child is temporarily unavailable", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-unread-subsessions-retry-"));
tempRoots.push(root);
const parentFile = join(root, "parent.jsonl");
const childFile = join(root, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "child-1", "/workspace-feature");
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{
type: "custom",
customType: "pi-web.subsession.link",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" },
}],
});
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: {
create: () => parentManager,
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([]),
open: () => fakeSessionManager("/workspace-feature"),
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.start("/workspace");
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([]);
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally {
await service.dispose();
}
});
it("does not re-exclude a detached child from persisted markers after restart", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-unread-detached-subsessions-"));
tempRoots.push(root);
const parentFile = join(root, "parent.jsonl");
const childFile = join(root, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
const parentManager = fakeSessionManager("/workspace", {
getSessionId: () => "parent-1",
getSessionFile: () => parentFile,
getEntries: () => [{
type: "custom",
customType: "pi-web.subsession.link",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" },
}],
});
const childManager = fakeSessionManager("/workspace-feature", {
getSessionId: () => "child-1",
getSessionFile: () => childFile,
getEntries: () => [{
type: "custom",
customType: "pi-web.subsession.spawned",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" },
}],
});
const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: childFile };
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "child-1", "/workspace-feature");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([childRecord]),
listAll: () => Promise.resolve([childRecord]),
open: (path) => path === parentFile ? parentManager : childManager,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("child-1", "/workspace-feature"));
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([]);
expect((await service.unreadCatalog()).sessions).toMatchObject([
{ sessionId: "child-1", cwd: "/workspace-feature" },
]);
} finally {
await service.dispose();
}
});
it("does not exclude a generic parentSessionPath descendant without verified tracked markers", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "branch-1", "/workspace");
const branch = fakeRuntime("branch-1", {
sessionFile: "/tmp/branch-1.jsonl",
sessionManager: fakeSessionManager("/workspace", { getBranch: () => [] }),
});
const genericDescendant = { ...sessionRecord("branch-1"), parentSessionPath: "/tmp/parent.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(branch.runtime),
sessionManager: sessionGateway([genericDescendant]),
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("branch-1"));
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "branch-1", cwd: "/workspace" }]);
} finally {
await service.dispose();
}
});
});
function completeRuntimeWork(runtime: ReturnType<typeof fakeRuntime>): void {
runtime.session.isStreaming = true;
runtime.emit({ type: "agent_start" });
runtime.session.isStreaming = false;
runtime.emit({ type: "turn_end" });
}
function completeStoreWork(store: SessionUnreadStore, sessionId: string, cwd: string): void {
store.observeActivityState(sessionId, cwd, true);
store.observeActivityState(sessionId, cwd, false);
}
function unreadEvents(hub: CapturingSessionEventHub) {
return hub.globalEvents.filter((event) => event.type === "sessions.unread");
}
interface Deferred {
promise: Promise<void>;
resolve(): void;
}
class RecoveringUnreadPersistence implements SessionUnreadPersistence {
saveCalls = 0;
private value: SessionUnreadPersistedState = {
version: 1,
catalogId: "catalog-test",
catalogRevision: 0,
nextCompletionOrder: 0,
sessions: [],
};
constructor(private readonly failures: number) {}
load(): Promise<unknown> {
return Promise.resolve(structuredClone(this.value));
}
save(state: SessionUnreadPersistedState): Promise<void> {
this.saveCalls += 1;
if (this.saveCalls <= this.failures) return Promise.reject(new Error("unread persistence unavailable"));
this.value = structuredClone(state);
return Promise.resolve();
}
persistedState(): SessionUnreadPersistedState {
return structuredClone(this.value);
}
}
class BlockingUnreadPersistence implements SessionUnreadPersistence {
readonly savedStates: SessionUnreadPersistedState[] = [];
private persistedState: SessionUnreadPersistedState | undefined;
private nextSaveGate: Deferred | undefined;
load(): Promise<unknown> {
return Promise.resolve(this.persistedState);
}
async save(state: SessionUnreadPersistedState): Promise<void> {
const gate = this.nextSaveGate;
this.nextSaveGate = undefined;
if (gate !== undefined) await gate.promise;
const saved = structuredClone(state);
this.persistedState = saved;
this.savedStates.push(saved);
}
blockNextSave(): Deferred {
const gate = deferred();
this.nextSaveGate = gate;
return gate;
}
}
async function drainMicrotasks(): Promise<void> {
for (let index = 0; index < 20; index += 1) await Promise.resolve();
}
function deferred(): Deferred {
let resolvePromise: (() => void) | undefined;
const promise = new Promise<void>((resolve) => { resolvePromise = resolve; });
return {
promise,
resolve() { resolvePromise?.(); },
};
}
+99 -1
View File
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
import type {
MessagePage,
SessionBulkArchiveResponse,
@@ -17,6 +17,8 @@ import type {
SessionStatus,
SessionStreamSnapshot,
SessionTreeNavigateRequest,
SessionUnreadAcknowledgeRequest,
SessionUnreadCatalogSnapshot,
SessionTreeNavigateResult,
} from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
@@ -71,6 +73,86 @@ describe("session routes", () => {
}
});
it("returns unread snapshots and validates race-safe acknowledgement cutoffs", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const catalog = await routeApp.inject({ method: "GET", url: "/sessions/unread" });
const acknowledged = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/unread/acknowledge",
payload: { cwd: requestCwd, catalogId: "catalog-test", throughCompletionOrder: 7 },
});
const invalid = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/unread/acknowledge",
payload: { cwd: requestCwd, catalogId: "catalog-test", throughCompletionOrder: 0 },
});
const oversized = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/unread/acknowledge",
payload: {
cwd: requestCwd,
catalogId: "x".repeat(SESSION_UNREAD_CATALOG_ID_MAX_LENGTH + 1),
throughCompletionOrder: 7,
},
});
expect(catalog.statusCode).toBe(200);
expect(catalog.json()).toEqual(routeService.unreadCatalogResponse);
expect(acknowledged.statusCode).toBe(200);
expect(acknowledged.json()).toEqual(routeService.unreadCatalogResponse);
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toEqual({ error: "throughCompletionOrder field must be positive" });
expect(oversized.statusCode).toBe(400);
expect(oversized.json()).toEqual({ error: "catalogId field is too long" });
expect(routeService.acknowledgeUnreadCalls).toEqual([{
sessionId: "session-1",
request: { cwd: requestCwd, catalogId: "catalog-test", throughCompletionOrder: 7 },
}]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("reports unread backend failures as unavailable while keeping validation errors at 400", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
routeService.unreadError = new Error("unread persistence unavailable");
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const catalog = await routeApp.inject({ method: "GET", url: "/sessions/unread" });
const acknowledgement = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/unread/acknowledge",
payload: { cwd: resolve("/repo"), catalogId: "catalog-test", throughCompletionOrder: 7 },
});
const invalid = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/unread/acknowledge",
payload: { cwd: "relative", catalogId: "catalog-test", throughCompletionOrder: 7 },
});
expect(catalog.statusCode).toBe(503);
expect(acknowledgement.statusCode).toBe(503);
expect(catalog.json()).toEqual({ error: "unread persistence unavailable" });
expect(acknowledgement.json()).toEqual({ error: "unread persistence unavailable" });
expect(invalid.statusCode).toBe(400);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("validates and forwards idempotent notification dismissal cutoffs", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
@@ -610,9 +692,12 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly clearQueueCalls: SessionRouteLookup[] = [];
readonly dismissWarningCalls: { lookup: SessionRouteLookup; dismissId: string }[] = [];
readonly notificationInboxCalls: SessionRef[] = [];
readonly acknowledgeUnreadCalls: { sessionId: string; request: SessionUnreadAcknowledgeRequest }[] = [];
readonly unreadCatalogResponse: SessionUnreadCatalogSnapshot = { catalogId: "catalog-test", catalogRevision: 1, sessions: [] };
readonly dismissNotificationCalls: { ref: SessionRef; request: Omit<SessionNotificationDismissRequest, "cwd"> }[] = [];
readonly dismissAllNotificationCalls: { ref: SessionRef; request: Omit<SessionNotificationDismissAllRequest, "cwd"> }[] = [];
dismissWarningError: Error | undefined;
unreadError: Error | undefined;
messagesResponse: unknown[] | MessagePage = [];
streamSnapshotResponse: SessionStreamSnapshot = { seq: 0, partial: null };
readonly streamSnapshotCalls: SessionRouteLookup[] = [];
@@ -658,6 +743,19 @@ class CapturingRouteSessionService implements SessionRouteService {
return { daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] };
}
unreadCatalog(): Promise<SessionUnreadCatalogSnapshot> {
return this.unreadError === undefined
? Promise.resolve(this.unreadCatalogResponse)
: Promise.reject(this.unreadError);
}
acknowledgeUnread(sessionId: string, request: SessionUnreadAcknowledgeRequest): Promise<SessionUnreadCatalogSnapshot> {
this.acknowledgeUnreadCalls.push({ sessionId, request });
return this.unreadError === undefined
? Promise.resolve(this.unreadCatalogResponse)
: Promise.reject(this.unreadError);
}
notificationInbox(ref: SessionRef): SessionNotificationInboxSnapshot {
this.notificationInboxCalls.push(ref);
return notificationSnapshot(ref);
+36 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice } from "../../shared/apiTypes.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
@@ -62,6 +62,35 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.get(`${prefix}/sessions/unread`, async (_request, reply) => {
try {
return await sessions.unreadCatalog();
} catch (error) {
return reply.code(503).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: Record<string, unknown> | undefined }>(`${prefix}/sessions/:sessionId/unread/acknowledge`, async (request, reply) => {
let sessionId: string;
let acknowledgement: SessionUnreadAcknowledgeRequest;
try {
const body = requireRecord(request.body);
sessionId = requireNonEmptyBoundedString(request.params.sessionId, "sessionId", SESSION_UNREAD_SESSION_ID_MAX_LENGTH);
acknowledgement = {
cwd: normalizeRequestCwd(requireNonEmptyBoundedString(body["cwd"], "cwd", SESSION_UNREAD_CWD_MAX_LENGTH)),
catalogId: requireNonEmptyBoundedString(body["catalogId"], "catalogId", SESSION_UNREAD_CATALOG_ID_MAX_LENGTH),
throughCompletionOrder: requirePositiveSafeInteger(body["throughCompletionOrder"], "throughCompletionOrder"),
};
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
try {
return await sessions.acknowledgeUnread(sessionId, acknowledgement);
} catch (error) {
return reply.code(503).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => {
try {
return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body)));
@@ -509,6 +538,12 @@ function requireNonNegativeSafeInteger(value: unknown, field: string): number {
return value;
}
function requirePositiveSafeInteger(value: unknown, field: string): number {
const parsed = requireNonNegativeSafeInteger(value, field);
if (parsed === 0) throw new Error(`${field} field must be positive`);
return parsed;
}
function requireThinkingLevel(value: unknown): string {
if (typeof value !== "string" || value === "") throw new Error("level field is invalid");
return value;
+4
View File
@@ -7,6 +7,8 @@ import type {
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
SessionUnreadAcknowledgeRequest,
SessionUnreadCatalogSnapshot,
} from "../../shared/apiTypes.js";
import type {
ClientArchiveSessionsResponse,
@@ -43,6 +45,8 @@ export interface SessionRouteService {
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
notificationCatalog(): SessionNotificationCatalogSnapshot | Promise<SessionNotificationCatalogSnapshot>;
unreadCatalog(): Promise<SessionUnreadCatalogSnapshot>;
acknowledgeUnread(sessionId: string, request: SessionUnreadAcknowledgeRequest): Promise<SessionUnreadCatalogSnapshot>;
notificationInbox(ref: SessionRouteRef): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
dismissNotification(ref: SessionRouteRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
dismissAllNotifications(ref: SessionRouteRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
@@ -0,0 +1,593 @@
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
import {
FileSessionUnreadPersistence,
SessionUnreadStore,
defaultSessionUnreadFilePath,
type SessionUnreadPersistedState,
type SessionUnreadPersistence,
} from "./sessionUnreadStore.js";
const roots: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("SessionUnreadStore", () => {
it("marks only known active-to-idle transitions unread", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]);
expect(store.observeActivityState("session-1", "/repo", true)).toEqual([]);
expect(store.observeActivityState("session-1", "/repo", true)).toEqual([]);
const completed = store.observeActivityState("session-1", "/repo", false);
expect(completed).toMatchObject([{
event: {
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 1,
sessionId: "session-1",
cwd: "/repo",
unread: { completionOrder: 1, completedAt: "2026-07-20T00:00:00.000Z" },
},
}]);
expect(store.catalogSnapshot()).toMatchObject({
catalogId: "catalog-a",
catalogRevision: 1,
sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }],
});
expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]);
});
it("uses monotonic completion orders so stale acknowledgements cannot clear newer work", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
complete(store, "session-1", "/repo");
const firstOrder = currentOrder(store, "session-1", "/repo");
complete(store, "session-1", "/repo");
const secondOrder = currentOrder(store, "session-1", "/repo");
expect(secondOrder).toBeGreaterThan(firstOrder);
expect(store.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: firstOrder,
}).mutations).toEqual([]);
expect(currentOrder(store, "session-1", "/repo")).toBe(secondOrder);
const acknowledged = store.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: secondOrder,
});
expect(acknowledged.mutations).toMatchObject([{
event: {
type: "sessions.unread",
catalogId: "catalog-a",
catalogRevision: 3,
sessionId: "session-1",
unread: null,
},
}]);
expect(store.catalogSnapshot().sessions).toEqual([]);
expect(store.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: secondOrder,
}).mutations).toEqual([]);
});
it("rejects stale acknowledgements from a reset catalog epoch", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-new");
complete(store, "session-1", "/repo");
const current = currentOrder(store, "session-1", "/repo");
const stale = store.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-old",
throughCompletionOrder: Number.MAX_SAFE_INTEGER,
});
expect(stale.mutations).toEqual([]);
expect(currentOrder(store, "session-1", "/repo")).toBe(current);
});
it("scopes lifecycle and acknowledgements to the canonical id and cwd pair", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
complete(store, "session-1", "/repo-a");
complete(store, "session-1", "/repo-b");
const repoBOrder = currentOrder(store, "session-1", "/repo-b");
store.acknowledge("session-1", {
cwd: "/repo-b",
catalogId: "catalog-a",
throughCompletionOrder: repoBOrder,
});
expect(store.catalogSnapshot().sessions).toMatchObject([
{ sessionId: "session-1", cwd: "/repo-a", completionOrder: 1 },
]);
});
it("forgets a closing runtime's active latch without manufacturing a completion", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
store.observeActivityState("session-1", "/repo", true);
store.forgetActivity("session-1", "/repo");
expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]);
expect(store.catalogSnapshot().sessions).toEqual([]);
});
it("excludes verified tracked sub-sessions and clears accidental lifecycle state", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
store.observeActivityState("tracked", "/repo", true);
expect(store.excludeSession("tracked", "/repo")).toEqual([]);
expect(store.observeActivityState("tracked", "/repo", false)).toEqual([]);
expect(store.observeActivityState("tracked", "/repo", true)).toEqual([]);
expect(store.observeActivityState("tracked", "/repo", false)).toEqual([]);
store.forgetSession("tracked", "/repo");
complete(store, "tracked", "/repo");
const removed = store.excludeSession("tracked", "/repo");
expect(removed).toMatchObject([{
event: { catalogId: "catalog-a", sessionId: "tracked", cwd: "/repo", unread: null },
}]);
expect(store.catalogSnapshot().sessions).toEqual([]);
});
it("removes durable and transient state when a cwd is reconciled", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
complete(store, "keep", "/repo");
complete(store, "remove", "/repo");
store.observeActivityState("active-orphan", "/repo", true);
store.excludeSession("excluded-orphan", "/repo");
const mutations = store.reconcileCwd("/repo", ["keep"]);
expect(mutations).toMatchObject([{ event: { sessionId: "remove", unread: null } }]);
expect(store.catalogSnapshot().sessions.map((summary) => summary.sessionId)).toEqual(["keep"]);
expect(store.observeActivityState("active-orphan", "/repo", false)).toEqual([]);
complete(store, "excluded-orphan", "/repo");
expect(currentOrder(store, "excluded-orphan", "/repo")).toBeGreaterThan(0);
});
it("bounds the catalog and emits an authoritative removal when pruning", () => {
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
let finalMutations = store.observeActivityState("baseline", "/repo", false);
for (let index = 0; index <= SESSION_UNREAD_LIMIT; index += 1) {
const sessionId = `session-${index.toString()}`;
store.observeActivityState(sessionId, "/repo", true);
finalMutations = store.observeActivityState(sessionId, "/repo", false);
}
const snapshot = store.catalogSnapshot();
expect(snapshot.sessions).toHaveLength(SESSION_UNREAD_LIMIT);
expect(snapshot.sessions.some((summary) => summary.sessionId === "session-0")).toBe(false);
expect(snapshot.sessions[0]).toMatchObject({ sessionId: `session-${SESSION_UNREAD_LIMIT.toString()}`, completionOrder: SESSION_UNREAD_LIMIT + 1 });
expect(finalMutations).toMatchObject([
{ event: { unread: { sessionId: `session-${SESSION_UNREAD_LIMIT.toString()}` } } },
{ event: { sessionId: "session-0", unread: null } },
]);
});
it("persists the catalog epoch, revisions, and completion order across store instances", async () => {
const persistence = new MemoryPersistence(undefined);
const first = persistedStore(persistence, "catalog-a", "2026-07-20T00:00:00.000Z");
await Promise.all([first.load(), first.load()]);
expect(persistence.loadCalls).toBe(1);
complete(first, "session-1", "/repo");
await first.flush();
const second = persistedStore(persistence, "unused-catalog-b", "2026-07-20T01:00:00.000Z");
await second.load();
expect(second.catalogSnapshot()).toEqual(first.catalogSnapshot());
const order = currentOrder(second, "session-1", "/repo");
second.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: order,
});
await second.flush();
const third = persistedStore(persistence, "unused-catalog-c", "2026-07-20T02:00:00.000Z");
await third.load();
expect(third.catalogSnapshot()).toMatchObject({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
complete(third, "session-1", "/repo");
expect(currentOrder(third, "session-1", "/repo")).toBe(2);
});
it("repairs malformed persistence with a fresh epoch and keeps stale old-epoch acks harmless", async () => {
const errors: { operation: "load" | "save"; error: unknown }[] = [];
const persistence = new MemoryPersistence({ version: 999, catalogId: "catalog-old" });
const store = new SessionUnreadStore({
persistence,
createCatalogId: () => "catalog-reset",
onPersistenceError: (operation, error) => { errors.push({ operation, error }); },
now: () => new Date("2026-07-20T00:00:00.000Z"),
});
expect(() => store.catalogSnapshot()).toThrow("must be loaded");
await store.load();
expect(errors).toHaveLength(1);
expect(errors[0]?.operation).toBe("load");
expect(errors[0]?.error).toBeInstanceOf(Error);
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
expect(persistence.valueSnapshot()).toMatchObject({
version: 1,
catalogId: "catalog-reset",
catalogRevision: 0,
nextCompletionOrder: 0,
sessions: [],
});
complete(store, "session-1", "/repo");
expect(store.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-old",
throughCompletionOrder: Number.MAX_SAFE_INTEGER,
}).mutations).toEqual([]);
expect(store.catalogSnapshot().sessions).toHaveLength(1);
});
it("resets persisted protocol fields that exceed shared bounds and rejects oversized runtime identities", async () => {
const persistence = new MemoryPersistence({
version: 1,
catalogId: "catalog-old",
catalogRevision: 1,
nextCompletionOrder: 1,
sessions: [{
sessionId: "x".repeat(SESSION_UNREAD_SESSION_ID_MAX_LENGTH + 1),
cwd: "/repo",
completionOrder: 1,
completedAt: "2026-07-20T00:00:00.000Z",
}],
});
const errors: unknown[] = [];
const store = new SessionUnreadStore({
persistence,
createCatalogId: () => "catalog-reset",
onPersistenceError: (_operation, error) => { errors.push(error); },
});
await store.load();
expect(errors).toHaveLength(1);
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
expect(() => store.observeActivityState(
"x".repeat(SESSION_UNREAD_SESSION_ID_MAX_LENGTH + 1),
"/repo",
true,
)).toThrow("sessionId exceeds its length limit");
});
it("rejects an oversized persisted catalog instead of loading unbounded state", async () => {
const sessions = Array.from({ length: SESSION_UNREAD_LIMIT + 1 }, (_, index) => ({
sessionId: `session-${index.toString()}`,
cwd: "/repo",
completionOrder: index + 1,
completedAt: "2026-07-20T00:00:00.000Z",
}));
const persistence = new MemoryPersistence({
version: 1,
catalogId: "catalog-old",
catalogRevision: sessions.length,
nextCompletionOrder: sessions.length,
sessions,
});
const errors: unknown[] = [];
const store = new SessionUnreadStore({
persistence,
createCatalogId: () => "catalog-reset",
onPersistenceError: (_operation, error) => { errors.push(error); },
});
await store.load();
expect(errors).toHaveLength(1);
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
});
it("serializes writes and captures each mutation's state", async () => {
const persistence = new BlockingPersistence(emptyPersistedState("catalog-a"));
const store = persistedStore(persistence, "unused-catalog", "2026-07-20T00:00:00.000Z");
await store.load();
complete(store, "session-1", "/repo");
const order = currentOrder(store, "session-1", "/repo");
store.acknowledge("session-1", {
cwd: "/repo",
catalogId: "catalog-a",
throughCompletionOrder: order,
});
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(1); });
expect(persistence.maximumConcurrentSaves).toBe(1);
expect(persistence.savedStates[0]?.sessions).toHaveLength(1);
persistence.releaseNextSave();
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(2); });
expect(persistence.maximumConcurrentSaves).toBe(1);
expect(persistence.savedStates[1]?.sessions).toEqual([]);
const flushed = store.flush();
persistence.releaseNextSave();
await flushed;
});
it("coalesces a mutation burst behind one in-flight persistence write", async () => {
const persistence = new BlockingPersistence(emptyPersistedState("catalog-a"));
const store = persistedStore(persistence, "unused-catalog", "2026-07-20T00:00:00.000Z");
await store.load();
complete(store, "session-0", "/repo");
for (let index = 1; index <= 200; index += 1) complete(store, `session-${index.toString()}`, "/repo");
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(1); });
expect(persistence.savedStates[0]).toMatchObject({ catalogRevision: 1, nextCompletionOrder: 1 });
persistence.releaseNextSave();
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(2); });
expect(persistence.savedStates[1]).toMatchObject({ catalogRevision: 201, nextCompletionOrder: 201 });
persistence.releaseNextSave();
await store.flush();
expect(persistence.savedStates).toHaveLength(2);
});
it("holds one latest snapshot without retrying every mutation during a storage outage", async () => {
const saveError = new Error("storage unavailable");
const save = vi.fn<SessionUnreadPersistence["save"]>(() => Promise.reject(saveError));
const store = persistedStore({
load: () => Promise.resolve(emptyPersistedState("catalog-a")),
save,
}, "unused-catalog", "2026-07-20T00:00:00.000Z");
await store.load();
complete(store, "session-0", "/repo");
await vi.waitFor(() => { expect(save).toHaveBeenCalledOnce(); });
for (let index = 1; index <= 100; index += 1) complete(store, `session-${index.toString()}`, "/repo");
await Promise.resolve();
expect(save).toHaveBeenCalledOnce();
await expect(store.flush()).rejects.toBe(saveError);
expect(save).toHaveBeenCalledTimes(2);
});
it("retries the latest snapshot before exposing state after a transient save failure", async () => {
const persistence = new FailOncePersistence(emptyPersistedState("catalog-a"));
const errors: unknown[] = [];
const store = new SessionUnreadStore({
persistence,
createCatalogId: () => "unused-catalog",
now: () => new Date("2026-07-20T00:00:00.000Z"),
onPersistenceError: (_operation, error) => { errors.push(error); },
});
await store.load();
complete(store, "session-1", "/repo");
const snapshot = await store.durableCatalogSnapshot();
expect(errors).toHaveLength(1);
expect(persistence.saveCalls).toBe(2);
expect(persistence.valueSnapshot()).toMatchObject({
catalogId: "catalog-a",
catalogRevision: 1,
nextCompletionOrder: 1,
sessions: [{ sessionId: "session-1", completionOrder: 1 }],
});
expect(snapshot.sessions).toHaveLength(1);
});
it("rejects operational load failures without overwriting the unread file", async () => {
const loadError = Object.assign(new Error("read failed"), { code: "EIO" });
const save = vi.fn<SessionUnreadPersistence["save"]>(() => Promise.resolve());
const errors: { operation: "load" | "save"; error: unknown }[] = [];
const store = new SessionUnreadStore({
persistence: { load: () => Promise.reject(loadError), save },
createCatalogId: () => "catalog-reset",
onPersistenceError: (operation, error) => { errors.push({ operation, error }); },
});
await expect(store.load()).rejects.toBe(loadError);
expect(save).not.toHaveBeenCalled();
expect(errors).toEqual([{ operation: "load", error: loadError }]);
expect(() => store.catalogSnapshot()).toThrow("must be loaded");
});
it("rejects startup when a missing or corrupt catalog cannot persist its fresh epoch", async () => {
const saveError = new Error("disk full");
const errors: { operation: "load" | "save"; error: unknown }[] = [];
const store = new SessionUnreadStore({
persistence: {
load: () => Promise.resolve(undefined),
save: () => Promise.reject(saveError),
},
createCatalogId: () => "catalog-reset",
onPersistenceError: (operation, error) => { errors.push({ operation, error }); },
});
await expect(store.load()).rejects.toBe(saveError);
expect(errors).toEqual([{ operation: "save", error: saveError }]);
expect(() => store.catalogSnapshot()).toThrow("must be loaded");
});
});
describe("FileSessionUnreadPersistence", () => {
it("repairs malformed JSON with a fresh persisted catalog epoch", async () => {
const root = await temporaryRoot();
const filePath = join(root, "session-unread.json");
await writeFile(filePath, "{not-json", "utf8");
const errors: unknown[] = [];
const store = new SessionUnreadStore({
persistence: new FileSessionUnreadPersistence(filePath),
createCatalogId: () => "catalog-reset",
onPersistenceError: (_operation, error) => { errors.push(error); },
});
await store.load();
expect(errors).toHaveLength(1);
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
expect(JSON.parse(await readFile(filePath, "utf8"))).toMatchObject({ catalogId: "catalog-reset", sessions: [] });
});
it("uses PI_WEB_DATA_DIR and atomically reloads a private state file", async () => {
const root = await temporaryRoot();
expect(defaultSessionUnreadFilePath({ PI_WEB_DATA_DIR: "state" }, root)).toBe(join(root, "state", "session-unread.json"));
const filePath = join(root, "state", "custom-unread.json");
const persistence = new FileSessionUnreadPersistence(filePath);
const store = new SessionUnreadStore({
persistence,
createCatalogId: () => "catalog-a",
now: () => new Date("2026-07-20T00:00:00.000Z"),
});
await store.load();
complete(store, "session-1", "/repo");
await store.flush();
const persisted: unknown = JSON.parse(await readFile(filePath, "utf8"));
expect(persisted).toMatchObject({
version: 1,
catalogId: "catalog-a",
catalogRevision: 1,
nextCompletionOrder: 1,
sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }],
});
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
expect((await readdir(join(root, "state"))).filter((name) => name.endsWith(".tmp"))).toEqual([]);
const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" });
await reloaded.load();
expect(reloaded.catalogSnapshot()).toMatchObject({
catalogId: "catalog-a",
sessions: [{ sessionId: "session-1", completionOrder: 1 }],
});
});
});
function storeAt(iso: string, catalogId: string): SessionUnreadStore {
return new SessionUnreadStore({ now: () => new Date(iso), createCatalogId: () => catalogId });
}
function persistedStore(
persistence: SessionUnreadPersistence,
catalogId: string,
iso: string,
): SessionUnreadStore {
return new SessionUnreadStore({
persistence,
createCatalogId: () => catalogId,
now: () => new Date(iso),
});
}
function complete(store: SessionUnreadStore, sessionId: string, cwd: string): void {
store.observeActivityState(sessionId, cwd, true);
store.observeActivityState(sessionId, cwd, false);
}
function currentOrder(store: SessionUnreadStore, sessionId: string, cwd: string): number {
return store.catalogSnapshot().sessions.find((summary) => summary.sessionId === sessionId && summary.cwd === cwd)?.completionOrder ?? 0;
}
function emptyPersistedState(catalogId: string): SessionUnreadPersistedState {
return {
version: 1,
catalogId,
catalogRevision: 0,
nextCompletionOrder: 0,
sessions: [],
};
}
class MemoryPersistence implements SessionUnreadPersistence {
loadCalls = 0;
constructor(private value: unknown) {}
load(): Promise<unknown> {
this.loadCalls += 1;
return Promise.resolve(structuredClone(this.value));
}
save(state: SessionUnreadPersistedState): Promise<void> {
this.value = structuredClone(state);
return Promise.resolve();
}
valueSnapshot(): unknown {
return structuredClone(this.value);
}
}
class FailOncePersistence implements SessionUnreadPersistence {
saveCalls = 0;
private value: SessionUnreadPersistedState;
constructor(initial: SessionUnreadPersistedState) {
this.value = structuredClone(initial);
}
load(): Promise<unknown> {
return Promise.resolve(structuredClone(this.value));
}
save(state: SessionUnreadPersistedState): Promise<void> {
this.saveCalls += 1;
if (this.saveCalls === 1) return Promise.reject(new Error("transient save failure"));
this.value = structuredClone(state);
return Promise.resolve();
}
valueSnapshot(): unknown {
return structuredClone(this.value);
}
}
class BlockingPersistence implements SessionUnreadPersistence {
readonly savedStates: SessionUnreadPersistedState[] = [];
maximumConcurrentSaves = 0;
private concurrentSaves = 0;
private readonly releases: (() => void)[] = [];
constructor(private readonly initial: SessionUnreadPersistedState) {}
load(): Promise<unknown> {
return Promise.resolve(structuredClone(this.initial));
}
save(state: SessionUnreadPersistedState): Promise<void> {
this.concurrentSaves += 1;
this.maximumConcurrentSaves = Math.max(this.maximumConcurrentSaves, this.concurrentSaves);
this.savedStates.push(structuredClone(state));
return new Promise<void>((resolve) => {
this.releases.push(() => {
this.concurrentSaves -= 1;
resolve();
});
});
}
releaseNextSave(): void {
const release = this.releases.shift();
if (release === undefined) throw new Error("No blocked persistence save to release");
release();
}
}
async function temporaryRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "pi-web-unread-"));
roots.push(root);
return root;
}
+603
View File
@@ -0,0 +1,603 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { piWebDataDir } from "../../config.js";
import {
SESSION_UNREAD_CATALOG_ID_MAX_LENGTH,
SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH,
SESSION_UNREAD_CWD_MAX_LENGTH,
SESSION_UNREAD_LIMIT,
SESSION_UNREAD_SESSION_ID_MAX_LENGTH,
type SessionUnreadAcknowledgeRequest,
type SessionUnreadCatalogSnapshot,
type SessionUnreadEvent,
type SessionUnreadSummary,
} from "../../shared/apiTypes.js";
const SESSION_UNREAD_STATE_VERSION = 1;
const SESSION_UNREAD_FILE_MODE = 0o600;
export interface SessionUnreadPersistedState {
version: typeof SESSION_UNREAD_STATE_VERSION;
catalogId: string;
catalogRevision: number;
nextCompletionOrder: number;
sessions: SessionUnreadSummary[];
}
export interface SessionUnreadPersistence {
load(): Promise<unknown>;
save(state: SessionUnreadPersistedState): Promise<void>;
}
export interface SessionUnreadStoreOptions {
now?: (() => Date) | undefined;
persistence?: SessionUnreadPersistence | undefined;
createCatalogId?: (() => string) | undefined;
onPersistenceError?: ((operation: "load" | "save", error: unknown) => void) | undefined;
}
export interface SessionUnreadMutation {
event: SessionUnreadEvent;
}
export interface SessionUnreadAcknowledgeResult {
mutations: SessionUnreadMutation[];
}
interface SessionUnreadIdentity {
sessionId: string;
cwd: string;
}
interface PendingSessionUnreadPersistence {
generation: number;
state: SessionUnreadPersistedState;
}
/**
* Daemon-owned unread completion catalog.
*
* Completion orders are global and never reused within a catalog epoch. An
* acknowledgement must carry both the epoch and the observed completion order,
* so neither an old completion nor a client from reset state can clear newer
* work.
*/
export class SessionUnreadStore {
private readonly now: () => Date;
private readonly persistence: SessionUnreadPersistence | undefined;
private readonly createCatalogId: () => string;
private readonly onPersistenceError: (operation: "load" | "save", error: unknown) => void;
private readonly unreadByIdentity = new Map<string, SessionUnreadSummary>();
private readonly activeByIdentity = new Map<string, SessionUnreadIdentity>();
private readonly excludedByIdentity = new Map<string, SessionUnreadIdentity>();
private catalogId: string;
private catalogRevision = 0;
private nextCompletionOrder = 0;
private persistenceWorker: Promise<void> | undefined;
private pendingPersistence: PendingSessionUnreadPersistence | undefined;
private persistenceGeneration = 0;
private durablePersistenceGeneration = 0;
private persistenceFailure: { error: unknown } | undefined;
private loadPromise: Promise<void> | undefined;
private loaded: boolean;
constructor(options: SessionUnreadStoreOptions = {}) {
this.now = options.now ?? (() => new Date());
this.persistence = options.persistence;
this.createCatalogId = options.createCatalogId ?? randomUUID;
this.onPersistenceError = options.onPersistenceError ?? (() => undefined);
this.loaded = this.persistence === undefined;
// A persisted store receives its epoch from disk or creates one during
// load; synchronous in-memory stores are ready immediately.
this.catalogId = this.loaded ? this.freshCatalogId() : "";
}
load(): Promise<void> {
if (this.loaded) return Promise.resolve();
if (this.loadPromise !== undefined) return this.loadPromise;
const loadPromise = this.loadPersistedState();
this.loadPromise = loadPromise;
return loadPromise;
}
/** Current in-memory state. Transport boundaries should use `durableCatalogSnapshot`. */
catalogSnapshot(): SessionUnreadCatalogSnapshot {
this.requireLoaded();
return {
catalogId: this.catalogId,
catalogRevision: this.catalogRevision,
sessions: [...this.unreadByIdentity.values()]
.sort((left, right) => right.completionOrder - left.completionOrder)
.map((summary) => ({ ...summary })),
};
}
observeActivityState(sessionId: string, cwd: string, active: boolean): SessionUnreadMutation[] {
this.requireLoaded();
const identity = requireIdentity(sessionId, cwd);
const key = sessionIdentityKey(identity);
if (this.excludedByIdentity.has(key)) {
this.activeByIdentity.delete(key);
return [];
}
if (active) {
this.activeByIdentity.set(key, identity);
return [];
}
if (!this.activeByIdentity.has(key)) return [];
const completionOrder = incrementSafe(this.nextCompletionOrder, "Session unread completion order exhausted");
const willExceedLimit = !this.unreadByIdentity.has(key) && this.unreadByIdentity.size >= SESSION_UNREAD_LIMIT;
this.assertRevisionCapacity(willExceedLimit ? 2 : 1);
const completedAt = this.now().toISOString();
this.activeByIdentity.delete(key);
this.nextCompletionOrder = completionOrder;
const summary: SessionUnreadSummary = {
sessionId,
cwd,
completionOrder,
completedAt,
};
// Reinsert existing identities so map order remains completion order.
this.unreadByIdentity.delete(key);
this.unreadByIdentity.set(key, summary);
const mutations = [this.mutation(identity, summary), ...this.trimToLimit()];
this.schedulePersist();
return mutations;
}
/** Clear only the transient active latch for a runtime that is closing or rebinding. */
forgetActivity(sessionId: string, cwd: string): void {
this.requireLoaded();
const identity = requireIdentity(sessionId, cwd);
this.activeByIdentity.delete(sessionIdentityKey(identity));
}
/**
* Suppress unread tracking until this identity is explicitly forgotten and
* remove state recorded before it was verified as a tracked sub-session.
*/
excludeSession(sessionId: string, cwd: string): SessionUnreadMutation[] {
this.requireLoaded();
const identity = requireIdentity(sessionId, cwd);
const key = sessionIdentityKey(identity);
const current = this.unreadByIdentity.get(key);
this.assertRevisionCapacity(current === undefined ? 0 : 1);
this.excludedByIdentity.set(key, identity);
this.activeByIdentity.delete(key);
if (current === undefined) return [];
this.unreadByIdentity.delete(key);
const mutations = [this.mutation(identity, null)];
this.schedulePersist();
return mutations;
}
acknowledge(sessionId: string, request: SessionUnreadAcknowledgeRequest): SessionUnreadAcknowledgeResult {
this.requireLoaded();
const identity = requireIdentity(sessionId, request.cwd);
requireCatalogId(request.catalogId);
requirePositiveSafeInteger(request.throughCompletionOrder, "throughCompletionOrder");
if (request.catalogId !== this.catalogId) return { mutations: [] };
const key = sessionIdentityKey(identity);
const current = this.unreadByIdentity.get(key);
if (current === undefined || current.completionOrder > request.throughCompletionOrder) {
return { mutations: [] };
}
this.assertRevisionCapacity(1);
this.unreadByIdentity.delete(key);
const mutations = [this.mutation(identity, null)];
this.schedulePersist();
return { mutations };
}
/** Remove durable unread and all transient lifecycle state for one identity. */
forgetSession(sessionId: string, cwd: string): SessionUnreadMutation[] {
this.requireLoaded();
const identity = requireIdentity(sessionId, cwd);
const key = sessionIdentityKey(identity);
const current = this.unreadByIdentity.get(key);
this.assertRevisionCapacity(current === undefined ? 0 : 1);
this.activeByIdentity.delete(key);
this.excludedByIdentity.delete(key);
if (current === undefined) return [];
this.unreadByIdentity.delete(key);
const mutations = [this.mutation(identity, null)];
this.schedulePersist();
return mutations;
}
reconcileCwd(cwd: string, sessionIds: Iterable<string>): SessionUnreadMutation[] {
this.requireLoaded();
const boundedCwd = requireBoundedNonEmptyString(cwd, "cwd", SESSION_UNREAD_CWD_MAX_LENGTH);
const retained = new Set(sessionIds);
const removed = [...this.unreadByIdentity.entries()]
.filter(([, summary]) => summary.cwd === boundedCwd && !retained.has(summary.sessionId));
this.assertRevisionCapacity(removed.length);
for (const [key, identity] of this.activeByIdentity) {
if (identity.cwd === boundedCwd && !retained.has(identity.sessionId)) this.activeByIdentity.delete(key);
}
for (const [key, identity] of this.excludedByIdentity) {
if (identity.cwd === boundedCwd && !retained.has(identity.sessionId)) this.excludedByIdentity.delete(key);
}
const mutations: SessionUnreadMutation[] = [];
for (const [key, summary] of removed) {
this.unreadByIdentity.delete(key);
mutations.push(this.mutation(summary, null));
}
if (mutations.length > 0) this.schedulePersist();
return mutations;
}
/** Wait until all currently queued state is durably represented or throw. */
async flush(): Promise<void> {
this.requireLoaded();
await this.waitForPersistenceWorker();
if (this.persistenceFailure === undefined
&& this.persistenceGeneration === this.durablePersistenceGeneration) return;
// Retry the latest complete snapshot once. Failed/intermediate snapshots
// are coalesced because completion orders and revisions are cumulative.
this.ensurePersistenceWorker();
await this.waitForPersistenceWorker();
this.throwIfPersistenceFailed();
}
/** Snapshot safe to expose to a client that may subsequently acknowledge it. */
async durableCatalogSnapshot(): Promise<SessionUnreadCatalogSnapshot> {
let snapshot: SessionUnreadCatalogSnapshot;
do {
await this.flush();
snapshot = this.catalogSnapshot();
} while (this.persistenceGeneration !== this.durablePersistenceGeneration);
return snapshot;
}
private async loadPersistedState(): Promise<void> {
const persistence = this.persistence;
if (persistence === undefined) {
this.loaded = true;
return;
}
let value: unknown;
let resetState = false;
try {
value = await persistence.load();
} catch (error: unknown) {
this.reportPersistenceError("load", error);
if (!(error instanceof SessionUnreadPersistenceCorruptionError)) throw error;
resetState = true;
}
if (!resetState && value !== undefined) {
try {
this.installPersistedState(parsePersistedState(value));
} catch (error: unknown) {
this.reportPersistenceError("load", error);
resetState = true;
}
} else if (value === undefined) {
resetState = true;
}
if (resetState) {
this.resetInMemoryState();
// Persist even an empty epoch so the catalog identity itself survives a
// clean daemon restart and a corrupt file is repaired once.
this.schedulePersist();
await this.waitForPersistenceWorker();
try {
this.throwIfPersistenceFailed();
} catch (error: unknown) {
this.loaded = false;
throw error;
}
}
this.loaded = true;
}
private resetInMemoryState(): void {
this.catalogId = this.freshCatalogId();
this.catalogRevision = 0;
this.nextCompletionOrder = 0;
this.unreadByIdentity.clear();
this.activeByIdentity.clear();
this.excludedByIdentity.clear();
}
private mutation(identity: SessionUnreadIdentity, unread: SessionUnreadSummary | null): SessionUnreadMutation {
this.catalogRevision = incrementSafe(this.catalogRevision, "Session unread catalog revision exhausted");
return {
event: {
type: "sessions.unread",
catalogId: this.catalogId,
catalogRevision: this.catalogRevision,
sessionId: identity.sessionId,
cwd: identity.cwd,
unread: unread === null ? null : { ...unread },
},
};
}
private trimToLimit(): SessionUnreadMutation[] {
const mutations: SessionUnreadMutation[] = [];
while (this.unreadByIdentity.size > SESSION_UNREAD_LIMIT) {
let oldestKey: string | undefined;
let oldest: SessionUnreadSummary | undefined;
for (const [key, summary] of this.unreadByIdentity) {
if (oldest === undefined || summary.completionOrder < oldest.completionOrder) {
oldestKey = key;
oldest = summary;
}
}
if (oldestKey === undefined || oldest === undefined) break;
this.unreadByIdentity.delete(oldestKey);
mutations.push(this.mutation(oldest, null));
}
return mutations;
}
private assertRevisionCapacity(count: number): void {
if (!Number.isSafeInteger(this.catalogRevision + count)) {
throw new Error("Session unread catalog revision exhausted");
}
}
private schedulePersist(): void {
if (this.persistence === undefined) return;
const generation = incrementSafe(this.persistenceGeneration, "Session unread persistence generation exhausted");
this.persistenceGeneration = generation;
this.pendingPersistence = { generation, state: this.persistedState() };
// Once a save fails, mutations continue replacing the one pending snapshot
// but do not hammer storage; the service's backoff (or an explicit flush)
// owns the next retry attempt.
if (this.persistenceFailure === undefined) this.ensurePersistenceWorker();
}
private ensurePersistenceWorker(): void {
if (this.persistence === undefined || this.persistenceWorker !== undefined || this.pendingPersistence === undefined) return;
const worker = this.runPersistenceWorker();
this.persistenceWorker = worker;
void worker.finally(() => {
if (this.persistenceWorker === worker) this.persistenceWorker = undefined;
});
}
private async runPersistenceWorker(): Promise<void> {
const persistence = this.persistence;
if (persistence === undefined) return;
while (this.pendingPersistence !== undefined) {
const pending = this.pendingPersistence;
this.pendingPersistence = undefined;
try {
await persistence.save(pending.state);
this.durablePersistenceGeneration = pending.generation;
this.persistenceFailure = undefined;
} catch (error: unknown) {
// A newer pending snapshot subsumes this failed one. Otherwise retain
// this exact snapshot so a later flush can retry without a new mutation.
this.pendingPersistence ??= pending;
this.persistenceFailure = { error };
this.reportPersistenceError("save", error);
return;
}
}
}
private async waitForPersistenceWorker(): Promise<void> {
let worker = this.persistenceWorker;
while (worker !== undefined) {
await worker;
worker = this.persistenceWorker;
}
}
private throwIfPersistenceFailed(): void {
const failure = this.persistenceFailure;
if (failure !== undefined) throw failure.error;
}
private persistedState(): SessionUnreadPersistedState {
return {
version: SESSION_UNREAD_STATE_VERSION,
catalogId: this.catalogId,
catalogRevision: this.catalogRevision,
nextCompletionOrder: this.nextCompletionOrder,
sessions: [...this.unreadByIdentity.values()].map((summary) => ({ ...summary })),
};
}
private installPersistedState(state: SessionUnreadPersistedState): void {
this.catalogId = state.catalogId;
this.catalogRevision = state.catalogRevision;
this.nextCompletionOrder = state.nextCompletionOrder;
this.unreadByIdentity.clear();
for (const summary of [...state.sessions].sort((left, right) => left.completionOrder - right.completionOrder)) {
this.unreadByIdentity.set(sessionIdentityKey(summary), { ...summary });
}
}
private freshCatalogId(): string {
return requireCatalogId(this.createCatalogId());
}
private requireLoaded(): void {
if (!this.loaded) throw new Error("Session unread store must be loaded before use");
}
private reportPersistenceError(operation: "load" | "save", error: unknown): void {
try {
this.onPersistenceError(operation, error);
} catch {
// Error reporting must not poison future serialized persistence work.
}
}
}
class SessionUnreadPersistenceCorruptionError extends Error {
constructor(cause: unknown) {
super("Session unread persistence contains invalid JSON", { cause });
}
}
export class FileSessionUnreadPersistence implements SessionUnreadPersistence {
constructor(readonly filePath = defaultSessionUnreadFilePath()) {}
async load(): Promise<unknown> {
let source: string;
try {
source = await readFile(this.filePath, "utf8");
} catch (error: unknown) {
if (isNodeError(error) && error.code === "ENOENT") return undefined;
throw error;
}
try {
const value: unknown = JSON.parse(source);
return value;
} catch (error: unknown) {
throw new SessionUnreadPersistenceCorruptionError(error);
}
}
async save(state: SessionUnreadPersistedState): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.${process.pid.toString()}-${randomUUID()}.tmp`;
try {
await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`, {
encoding: "utf8",
mode: SESSION_UNREAD_FILE_MODE,
flag: "wx",
});
await rename(tempPath, this.filePath);
} finally {
await rm(tempPath, { force: true }).catch(() => undefined);
}
}
}
export function defaultSessionUnreadFilePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
return join(piWebDataDir(env, cwd), "session-unread.json");
}
function parsePersistedState(value: unknown): SessionUnreadPersistedState {
const record = requireRecord(value, "Session unread state must be an object");
if (record["version"] !== SESSION_UNREAD_STATE_VERSION) throw new Error("Unsupported session unread state version");
const catalogId = requireCatalogId(record["catalogId"]);
const catalogRevision = requireNonNegativeSafeInteger(record["catalogRevision"], "catalogRevision");
const nextCompletionOrder = requireNonNegativeSafeInteger(record["nextCompletionOrder"], "nextCompletionOrder");
const rawSessions = record["sessions"];
if (!Array.isArray(rawSessions)) throw new Error("Session unread sessions must be an array");
if (rawSessions.length > SESSION_UNREAD_LIMIT) throw new Error("Session unread state exceeds its session limit");
const sessions = rawSessions.map(parseSummary);
const identities = new Set<string>();
const orders = new Set<number>();
for (const summary of sessions) {
const key = sessionIdentityKey(summary);
if (identities.has(key)) throw new Error("Duplicate session unread identity");
if (orders.has(summary.completionOrder)) throw new Error("Duplicate session unread completion order");
identities.add(key);
orders.add(summary.completionOrder);
}
const maxOrder = sessions.reduce((maximum, summary) => Math.max(maximum, summary.completionOrder), 0);
if (nextCompletionOrder < maxOrder) throw new Error("Session unread completion order is inconsistent");
if (catalogRevision < nextCompletionOrder) throw new Error("Session unread catalog revision is inconsistent");
return {
version: SESSION_UNREAD_STATE_VERSION,
catalogId,
catalogRevision,
nextCompletionOrder,
sessions,
};
}
function parseSummary(value: unknown): SessionUnreadSummary {
const record = requireRecord(value, "Session unread summary must be an object");
const completedAt = requireBoundedNonEmptyString(
record["completedAt"],
"completedAt",
SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH,
);
const completedDate = new Date(completedAt);
if (!Number.isFinite(completedDate.getTime()) || completedDate.toISOString() !== completedAt) {
throw new Error("Session unread completedAt must be a canonical ISO timestamp");
}
return {
sessionId: requireBoundedNonEmptyString(
record["sessionId"],
"sessionId",
SESSION_UNREAD_SESSION_ID_MAX_LENGTH,
),
cwd: requireBoundedNonEmptyString(record["cwd"], "cwd", SESSION_UNREAD_CWD_MAX_LENGTH),
completionOrder: requirePositiveSafeInteger(record["completionOrder"], "completionOrder"),
completedAt,
};
}
function requireRecord(value: unknown, message: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(message);
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value === "") throw new Error(`Session unread ${field} must be a non-empty string`);
return value;
}
function requireBoundedNonEmptyString(value: unknown, field: string, maxLength: number): string {
const parsed = requireNonEmptyString(value, field);
if (parsed.length > maxLength) throw new Error(`Session unread ${field} exceeds its length limit`);
return parsed;
}
function requireCatalogId(value: unknown): string {
return requireBoundedNonEmptyString(value, "catalogId", SESSION_UNREAD_CATALOG_ID_MAX_LENGTH);
}
function requireNonNegativeSafeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`Session unread ${field} must be a non-negative safe integer`);
}
return value;
}
function requirePositiveSafeInteger(value: unknown, field: string): number {
const parsed = requireNonNegativeSafeInteger(value, field);
if (parsed === 0) throw new Error(`Session unread ${field} must be positive`);
return parsed;
}
function requireIdentity(sessionId: string, cwd: string): SessionUnreadIdentity {
return {
sessionId: requireBoundedNonEmptyString(sessionId, "sessionId", SESSION_UNREAD_SESSION_ID_MAX_LENGTH),
cwd: requireBoundedNonEmptyString(cwd, "cwd", SESSION_UNREAD_CWD_MAX_LENGTH),
};
}
function sessionIdentityKey(identity: SessionUnreadIdentity): string {
return JSON.stringify([identity.sessionId, identity.cwd]);
}
function incrementSafe(value: number, message: string): number {
const next = value + 1;
if (!Number.isSafeInteger(next)) throw new Error(message);
return next;
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
+44 -1
View File
@@ -9,6 +9,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsClearQueue: "sessions.clearQueue",
sessionsPersistedState: "sessions.persistedState",
sessionsNotifications: "sessions.notifications",
sessionsUnread: "sessions.unread",
promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage",
@@ -204,6 +205,47 @@ export interface SessionRef {
cwd: string;
}
export const SESSION_UNREAD_LIMIT = 1_000;
export const SESSION_UNREAD_SESSION_ID_MAX_LENGTH = 512;
export const SESSION_UNREAD_CWD_MAX_LENGTH = 32 * 1024;
export const SESSION_UNREAD_CATALOG_ID_MAX_LENGTH = 512;
export const SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH = 64;
export interface SessionUnreadSummary {
sessionId: string;
cwd: string;
/** Monotonic within a catalog and never greater than its containing revision. */
completionOrder: number;
completedAt: string;
}
export interface SessionUnreadCatalogSnapshot {
/** Stable for one persisted catalog epoch; changes when unread state is reset. */
catalogId: string;
/** Monotonic catalog mutation revision; at least every contained completion order. */
catalogRevision: number;
/** Bounded by `SESSION_UNREAD_LIMIT` and ordered newest completion first. */
sessions: SessionUnreadSummary[];
}
export interface SessionUnreadAcknowledgeRequest {
cwd: string;
/** The catalog epoch in which `throughCompletionOrder` was observed. */
catalogId: string;
throughCompletionOrder: number;
}
/** Authoritative delta for one session in the daemon-owned unread catalog. */
export interface SessionUnreadEvent {
type: "sessions.unread";
catalogId: string;
/** At least `unread.completionOrder` when carrying an unread summary. */
catalogRevision: number;
sessionId: string;
cwd: string;
unread: SessionUnreadSummary | null;
}
export const SESSION_NOTIFICATION_LIMIT = 100;
export const SESSION_NOTIFICATION_MESSAGE_BYTES = 8 * 1024;
@@ -925,5 +967,6 @@ type SessionUiEventBody =
export type GlobalSessionEvent =
| Extract<SessionUiEventBody, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>
| SessionNotificationSummaryEvent;
| SessionNotificationSummaryEvent
| SessionUnreadEvent;
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;
+20
View File
@@ -70,6 +70,26 @@ describe("PI WEB capabilities", () => {
})).toContain(notifications);
});
it("negotiates daemon-authoritative unread state only when both runtimes support it", () => {
const unread = PI_WEB_CAPABILITIES.sessionsUnread;
expect(WEB_RUNTIME_CAPABILITIES).toContain(unread);
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(unread);
expect(parseKnownPiWebCapabilities([unread, "future.capability"])).toEqual([unread]);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [unread] },
sessiond: { available: true, capabilities: [] },
})).not.toContain(unread);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [] },
sessiond: { available: true, capabilities: [unread] },
})).not.toContain(unread);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [unread] },
sessiond: { available: true, capabilities: [unread] },
})).toContain(unread);
});
it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
+3
View File
@@ -14,6 +14,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.sessionsNotifications,
PI_WEB_CAPABILITIES.sessionsUnread,
PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage,
@@ -29,6 +30,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.sessionsNotifications,
PI_WEB_CAPABILITIES.sessionsUnread,
PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[];
@@ -40,6 +42,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsUnread]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
+2
View File
@@ -46,6 +46,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/activity" },
{ method: "GET", path: "/sessions" },
{ method: "POST", path: "/sessions" },
{ method: "GET", path: "/sessions/unread" },
{ method: "GET", path: "/sessions/notifications" },
{ method: "POST", path: "/sessions/cleanup/preview" },
{ method: "POST", path: "/sessions/cleanup" },
@@ -55,6 +56,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/sessions/:sessionId/notifications" },
{ method: "POST", path: "/sessions/:sessionId/notifications/dismiss" },
{ method: "POST", path: "/sessions/:sessionId/notifications/dismiss-all" },
{ method: "POST", path: "/sessions/:sessionId/unread/acknowledge" },
{ method: "GET", path: "/sessions/:sessionId/status" },
{ method: "GET", path: "/sessions/:sessionId/stream-snapshot" },
{ method: "GET", path: "/sessions/:sessionId/models" },