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, latestChanges, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts"; const thread = (overrides: Partial = {}): ThreadShell => ({ id: "thread-1", projectId: "project-1", title: "Receipt OCR", createdAt: "2026-11-01T09:00:00.000Z", updatedAt: "2026-11-01T09:00:00.000Z", status: "running", activeRunId: "run-1", pendingRuntimeRequest: null, modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" }, lineage: { relationshipToParent: null }, ...overrides, }); const message = (role: string, text: string, id = text) => ({ id, role, text, createdAt: "2026-11-01T09:00:00.000Z", updatedAt: "2026-11-01T09:00:00.000Z", }); const bounded = (turnItems: BoundedThread["projection"]["turnItems"], messages = [message("assistant", "Tests written.")]) => ({ snapshotSequence: 1, projection: { thread: thread(), messages, turnItems } }) as BoundedThread; describe("trimText", () => { it("collapses whitespace and cuts at a word", () => { expect(trimText("one two\n\nthree", 50)).toBe("one two three"); expect(trimText("alpha beta gamma delta", 15)).toBe("alpha beta…"); }); }); describe("summarize", () => { it("takes the last assistant message and skips empty or system ones", () => { const summary = summarize( bounded([], [message("user", "Add OCR"), message("assistant", "Done, all green."), message("system", "noise"), message("assistant", " ")]), thread(), ); expect(summary.lastAssistantMessage).toBe("Done, all green."); expect(summary.messages.map((m) => m.role)).toEqual(["user", "assistant"]); }); it("reads the pending approval's prompt and options", () => { const summary = summarize( bounded([ { type: "approval_request", requestId: "r1", requestKind: "command", prompt: "bun test --filter ocr", options: [ { decision: "accept", label: "Allow once" }, { decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" }, { decision: "decline", label: "Deny" }, ], } as BoundedThread["projection"]["turnItems"][number], ]), thread({ pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: "2026-11-01T09:00:00.000Z" } }), ); expect(summary.request).toEqual({ id: "r1", kind: "command", prompt: "bun test --filter ocr", reason: null, options: [ { decision: "accept", label: "Allow once" }, { decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" }, { decision: "decline", label: "Deny" }, ], questions: [], }); }); it("offers Allow and Deny when the approval's turn item is not loaded yet", () => { const summary = summarize( bounded([]), thread({ pendingRuntimeRequest: { id: "r9", kind: "file-change", createdAt: "2026-11-01T09:00:00.000Z" } }), ); expect(summary.request?.options.map((o) => o.decision)).toEqual(["accept", "decline"]); }); it("reads questions, using the label when an option has no value", () => { const summary = summarize( bounded([ { type: "user_input_request", requestId: "q1", questions: [ { id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", description: "" }] }, ], } as BoundedThread["projection"]["turnItems"][number], ]), thread({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }), ); expect(summary.request?.questions).toEqual([ { id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", value: "SQLite" }], allowCustomAnswer: false, multiSelect: false }, ]); }); }); 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])), bounded: () => Promise.reject(new Error("not read")), }) as unknown as T3Host; 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" }), thread({ id: "c", status: "completed" }), thread({ id: "d", status: "running", lineage: { relationshipToParent: "subagent" } }), ]), 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", request: null }, current: { hostId: "Mac mini", threadId: "b", title: "Receipt OCR", activity: null }, hostsOffline: 1, }); }); }); describe("routeKind", () => { it("tells direct, VPN and public routes apart", () => { expect(routeKind("http://desktop.gib:3773")).toBe("direct"); expect(routeKind("http://192.168.2.2:3773")).toBe("direct"); expect(routeKind("http://laptopvpn.gib:3773")).toBe("vpn"); expect(routeKind("https://t3laptopvpn.gbrown.org")).toBe("public"); }); }); describe("buildFeed", () => { const host = (label: string, threads: ThreadShell[]) => ({ id: label, label, status: "online", config: { environmentId: `env-${label}` }, projects: new Map(), threads: new Map(threads.map((t) => [t.id, t])), bounded: () => Promise.reject(new Error("not needed")), }) as unknown as T3Host; const ask = (id: string, minute: number) => ({ id, kind: "command", createdAt: `2026-11-01T09:${minute}:00.000Z` }); const delegatedFrom = { environmentId: "env-Desktop", threadId: "parent", title: "HomeLab Stats" }; it("shows a waiting subagent's request on the card of the thread that delegated it", async () => { const feed = await buildFeed( [ host("Desktop", [thread({ id: "parent", title: "HomeLab Stats" })]), host("Mac mini", [ thread({ id: "child", title: "Check PATH", delegatedFrom, pendingRuntimeRequest: ask("r1", 10) }), thread({ id: "busy", title: "Survey setup", delegatedFrom }), ]), ], new SummaryCache(), ); expect(feed.working).toEqual([]); expect(feed.needsYou.map((item) => [item.threadId, item.phase, item.via])).toEqual([ ["parent", "waiting_for_approval", { hostId: "Mac mini", threadId: "child", title: "Check PATH" }], ]); }); it("keeps a second waiting subagent on its own card", async () => { const feed = await buildFeed( [ host("Desktop", [thread({ id: "parent", title: "HomeLab Stats" })]), host("Mac mini", [ thread({ id: "later", delegatedFrom, pendingRuntimeRequest: ask("r2", 20) }), thread({ id: "first", delegatedFrom, pendingRuntimeRequest: ask("r1", 10) }), ]), ], new SummaryCache(), ); expect(feed.needsYou.map((item) => [item.threadId, item.via?.threadId ?? null])).toEqual([ ["parent", "first"], ["later", null], ]); }); it("keeps finished threads in Recent until they are settled, however old", async () => { const old = (id: string, day: number) => thread({ id, status: "completed", updatedAt: `2026-10-${day}T09:00:00.000Z` }); const feed = await buildFeed( [host("Desktop", [...Array.from({ length: 11 }, (_, i) => old(`t${i}`, 10 + i)), { ...old("done", 29), settledOverride: "settled" }])], new SummaryCache(), ); expect(feed.recent.map((item) => item.threadId)).toEqual(["t10", "t9", "t8", "t7", "t6", "t5", "t4", "t3", "t2", "t1"]); expect(feed.recentTotal).toBe(11); }); }); describe("agentProviders", () => { const provider = (overrides: object) => ({ instanceId: "claudeAgent", driver: "claudeAgent", displayName: "Claude", enabled: true, installed: true, status: "ready", models: [ { slug: "claude-opus-5-5", name: "Claude Opus 5.5" }, { slug: "claude-opus-4", name: "Claude Opus 4", isLegacy: true }, ], ...overrides, }); it("offers ready providers without their legacy models, and says which cannot ask first", () => { const host = { serverConfig: { providers: [ provider({}), provider({ instanceId: "codex", displayName: undefined, driver: "codex", supportedRuntimeModes: ["full-access"] }), provider({ instanceId: "off", enabled: false }), provider({ instanceId: "broken", status: "error" }), provider({ instanceId: "away", availability: "unavailable" }), ], }, } as unknown as T3Host; expect(agentProviders(host)).toEqual([ { instanceId: "claudeAgent", name: "Claude", canAskFirst: true, models: [{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" }] }, { instanceId: "codex", name: "codex", canAskFirst: false, models: [{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" }] }, ]); }); }); describe("currentActivity", () => { const item = (type: string, status: string, extra: object = {}) => ({ type, status, startedAt: "2026-11-01T09:00:00.000Z", ...extra }); it("says the newest step still in progress, leaving out monitors", () => { expect( currentActivity([ item("file_change", "running", { fileName: "gateway/src/host.ts" }), item("command_execution", "running", { input: "\nbun test \\\n --bail" }), item("command_execution", "running", { input: "tail -f log", monitor: true }), item("assistant_message", "running"), ]), ).toEqual({ kind: "command", text: "bun test \\", startedAt: "2026-11-01T09:00:00.000Z" }); expect(currentActivity([item("file_change", "running", { fileName: "gateway/src/host.ts" })])?.text).toBe("host.ts"); }); it("is null once every step finished", () => { expect(currentActivity([item("command_execution", "completed", { input: "bun test" })])).toBeNull(); }); }); describe("thread history", () => { const messages = Array.from({ length: 45 }, (_, i) => message(i % 2 ? "assistant" : "user", `m${i}`)); const window = { snapshotSequence: 1, historyCursor: "t3-cursor", hasMoreHistory: true, projection: { thread: thread(), messages, turnItems: [] } }; 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([second.messages.length, second.earlierCursor]).toEqual([5, "ht3-cursor"]); }); }); describe("projectThreads", () => { it("lists a project's threads newest first, settled ones too, without subagents", () => { const host = { id: "Desktop", threads: new Map( [ thread({ id: "old", updatedAt: "2026-11-01T08:00:00.000Z", settledOverride: "settled", status: "completed" }), thread({ id: "new", updatedAt: "2026-11-01T10:00:00.000Z" }), thread({ id: "sub", lineage: { relationshipToParent: "subagent", parentThreadId: "new" } }), thread({ id: "gone", archivedAt: "2026-11-01T09:30:00.000Z" }), thread({ id: "elsewhere", projectId: "project-2" }), ].map((t) => [t.id, t]), ), } as unknown as T3Host; expect(projectThreads(host, "project-1").map((row) => [row.threadId, row.phase, row.settled])).toEqual([ ["new", "running", false], ["old", "completed", true], ]); }); }); 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"]); }); });