feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
+91 -13
View File
@@ -1,5 +1,6 @@
import type { Message } from "./api";
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
@@ -19,15 +20,18 @@ export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
};
export type AgentReadState = { cursor: string; updatedAt: string | null };
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
export type AgentRoomState = {
conversationId: string | null; messages: Message[]; cursor: string;
readCursor: string; activity: AgentActivity | null;
/** undefined before the first read snapshot; null pins a legacy source-less session. */
sourceId?: string | null;
};
export type AgentRoomEvent =
| ({ type: "messages" } & AgentMessagesPage)
| { type: "activity"; conversationId: string; activity: AgentActivity }
| { type: "ready"; conversationId?: string }
| ({ type: "read_state" } & AgentReadState)
| { type: "pong" | "heartbeat" }
| { type: "error"; error?: string; message?: string };
@@ -81,6 +85,17 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
export const createAgentRoomState = (): AgentRoomState => ({
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
});
export class AgentReadSourceChangedError extends Error {
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
}
function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCursor" | "messages">, read: AgentReadState): string | null {
if (read.sourceId != null && !agentPushSourceId(read.sourceId)) throw new AgentReadSourceChangedError();
const incoming = read.sourceId ?? null;
if (state.sourceId !== undefined && state.sourceId !== incoming) throw new AgentReadSourceChangedError();
// A source cannot be attached retroactively to already rendered unknown history.
if (state.sourceId === undefined && incoming !== null && (state.cursor !== "0" || state.readCursor !== "0" || state.messages.length > 0)) throw new AgentReadSourceChangedError();
return incoming;
}
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
@@ -88,10 +103,12 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
}
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
const sourceId = readSource(state, read);
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
}
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
if (event.type === "messages") return applyAgentPage(state, event);
if (event.type === "read_state") return applyAgentReadState(state, event);
if (event.type !== "activity") return state;
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
return { ...state, conversationId: event.conversationId, activity: event.activity };
@@ -110,7 +127,10 @@ export function agentDisplayMessages(state: AgentRoomState): Message[] {
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
const event = object(JSON.parse(raw));
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
if (!["messages", "activity", "ready", "pong", "heartbeat", "error", "read_state"].includes(event.type)) return null;
if (event.type === "read_state" && (!agentPushCursor(event.cursor)
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
if (event.type === "messages") {
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
@@ -123,8 +143,9 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
return event as AgentRoomEvent;
}
export function agentAuthFrame(token: string | null, cursor: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
@@ -146,10 +167,10 @@ export class AgentRoomClient {
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
let value: unknown;
try { value = await response.json(); }
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
catch { throw Object.assign(new Error(`Agent API returned ${response.status} without a JSON response.`), { status: response.status }); }
if (!response.ok) {
const error = object(value);
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
throw Object.assign(new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`), { status: response.status });
}
return value as T;
}
@@ -158,8 +179,8 @@ export class AgentRoomClient {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
}
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
@@ -215,18 +236,75 @@ export function createAgentReadWriter(options: {
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
}) {
let desired = "0";
let stopped = false;
let running: Promise<void> | undefined;
const cancel = () => { stopped = true; desired = "0"; };
const flush = (): Promise<void> => {
if (stopped) return Promise.resolve();
if (running) return running;
running = (async () => {
while (BigInt(desired) > BigInt(options.current())) {
while (!stopped && BigInt(desired) > BigInt(options.current())) {
const captured = desired;
const saved = await options.save(captured);
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
if (stopped) return;
try {
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
// A rejected/source-mismatched acknowledgment must never become a PUT loop.
if (BigInt(options.current()) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
} catch (error) { cancel(); throw error; }
}
})().finally(() => { running = undefined; });
return running;
};
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
return { request(cursor: string) { if (!stopped) desired = advanceAgentCursor(desired, cursor); return flush(); }, flush, cancel };
}
/** Pin reads to the source that owns this rendered room, including queued writes. */
export function createAgentReadSession(options: {
initial: AgentRoomState;
save: (cursor: string, sourceId: string | null) => Promise<AgentReadState>;
apply: (read: AgentReadState) => void;
invalidated?: (error: AgentReadSourceChangedError) => void;
}) {
let binding = options.initial.sourceId, acknowledged = options.initial.readCursor;
let failure: AgentReadSourceChangedError | undefined;
const invalidate = () => {
if (!failure) { failure = new AgentReadSourceChangedError(); writer.cancel(); options.invalidated?.(failure); }
return failure;
};
const receive = (read: AgentReadState) => {
if (failure) throw failure;
try {
const nextSource = readSource({ ...options.initial, sourceId: binding, readCursor: acknowledged }, read);
const nextCursor = advanceAgentCursor(acknowledged, read.cursor);
binding = nextSource; acknowledged = nextCursor;
options.apply({ ...read, sourceId: binding });
} catch (error) { if (error instanceof AgentReadSourceChangedError) throw invalidate(); throw error; }
};
const writer = createAgentReadWriter({
current: () => acknowledged,
save: async cursor => {
if (failure || binding === undefined) throw invalidate();
const source = binding;
try { return await options.save(cursor, source); }
catch (error) {
if (error instanceof AgentReadSourceChangedError || object(error)?.status === 409) throw invalidate();
throw error;
}
},
apply: receive,
});
return {
receive,
request(cursor: string, renderedSourceId: string | null | undefined) {
if (failure) return Promise.reject(failure);
if (binding === undefined || renderedSourceId !== binding) return Promise.reject(invalidate());
return writer.request(cursor);
},
flush: writer.flush,
cancel: writer.cancel,
sourceId: () => binding,
readCursor: () => acknowledged,
};
}