Gateway: changed files, search across hosts, a richer complication summary, and question options in pushes
Gateway / check (push) Successful in 10s
Gateway / image (push) Successful in 23s

This commit is contained in:
gib committed 2026-11-02 13:39:49 -05:00
1 parent 45251b1a43
commit b08fd9bdb1
13 files changed
+500 -19

No files matched your search

+37
View File
@@ -108,3 +108,40 @@ describe("Notifier", () => {
]);
});
});
describe("question pushes", () => {
it("carry a lone single-choice question's options for the notification's buttons", async () => {
const asking = shell({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } });
const question = {
id: "base",
header: "Image",
question: "Which base image?",
options: [
{ label: "bun:1-alpine", description: "" },
{ label: "bun:1-debian", description: "", value: "debian" },
],
allowCustomAnswer: true,
};
const reading = {
...host,
bounded: () =>
Promise.resolve({
snapshotSequence: 1,
projection: { thread: asking, messages: [], turnItems: [{ type: "user_input_request", requestId: "q1", questions: [question] }] },
}),
} as unknown as T3Host;
const pushes: Push[] = [];
const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache());
notifier.onThreadChange({ host: reading, previous: undefined, next: shell(), initial: true });
notifier.onThreadChange({ host: reading, previous: shell(), next: asking, initial: false });
await Bun.sleep(5);
expect(pushes[0]?.data.question).toEqual({
id: "base",
options: [
{ label: "bun:1-alpine", value: "bun:1-alpine" },
{ label: "bun:1-debian", value: "debian" },
],
allowCustomAnswer: true,
});
});
});
+63 -6
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
import type { T3Host } from "../src/t3/host.ts";
import { agentProviders, buildFeed, currentActivity, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
import { agentProviders, buildFeed, currentActivity, latestChanges, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
id: "thread-1",
@@ -107,10 +107,16 @@ describe("summarize", () => {
describe("buildSummary", () => {
const at = (minute: number) => `2026-11-01T09:${String(minute).padStart(2, "0")}:00.000Z`;
const host = (label: string, status: string, threads: ThreadShell[]) =>
({ id: label, label, status, threads: new Map(threads.map((t) => [t.id, t])) }) as unknown as T3Host;
({
id: label,
label,
status,
threads: new Map(threads.map((t) => [t.id, t])),
bounded: () => Promise.reject(new Error("not read")),
}) as unknown as T3Host;
it("counts waiting and working threads and names the one waiting longest", () => {
const summary = buildSummary([
it("counts waiting and working threads and names the one waiting longest", async () => {
const summary = await buildSummary([
host("Mac mini", "online", [
thread({ id: "a", title: "Newer ask", pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: at(30) } }),
thread({ id: "b", status: "running" }),
@@ -120,11 +126,12 @@ describe("buildSummary", () => {
host("LittleMan", "offline", [
thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }),
]),
]);
], new SummaryCache());
expect(summary).toEqual({
needsYou: 2,
working: 1,
next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan" },
next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan", request: null },
current: { hostId: "Mac mini", threadId: "b", title: "Receipt OCR", activity: null },
hostsOffline: 1,
});
});
@@ -286,3 +293,53 @@ 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 });
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),
{ type: "assistant_message", runId: "run-2" },
]),
).toEqual([
{ path: "gateway/Containerfile", name: "Containerfile", additions: 31, deletions: 0 },
{ path: "gateway/compose.yml", name: "compose.yml", additions: 9, deletions: 6 },
]);
});
});
describe("searchThreads", () => {
it("merges every host's hits newest first and names hosts it could not search", async () => {
const searching = (label: string, status: string, threads: ThreadShell[], matches: object[]) =>
({
id: label,
label,
status,
projects: new Map([["project-1", { title: "wristcode" }]]),
threads: new Map(threads.map((t) => [t.id, t])),
search: () => Promise.resolve(matches),
}) as unknown as T3Host;
const match = (threadId: string, snippet: string, at: string) => ({ threadId, projectId: "project-1", source: "assistant", snippet, messageCreatedAt: at });
const result = await searchThreads(
[
searching("Desktop", "online", [thread({ id: "a", title: "Gateway Containerfile" })], [match("a", "Gateway Containerfile", "2026-11-01T08:00:00.000Z")]),
searching("Mac mini", "online", [thread({ id: "b", title: "Jellyfin" }), thread({ id: "gone", deletedAt: "x" })], [
match("b", "rebuild from the Containerfile", "2026-11-01T09:00:00.000Z"),
match("gone", "Containerfile", "2026-11-01T10:00:00.000Z"),
]),
searching("Laptop", "offline", [], []),
],
"containerfile",
);
expect(result.hits.map((hit) => [hit.hostLabel, hit.thread.threadId, hit.snippet])).toEqual([
["Mac mini", "b", "rebuild from the Containerfile"],
["Desktop", "a", null],
]);
expect(result.unsearched).toEqual(["Laptop"]);
});
});