193 lines
7.4 KiB
TypeScript
193 lines
7.4 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 { buildFeed, 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" },
|
|
],
|
|
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])) }) as unknown as T3Host;
|
|
|
|
it("counts waiting and working threads and names the one waiting longest", () => {
|
|
const summary = 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) } }),
|
|
]),
|
|
]);
|
|
expect(summary).toEqual({
|
|
needsYou: 2,
|
|
working: 1,
|
|
next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan" },
|
|
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 now = Date.parse("2026-11-01T09:30:00.000Z");
|
|
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(),
|
|
now,
|
|
);
|
|
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(),
|
|
now,
|
|
);
|
|
expect(feed.needsYou.map((item) => [item.threadId, item.via?.threadId ?? null])).toEqual([
|
|
["parent", "first"],
|
|
["later", null],
|
|
]);
|
|
});
|
|
});
|