From 99b300855eb955e8b98fdd18ddadd0ca2bb61342 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 2 Nov 2026 14:59:32 -0500 Subject: [PATCH] Gateway: review fixes for stale summaries, history cursors that survive a moving window, timeouts on T3 calls, push size, T3's snooze rules, and input checks --- docs/api.md | 6 ++- gateway/src/api.ts | 8 +++- gateway/src/awareness.ts | 17 ++++++++ gateway/src/main.ts | 10 ++++- gateway/src/notify.ts | 15 +++++-- gateway/src/push.ts | 37 +++++++++++++----- gateway/src/t3/host.ts | 4 ++ gateway/src/t3/rpc.ts | 28 ++++++++++++-- gateway/src/t3/types.ts | 4 ++ gateway/src/views.ts | 71 ++++++++++++++++++++-------------- gateway/test/awareness.test.ts | 24 +++++++++++- gateway/test/push.test.ts | 22 ++++++++++- gateway/test/views.test.ts | 20 +++++++--- 13 files changed, 208 insertions(+), 58 deletions(-) diff --git a/docs/api.md b/docs/api.md index fa27a69..63b3a5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -103,7 +103,7 @@ directory), the search, the subagent's title or the tool's name, trimmed to abou Monitors are left out, since they run for hours. It is null when no step is in progress. The gateway rereads a running thread at most every 5 seconds, so it can lag that much. -`changes` totals the files the thread's latest run changed. It is null when the run changed no +`changes` totals the files the thread's latest run changed, counting only edits that ran. It is null when the run changed no files, and, like `lastMessage`, until the gateway has read the thread. ``` @@ -234,7 +234,9 @@ POST /v1/threads/:hostId/:threadId/unsnooze ``` Snoozing hides a finished thread from Recent until `until`, which must be in the next 30 days. Then -it comes back on its own. A snoozed thread that starts working or waiting on you shows as usual. +it comes back on its own. A snoozed thread that starts working or waiting on you shows as usual, +and one that fails after it was snoozed comes back to Recent, the same rules T3 uses. A snoozed +thread that finishes again does not push. Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you. diff --git a/gateway/src/api.ts b/gateway/src/api.ts index f71fa79..ee9da46 100644 --- a/gateway/src/api.ts +++ b/gateway/src/api.ts @@ -340,7 +340,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} GET: authed<"/v1/threads/:hostId/:threadId/history">(async (req) => { const { host, thread } = findThread(req.params.hostId, req.params.threadId); const cursor = new URL(req.url).searchParams.get("cursor") ?? ""; - if (!/^[wh]./.test(cursor)) throw new HttpError(400, "Send the earlierCursor from the thread."); + if (!/^(w.{1,256}|h.{1,4096})$/.test(cursor)) throw new HttpError(400, "Send the earlierCursor from the thread."); return json(await loadHistory(host, thread, cursor)); }), }, @@ -462,12 +462,16 @@ const isAnswers = (value: unknown): value is Record = (answer) => typeof answer === "string" || (Array.isArray(answer) && answer.every((item) => typeof item === "string")), ); +/** The request's JSON object. Anything else, such as `null` or an array, is a 400. */ async function readBody(req: Request): Promise & T> { + let body: unknown; try { - return (await req.json()) as Partial & T; + body = await req.json(); } catch { throw new HttpError(400, "The body must be JSON."); } + if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError(400, "The body must be a JSON object."); + return body as Partial & T; } /** A thread title from the first line of the prompt. T3 replaces it with a generated one. */ diff --git a/gateway/src/awareness.ts b/gateway/src/awareness.ts index d493ea5..c6ff6fc 100644 --- a/gateway/src/awareness.ts +++ b/gateway/src/awareness.ts @@ -75,6 +75,23 @@ export function resolvePhase(thread: AwarenessFields): Phase | null { // Commands and monitors are as often a dev server or its watchdog as a wait, so they hold // completion only when the agent marks them awaited. +/** + * Whether a snooze still hides the thread, as T3's own `isSnoozed` decides: until its time, unless + * the thread waits on someone, or failed after it was snoozed. + */ +export function isSnoozed( + thread: Pick, + now = Date.now(), +): boolean { + const until = thread.snoozedUntil ? Date.parse(thread.snoozedUntil) : NaN; + if (!(until > now)) return false; + if (thread.pendingRuntimeRequest !== null || (thread.pendingGuestMessageCount ?? 0) > 0) return false; + const snoozedAt = thread.snoozedAt ? Date.parse(thread.snoozedAt) : null; + const completedAt = thread.latestRunCompletedAt ? Date.parse(thread.latestRunCompletedAt) : null; + const wokeOnError = thread.status === "failed" && (snoozedAt === null || (completedAt !== null && completedAt > snoozedAt)); + return !wokeOnError; +} + function backgroundWorkHoldsCompletion(tasks: NonNullable): boolean { return tasks.some((task) => task.awaited === true || task.kind === "subagent" || task.kind === "background_task"); } diff --git a/gateway/src/main.ts b/gateway/src/main.ts index 5ec63f0..ebc7b80 100644 --- a/gateway/src/main.ts +++ b/gateway/src/main.ts @@ -29,7 +29,15 @@ if (!apns) log("APNs is not configured. Pushes will only be logged."); const pusher = new Pusher(db, apns, log); const notifier = new Notifier(pusher.sendAll, cache, log); -const hub = new Hub(db, { onThreadChange: notifier.onThreadChange, onStatus: notifier.onHostStatus, log }); +const hub = new Hub(db, { + onThreadChange: (change) => { + // A removed thread's summary would otherwise stay cached for the gateway's lifetime. + if (!change.next && change.previous) cache.forget(change.host, change.previous.id); + notifier.onThreadChange(change); + }, + onStatus: notifier.onHostStatus, + log, +}); hub.sync(); setInterval(() => hub.sync(), HOST_SYNC_MS); notifier.checkTokenExpiry(hub.rows()); diff --git a/gateway/src/notify.ts b/gateway/src/notify.ts index e65156b..53b44c6 100644 --- a/gateway/src/notify.ts +++ b/gateway/src/notify.ts @@ -3,7 +3,7 @@ // thread already waiting counts as a change, so each request notifies once. Nothing notifies while // a host loads its first snapshot, so a restart never replays old news. -import { threadPhase, type Phase } from "./awareness.ts"; +import { isSnoozed, threadPhase, type Phase } from "./awareness.ts"; import type { PendingRequest, PushCategory, PushPayload } from "./contract.ts"; import type { HostRow } from "./db.ts"; import type { T3Host, ThreadChange } from "./t3/host.ts"; @@ -25,6 +25,9 @@ export type SendPush = (push: Push) => Promise; const NOTIFYING: ReadonlySet = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]); const BODY_CHARS = 180; +const TITLE_CHARS = 80; +/** An option value longer than this can't be sent whole, so the question goes without buttons. */ +const OPTION_VALUE_CHARS = 200; const SUMMARY_WAIT_MS = 3_000; const TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000; @@ -51,6 +54,8 @@ export class Notifier { const before = this.keys.get(id); this.keys.set(id, key); if (initial || phase === null || !NOTIFYING.has(phase) || key === before) return; + // T3 keeps a snoozed thread asleep through background wakes, so its finishing again is not news. + if (phase === "completed" && isSnoozed(next)) return; void this.notify(host, next, phase).catch((error: unknown) => this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`), ); @@ -97,9 +102,9 @@ export class Notifier { const parent = thread.delegatedFrom?.title; const text = body(phase, thread.lastError ?? null, summary); await this.send({ - title: parent ?? thread.title, + title: trimText(parent ?? thread.title, TITLE_CHARS), subtitle: project ? `${host.label} · ${project}` : host.label, - body: parent ? `Subagent ${thread.title}: ${text}` : text, + body: parent ? `Subagent ${trimText(thread.title, TITLE_CHARS)}: ${text}` : text, category: category(phase), fromSubagent: parent !== undefined, data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}), ...pushQuestion(phase, summary) }, @@ -117,10 +122,12 @@ function pushQuestion(phase: Phase, summary: ThreadSummary | undefined): Pick value.length > OPTION_VALUE_CHARS)) return {}; return { question: { id: question.id, - options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value: value.slice(0, 200) })), + options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value })), allowCustomAnswer: question.allowCustomAnswer, }, }; diff --git a/gateway/src/push.ts b/gateway/src/push.ts index 75b4eb5..116f2da 100644 --- a/gateway/src/push.ts +++ b/gateway/src/push.ts @@ -6,6 +6,7 @@ import type { Apns } from "./apns.ts"; import type { NotificationPrefs, PushResult } from "./contract.ts"; import type { Db, DeviceRow } from "./db.ts"; import type { Push } from "./notify.ts"; +import { trimText } from "./views.ts"; const ALL_ON: NotificationPrefs = { needsMe: true, finished: true, failed: true, subagents: true }; @@ -30,6 +31,32 @@ export function wants(prefs: NotificationPrefs, push: Push): boolean { } } +/** APNs refuses a payload over 4096 bytes. This leaves room for the JSON APNs adds. */ +const PAYLOAD_LIMIT = 3900; + +/** + * The APNs payload for a push, kept under APNs' size limit. Long titles and bodies in multi-byte + * scripts can overflow it, so the question's buttons go first, then the body is cut. + */ +export function apnsPayload(push: Push) { + const build = (data: Push["data"], body: string) => ({ + aps: { + alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body }, + sound: "default", + category: push.category, + "thread-id": data.threadId ?? data.hostId ?? push.category, + }, + ...data, + }); + const fits = (payload: object) => Buffer.byteLength(JSON.stringify(payload)) <= PAYLOAD_LIMIT; + let payload = build(push.data, push.body); + if (fits(payload)) return payload; + const { question: _, ...plain } = push.data; + payload = build(plain, push.body); + for (let chars = 120; !fits(payload) && chars >= 20; chars -= 50) payload = build(plain, trimText(push.body, chars)); + return payload; +} + export class Pusher { /** Apple's answer to the most recent push sent to any watch. */ last: PushResult | null = null; @@ -63,15 +90,7 @@ export class Pusher { if (!device.apns_token || !device.apns_env) { return { ok: false, at, status: 0, reason: "This watch has not registered for notifications." }; } - const payload = { - aps: { - alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body: push.body }, - sound: "default", - category: push.category, - "thread-id": push.data.threadId ?? push.data.hostId ?? push.category, - }, - ...push.data, - }; + const payload = apnsPayload(push); const answer = await this.apns.send({ deviceToken: device.apns_token, environment: device.apns_env, diff --git a/gateway/src/t3/host.ts b/gateway/src/t3/host.ts index 130296d..5654d36 100644 --- a/gateway/src/t3/host.ts +++ b/gateway/src/t3/host.ts @@ -74,6 +74,9 @@ export class HostError extends Error { } } +/** How long a read from a T3 server may take before the gateway gives up on it. */ +const HTTP_TIMEOUT_MS = 15_000; + export class T3Host { status: HostStatus = "connecting"; lastError: string | null = null; @@ -213,6 +216,7 @@ export class T3Host { private async http(path: string, init: RequestInit = {}): Promise { const response = await fetch(new URL(path, this.activeUrl), { + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), ...init, headers: { authorization: `Bearer ${this.config.token}`, diff --git a/gateway/src/t3/rpc.ts b/gateway/src/t3/rpc.ts index 69938eb..d71bc23 100644 --- a/gateway/src/t3/rpc.ts +++ b/gateway/src/t3/rpc.ts @@ -26,6 +26,9 @@ export class RpcError extends Error { } } +/** How long a unary call waits for its answer. */ +const CALL_TIMEOUT_MS = 20_000; + interface Pending { onChunk?: (value: unknown) => void; resolve: (value: unknown) => void; @@ -83,11 +86,28 @@ export class RpcSocket { }); } - /** Calls a unary method and resolves with its success value. */ - call(tag: string, payload: unknown): Promise { + /** Calls a unary method and resolves with its success value. Fails after `timeoutMs`. */ + call(tag: string, payload: unknown, timeoutMs = CALL_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { - const id = this.request(tag, payload, { resolve: resolve as (v: unknown) => void, reject }); - if (id === null) reject(new RpcError("socket is not open")); + let timer: ReturnType | undefined; + const id = this.request(tag, payload, { + resolve: (value) => { + clearTimeout(timer); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + if (id === null) { + reject(new RpcError("socket is not open")); + return; + } + timer = setTimeout(() => { + this.pending.delete(id); + reject(new RpcError(`${tag} got no answer in ${timeoutMs / 1000} s`)); + }, timeoutMs); }); } diff --git a/gateway/src/t3/types.ts b/gateway/src/t3/types.ts index d0e1c99..a8dd755 100644 --- a/gateway/src/t3/types.ts +++ b/gateway/src/t3/types.ts @@ -74,6 +74,10 @@ export interface ThreadShell { settledOverride?: Open<"settled"> | null; /** Set while the user has snoozed the thread. It stays set after the time passes. */ snoozedUntil?: string | null; + snoozedAt?: string | null; + latestRunCompletedAt?: string | null; + /** Messages from guests waiting for the thread. One wakes a snoozed thread. */ + pendingGuestMessageCount?: number; } export interface ShellSnapshot { diff --git a/gateway/src/views.ts b/gateway/src/views.ts index 5688aab..7088eb1 100644 --- a/gateway/src/views.ts +++ b/gateway/src/views.ts @@ -3,7 +3,7 @@ // are large (hundreds of KB), so they are reduced to a small summary and cached per thread until // the thread's `updatedAt` moves. -import { resolvePhase, threadPhase, type Phase } from "./awareness.ts"; +import { isSnoozed, resolvePhase, threadPhase, type Phase } from "./awareness.ts"; import type { Activity, AgentProvider, @@ -76,7 +76,7 @@ export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSu request: pendingRequest(thread, turnItems), activity: currentActivity(turnItems), changedFiles: latestChanges(turnItems), - earlierCursor: windowCursor(start, bounded), + earlierCursor: windowCursor(start, visible, bounded), }; } @@ -90,12 +90,13 @@ const toMessage = (message: Pick` means the bounded window's messages before index n, -// which the thread detail leaves out past its 20. `h` is T3's cursor for older pages. +// History cursors are the gateway's own. `w` means the bounded window's messages before +// that message, which the thread detail leaves out past its 20. The window moves as the thread +// grows, so the cursor names a message, not a position. `h` is T3's cursor for older pages. -/** The cursor for what comes before the window's message `start`. */ -function windowCursor(start: number, bounded: BoundedThread): string | null { - if (start > 0) return `w${start}`; +/** The cursor for what comes before `visible[start]`. */ +function windowCursor(start: number, visible: ConversationMessage[], bounded: BoundedThread): string | null { + if (start > 0) return `w${visible[start]!.id}`; return bounded.hasMoreHistory && bounded.historyCursor ? `h${bounded.historyCursor}` : null; } @@ -105,11 +106,17 @@ function windowCursor(start: number, bounded: BoundedThread): string | null { */ export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: string): Promise { if (cursor.startsWith("w")) { - const end = Number(cursor.slice(1)); const bounded = await host.bounded(thread.id); const visible = visibleMessages(bounded.projection.messages); - const start = Math.max(0, Math.min(end, visible.length) - MESSAGES_PER_THREAD); - return { messages: visible.slice(start, end).map(toMessage), earlierCursor: windowCursor(start, bounded) }; + const end = visible.findIndex((message) => message.id === cursor.slice(1)); + if (end < 0) { + // The message left the window. T3's pages pick up before the window, and the watch drops + // any message it already has. + const before = windowCursor(0, visible, bounded); + return before ? loadHistory(host, thread, before) : { messages: [], earlierCursor: null }; + } + const start = Math.max(0, end - MESSAGES_PER_THREAD); + return { messages: visible.slice(start, end).map(toMessage), earlierCursor: windowCursor(start, visible, bounded) }; } let next: string | null = cursor.slice(1); for (let pages = 0; next && pages < 3; pages++) { @@ -134,13 +141,13 @@ export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: str return { messages: [], earlierCursor: next && `h${next}` }; } -/** The files the latest run changed, summed per file, most lines changed first. */ +/** The files the latest run changed, summed per file, most lines changed first. Only edits that ran count. */ export function latestChanges(turnItems: TurnItem[]): ChangedFile[] { const runId = turnItems.findLast((item) => item.runId)?.runId; if (!runId) return []; const files = new Map(); for (const item of turnItems) { - if (item.type !== "file_change" || item.runId !== runId || !item.fileName) continue; + if (item.type !== "file_change" || item.runId !== runId || item.status !== "completed" || !item.fileName) continue; const file = files.get(item.fileName) ?? { path: item.fileName, name: item.fileName.split("/").pop()!, additions: 0, deletions: 0 }; file.additions += item.additions ?? 0; file.deletions += item.deletions ?? 0; @@ -271,26 +278,34 @@ export class SummaryCache { constructor(private readonly log: (message: string) => void = () => {}) {} - /** The cached summary, if it matches the thread's current version and, while running, is recent. */ + /** + * The summary to show: one matching the thread's current version, or for a running thread the + * last one read, which is at most a few seconds behind. Its request can be stale, so callers + * match it against the thread's pending request. + */ peek(host: T3Host, thread: ThreadShell): ThreadSummary | undefined { const entry = this.entries.get(key(host, thread.id)); - if (entry?.updatedAt !== thread.updatedAt) return undefined; - if (thread.activeRunId !== null && Date.now() - entry.fetchedAt > RUNNING_SUMMARY_MS) return undefined; - return entry.summary; + if (!entry) return undefined; + return entry.updatedAt === thread.updatedAt || thread.activeRunId !== null ? entry.summary : undefined; + } + + /** Whether the cached summary is current: the same version and, while running, under 5 s old. */ + fresh(host: T3Host, thread: ThreadShell): boolean { + const entry = this.entries.get(key(host, thread.id)); + if (entry?.updatedAt !== thread.updatedAt) return false; + return thread.activeRunId === null || Date.now() - entry.fetchedAt <= RUNNING_SUMMARY_MS; } get(host: T3Host, thread: ThreadShell): Promise { - const cached = this.peek(host, thread); - if (cached) return Promise.resolve(cached); + if (this.fresh(host, thread)) return Promise.resolve(this.entries.get(key(host, thread.id))!.summary); const k = `${key(host, thread.id)}@${thread.updatedAt}`; - const fetchedAt = Date.now(); const running = this.inflight.get(k); if (running) return running; const promise = host .bounded(thread.id) .then((bounded) => { const summary = summarize(bounded, thread); - this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt, summary }); + this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt: Date.now(), summary }); return summary; }) .finally(() => this.inflight.delete(k)); @@ -384,6 +399,10 @@ function attachDelegates(candidates: Candidate[], hosts: T3Host[]): Candidate[] const requestAge = (thread: ThreadShell) => thread.pendingRuntimeRequest?.createdAt ?? ""; +/** The summary's request, if it is still the one the thread waits on. */ +const currentRequest = (thread: ThreadShell, summary: ThreadSummary | undefined): PendingRequest | null => + summary?.request && summary.request.id === thread.pendingRuntimeRequest?.id ? summary.request : null; + function feedItem( host: T3Host, thread: ThreadShell, @@ -410,7 +429,7 @@ function feedItem( deletions: summary.changedFiles.reduce((sum, file) => sum + file.deletions, 0), } : null, - request: (via ? via.summary : summary)?.request ?? null, + request: via ? currentRequest(via.thread, via.summary) : currentRequest(thread, summary), via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null, }; } @@ -455,14 +474,14 @@ export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promis } } const read = [next, current].filter((pick) => pick !== null); - const missing = read.filter(({ host, thread }) => !cache.peek(host, thread)); + const missing = read.filter(({ host, thread }) => !cache.fresh(host, thread)); if (missing.length > 0) { await Promise.race([ Promise.all(missing.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined))), Bun.sleep(SUMMARY_WAIT_MS), ]); } - const request = next && cache.peek(next.host, next.thread)?.request; + const request = next && currentRequest(next.thread, cache.peek(next.host, next.thread)); const asked = request?.prompt ?? request?.questions[0]?.question; return { needsYou, @@ -484,10 +503,6 @@ export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promis }; } -/** True while a snooze is set and its time has not come. */ -const isSnoozed = (thread: ThreadShell, now: number) => - !!thread.snoozedUntil && Date.parse(thread.snoozedUntil) > now; - /** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */ export function feedCandidates(hosts: T3Host[], now = Date.now()) { const found: Candidate[] = []; @@ -530,7 +545,7 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise (candidate.via ? [candidate, candidate.via] : [candidate])); const missing = shown - .filter(({ host, thread }) => !cache.peek(host, thread)) + .filter(({ host, thread }) => !cache.fresh(host, thread)) .map(({ host, thread }) => cache.get(host, thread).catch(() => undefined)); if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]); diff --git a/gateway/test/awareness.test.ts b/gateway/test/awareness.test.ts index ad6d3d3..9dded08 100644 --- a/gateway/test/awareness.test.ts +++ b/gateway/test/awareness.test.ts @@ -1,7 +1,7 @@ // Cases ported from T3 Code's agentAwareness.test.ts, so the gateway notifies when T3's own app would. import { describe, expect, it } from "bun:test"; -import { threadPhase } from "../src/awareness.ts"; +import { isSnoozed, threadPhase } from "../src/awareness.ts"; import type { ThreadShell } from "../src/t3/types.ts"; const thread = (overrides: Partial = {}): ThreadShell => ({ @@ -104,3 +104,25 @@ describe("threadPhase", () => { expect(threadPhase(thread({ delegatedFrom, pendingRuntimeRequest: request("command") }))).toBe("waiting_for_approval"); }); }); + +describe("isSnoozed", () => { + const now = Date.parse("2026-11-01T12:00:00.000Z"); + const snoozed = { + snoozedUntil: "2026-11-01T18:00:00.000Z", + snoozedAt: "2026-11-01T10:00:00.000Z", + latestRunCompletedAt: "2026-11-01T09:00:00.000Z", + status: "completed", + pendingRuntimeRequest: null, + } as const; + + it("hides a thread until its time, as T3 does", () => { + expect(isSnoozed(snoozed, now)).toBe(true); + expect(isSnoozed({ ...snoozed, snoozedUntil: "2026-11-01T11:00:00.000Z" }, now)).toBe(false); + }); + + it("wakes a thread that waits on someone or failed after the snooze", () => { + expect(isSnoozed({ ...snoozed, pendingRuntimeRequest: { id: "r", kind: "command", createdAt: "" } }, now)).toBe(false); + expect(isSnoozed({ ...snoozed, status: "failed", latestRunCompletedAt: "2026-11-01T11:00:00.000Z" }, now)).toBe(false); + expect(isSnoozed({ ...snoozed, status: "failed" }, now)).toBe(true); + }); +}); diff --git a/gateway/test/push.test.ts b/gateway/test/push.test.ts index 3706e5f..c2eee75 100644 --- a/gateway/test/push.test.ts +++ b/gateway/test/push.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import type { DeviceRow } from "../src/db.ts"; import type { Push } from "../src/notify.ts"; -import { notificationPrefs, wants } from "../src/push.ts"; +import { apnsPayload, notificationPrefs, wants } from "../src/push.ts"; const push = (category: Push["category"], fromSubagent = false): Push => ({ title: "Receipt OCR", @@ -35,3 +35,23 @@ describe("wants", () => { expect(wants({ needsMe: false, finished: false, failed: false, subagents: false }, push("host"))).toBe(true); }); }); + +describe("apnsPayload", () => { + it("drops the question's buttons, then cuts the body, to stay under APNs' 4 KB", () => { + const wide = "界".repeat(400); + const big: Push = { + ...push("input"), + title: "界".repeat(80), + body: wide, + data: { + hostId: "host-1", + threadId: "thread-1", + question: { id: "q", options: [1, 2, 3, 4].map((n) => ({ label: `${n}${"界".repeat(39)}`, value: "界".repeat(200) })), allowCustomAnswer: true }, + }, + }; + const payload = apnsPayload(big); + expect(Buffer.byteLength(JSON.stringify(payload))).toBeLessThanOrEqual(3900); + expect("question" in payload).toBe(false); + expect(apnsPayload(push("finished"))).toMatchObject({ aps: { alert: { body: "Finished." } }, threadId: "thread-1" }); + }); +}); diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts index 248a791..c2c6364 100644 --- a/gateway/test/views.test.ts +++ b/gateway/test/views.test.ts @@ -266,12 +266,19 @@ describe("thread history", () => { const host = { bounded: () => Promise.resolve(window) } as unknown as T3Host; it("pages back through the bounded window, then hands over to T3's cursor", async () => { - expect(summarize(window as BoundedThread, thread()).earlierCursor).toBe("w25"); - const first = await loadHistory(host, thread(), "w25"); - expect([first.messages[0]?.text, first.messages.at(-1)?.text, first.earlierCursor]).toEqual(["m5", "m24", "w5"]); - const second = await loadHistory(host, thread(), "w5"); + expect(summarize(window as BoundedThread, thread()).earlierCursor).toBe("wm25"); + const first = await loadHistory(host, thread(), "wm25"); + expect([first.messages[0]?.text, first.messages.at(-1)?.text, first.earlierCursor]).toEqual(["m5", "m24", "wm5"]); + const second = await loadHistory(host, thread(), "wm5"); expect([second.messages.length, second.earlierCursor]).toEqual([5, "ht3-cursor"]); }); + + it("names a message, so new messages moving the window do not repeat or skip any", async () => { + const grown = { ...window, projection: { ...window.projection, messages: [...messages.slice(3), message("user", "m45"), message("assistant", "m46"), message("user", "m47")] } }; + const moved = { bounded: () => Promise.resolve(grown) } as unknown as T3Host; + const page = await loadHistory(moved, thread(), "wm25"); + expect([page.messages[0]?.text, page.messages.at(-1)?.text]).toEqual(["m5", "m24"]); + }); }); describe("projectThreads", () => { @@ -297,14 +304,15 @@ describe("projectThreads", () => { describe("latestChanges", () => { it("sums each file the latest run changed, most lines first", () => { - const change = (runId: string, fileName: string, additions: number, deletions: number) => - ({ type: "file_change", runId, fileName, additions, deletions }); + const change = (runId: string, fileName: string, additions: number, deletions: number, status = "completed") => + ({ type: "file_change", runId, status, fileName, additions, deletions }); expect( latestChanges([ change("run-1", "old.ts", 50, 0), change("run-2", "gateway/compose.yml", 3, 2), change("run-2", "gateway/Containerfile", 31, 0), change("run-2", "gateway/compose.yml", 6, 4), + change("run-2", "declined.ts", 80, 0, "cancelled"), { type: "assistant_message", runId: "run-2" }, ]), ).toEqual([