feat(sessions): add daemon notification inbox protocol

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 01:28:45 +02:00
parent d77ca66f28
commit 6e09df8329
21 changed files with 2290 additions and 59 deletions
+171 -1
View File
@@ -2,10 +2,24 @@ 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 type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js";
import type {
MessagePage,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
SessionBulkMutationRef,
SessionCleanupExecuteResponse,
SessionCleanupPreviewResponse,
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
SessionRef,
SessionStatus,
SessionStreamSnapshot,
} from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import { testModelRuntime } from "./piSessionService.testSupport.js";
import { SessionNotificationStore } from "./sessionNotificationStore.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
@@ -31,6 +45,130 @@ afterEach(async () => {
});
describe("session routes", () => {
it("returns notification catalog and selected-inbox snapshots with required cwd context", 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/notifications" });
const inbox = await routeApp.inject({ method: "GET", url: `/sessions/session-1/notifications?cwd=${encodeURIComponent(requestCwd)}` });
expect(catalog.statusCode).toBe(200);
expect(catalog.json()).toEqual({ daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] });
expect(inbox.statusCode).toBe(200);
expect(inbox.json()).toMatchObject({ daemonInstanceId: "daemon-test", summary: { sessionId: "session-1", cwd: requestCwd } });
expect(routeService.notificationInboxCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
} 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);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const dismiss = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss",
payload: { cwd: requestCwd, daemonInstanceId: "daemon-test", notificationId: "notice-1" },
});
const dismissAll = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss-all",
payload: { cwd: requestCwd, daemonInstanceId: "daemon-test", throughOrder: 12, throughOverflowWatermark: 3 },
});
expect(dismiss.statusCode).toBe(200);
expect(dismissAll.statusCode).toBe(200);
expect(routeService.dismissNotificationCalls).toEqual([{
ref: { id: "session-1", cwd: requestCwd },
request: { daemonInstanceId: "daemon-test", notificationId: "notice-1" },
}]);
expect(routeService.dismissAllNotificationCalls).toEqual([{
ref: { id: "session-1", cwd: requestCwd },
request: { daemonInstanceId: "daemon-test", throughOrder: 12, throughOverflowWatermark: 3 },
}]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("keeps stale notification mutations harmless and rejects mismatched ownership", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const requestCwd = resolve("/repo");
const notificationStore = new SessionNotificationStore({ daemonInstanceId: "daemon-current" });
const registration = notificationStore.registerSession("session-1", requestCwd);
notificationStore.addNotification(registration.generation, "keep", "warning");
const routeService = new PiSessionService(eventHub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
notificationStore,
sessionManager: new RejectingSessionManager(),
heartbeatIntervalMs: 60_000,
});
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const stale = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss-all",
payload: { cwd: requestCwd, daemonInstanceId: "daemon-old", throughOrder: Number.MAX_SAFE_INTEGER, throughOverflowWatermark: Number.MAX_SAFE_INTEGER },
});
const mismatch = await routeApp.inject({ method: "GET", url: `/sessions/session-1/notifications?cwd=${encodeURIComponent(resolve("/other"))}` });
const missing = await routeApp.inject({ method: "GET", url: `/sessions/missing/notifications?cwd=${encodeURIComponent(requestCwd)}` });
expect(stale.statusCode).toBe(200);
expect(stale.json()).toMatchObject({ summary: { retainedCount: 1, inboxRevision: 1 } });
expect(mismatch.statusCode).toBe(400);
expect(mismatch.json()).toEqual({ error: "Session cwd mismatch" });
expect(missing.statusCode).toBe(404);
expect(missing.json()).toEqual({ error: "Session not found" });
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed notification requests before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const missingCwd = await routeApp.inject({ method: "GET", url: "/sessions/session-1/notifications" });
const unsafeCutoff = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/notifications/dismiss-all",
payload: { cwd: "/repo", daemonInstanceId: "daemon-test", throughOrder: Number.MAX_SAFE_INTEGER + 1, throughOverflowWatermark: 0 },
});
expect(missingCwd.statusCode).toBe(400);
expect(missingCwd.json()).toEqual({ error: "cwd field must be a string" });
expect(unsafeCutoff.statusCode).toBe(400);
expect(unsafeCutoff.json()).toEqual({ error: "throughOrder field must be a non-negative safe integer" });
expect(routeService.notificationInboxCalls).toEqual([]);
expect(routeService.dismissAllNotificationCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects prompt payloads that omit text without opening a session", async () => {
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
@@ -385,6 +523,9 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly reloadCalls: SessionRouteLookup[] = [];
readonly clearQueueCalls: SessionRouteLookup[] = [];
readonly dismissWarningCalls: { lookup: SessionRouteLookup; dismissId: string }[] = [];
readonly notificationInboxCalls: SessionRef[] = [];
readonly dismissNotificationCalls: { ref: SessionRef; request: Omit<SessionNotificationDismissRequest, "cwd"> }[] = [];
readonly dismissAllNotificationCalls: { ref: SessionRef; request: Omit<SessionNotificationDismissAllRequest, "cwd"> }[] = [];
dismissWarningError: Error | undefined;
messagesResponse: unknown[] | MessagePage = [];
streamSnapshotResponse: SessionStreamSnapshot = { seq: 0, partial: null };
@@ -426,6 +567,25 @@ class CapturingRouteSessionService implements SessionRouteService {
return Promise.resolve();
}
notificationCatalog() {
return { daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] };
}
notificationInbox(ref: SessionRef): SessionNotificationInboxSnapshot {
this.notificationInboxCalls.push(ref);
return notificationSnapshot(ref);
}
dismissNotification(ref: SessionRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot {
this.dismissNotificationCalls.push({ ref, request });
return notificationSnapshot(ref);
}
dismissAllNotifications(ref: SessionRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot {
this.dismissAllNotificationCalls.push({ ref, request });
return notificationSnapshot(ref);
}
list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); }
@@ -542,6 +702,16 @@ class RejectingSessionManager implements PiSessionManagerGateway {
}
}
function notificationSnapshot(ref: SessionRef): SessionNotificationInboxSnapshot {
return {
daemonInstanceId: "daemon-test",
catalogRevision: 0,
summary: { sessionId: ref.id, cwd: ref.cwd, inboxRevision: 0, retainedCount: 0, discardedCount: 0 },
notifications: [],
dismissThrough: { order: 0, overflowWatermark: 0 },
};
}
function sessionIdFromLookup(lookup: SessionRouteLookup): string {
return typeof lookup === "string" ? lookup : lookup.id;
}