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
This commit is contained in:
1 parent
edc4b88990
commit
99b300855e
13 files changed
+208
-58
No files matched your search
+4
-2
@@ -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.
|
||||
|
||||
|
||||
+6
-2
@@ -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<string, string | string[]> =
|
||||
(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<T>(req: Request): Promise<Partial<T> & T> {
|
||||
let body: unknown;
|
||||
try {
|
||||
return (await req.json()) as Partial<T> & 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> & T;
|
||||
}
|
||||
|
||||
/** A thread title from the first line of the prompt. T3 replaces it with a generated one. */
|
||||
|
||||
@@ -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<ThreadShell, "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest" | "pendingGuestMessageCount">,
|
||||
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<ThreadShell["pendingBackgroundTasks"]>): boolean {
|
||||
return tasks.some((task) => task.awaited === true || task.kind === "subagent" || task.kind === "background_task");
|
||||
}
|
||||
+9
-1
@@ -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());
|
||||
|
||||
+11
-4
@@ -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<void>;
|
||||
|
||||
const NOTIFYING: ReadonlySet<Phase> = 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<Pu
|
||||
const question = questions[0];
|
||||
if (phase !== "waiting_for_input" || questions.length !== 1 || !question || question.multiSelect) return {};
|
||||
if (question.options.length === 0 && !question.allowCustomAnswer) return {};
|
||||
// A cut value would answer with a string that matches no option.
|
||||
if (question.options.some(({ value }) => 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,
|
||||
},
|
||||
};
|
||||
|
||||
+28
-9
@@ -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,
|
||||
|
||||
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(new URL(path, this.activeUrl), {
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.config.token}`,
|
||||
|
||||
+24
-4
@@ -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<T = unknown>(tag: string, payload: unknown): Promise<T> {
|
||||
/** Calls a unary method and resolves with its success value. Fails after `timeoutMs`. */
|
||||
call<T = unknown>(tag: string, payload: unknown, timeoutMs = CALL_TIMEOUT_MS): Promise<T> {
|
||||
return new Promise<T>((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<typeof setTimeout> | 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+43
-28
@@ -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<ConversationMessage, "id" | "role" | "text" | "
|
||||
createdAt: message.createdAt,
|
||||
});
|
||||
|
||||
// History cursors are the gateway's own. `w<n>` means the bounded window's messages before index n,
|
||||
// which the thread detail leaves out past its 20. `h<cursor>` is T3's cursor for older pages.
|
||||
// History cursors are the gateway's own. `w<messageId>` 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<cursor>` 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<HistoryResponse> {
|
||||
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<string, ChangedFile>();
|
||||
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<ThreadSummary> {
|
||||
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<F
|
||||
|
||||
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (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)]);
|
||||
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user