Files

400 lines
17 KiB
TypeScript

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, feedCandidates, latestChanges, pendingFiles, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
const thread = (overrides: Partial<ThreadShell> = {}): 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" },
],
files: [],
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("VPS", "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: "VPS", threadId: "e", title: "Older ask", hostLabel: "VPS", 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.lan:3773")).toBe("direct");
expect(routeKind("http://10.0.0.2:3773")).toBe("direct");
expect(routeKind("http://laptop-vpn.lan:3773")).toBe("vpn");
expect(routeKind("https://t3-laptop-vpn.example.com")).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("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", () => {
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, 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([
{ 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"]);
});
});
describe("pendingFiles", () => {
it("lists the run's unfinished file changes with whatever counts and diff the server sent", () => {
const files = pendingFiles(
[
{ type: "file_change", runId: "run-2", status: "completed", fileName: "done.ts" },
{ type: "file_change", runId: "run-2", status: "pending", fileName: "deploy/compose.yml", additions: 2, deletions: 1, diffStr: "--- a/compose.yml\n+++ b/compose.yml\n@@ -1 +1,2 @@\n- image: bun:1\n+ image: bun:1-alpine\n+ restart: always" },
],
"run-2",
"Edit: deploy/compose.yml",
);
expect(files).toEqual([
{ path: "deploy/compose.yml", name: "compose.yml", additions: 2, deletions: 1, preview: ["- image: bun:1", "+ image: bun:1-alpine", "+ restart: always"] },
]);
});
it("falls back to the path in Claude's prompt when no change is in the window", () => {
expect(pendingFiles([], "run-1", "Edit: /home/me/app/src/main.ts")).toEqual([
{ path: "/home/me/app/src/main.ts", name: "main.ts", additions: null, deletions: null, preview: [] },
]);
});
});
describe("snoozed threads", () => {
it("leave Recent until their time comes, the soonest first", () => {
const now = Date.parse("2026-11-01T12:00:00.000Z");
const host = {
id: "Desktop",
label: "Desktop",
threads: new Map(
[
thread({ id: "done", status: "completed" }),
thread({ id: "later", status: "completed", snoozedUntil: "2026-11-02T09:00:00.000Z" }),
thread({ id: "soon", status: "completed", snoozedUntil: "2026-11-01T13:00:00.000Z" }),
thread({ id: "woke", status: "completed", snoozedUntil: "2026-11-01T11:00:00.000Z" }),
thread({ id: "busy", status: "running", snoozedUntil: "2026-11-02T09:00:00.000Z" }),
].map((t) => [t.id, t]),
),
} as unknown as T3Host;
const { recent, snoozed, working } = feedCandidates([host], now);
expect(recent.map(({ thread }) => thread.id).sort()).toEqual(["done", "woke"]);
expect(snoozed.map(({ thread }) => thread.id)).toEqual(["soon", "later"]);
expect(working.map(({ thread }) => thread.id)).toEqual(["busy"]);
});
});