Gateway: watch API, pairing, notifications, APNs sender, CLI, tests
This commit is contained in:
1 parent
972f6e4be5
commit
6ca098b701
12 files changed
+1297
-1
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { Notifier, type Push } from "../src/notify.ts";
|
||||
import type { T3Host } from "../src/t3/host.ts";
|
||||
import type { ThreadShell } from "../src/t3/types.ts";
|
||||
import { SummaryCache } from "../src/views.ts";
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Mac mini",
|
||||
projects: new Map([["project-1", { id: "project-1", title: "wristcode" }]]),
|
||||
bounded: () => Promise.reject(new Error("offline")),
|
||||
} as unknown as T3Host;
|
||||
|
||||
const shell = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||
id: "thread-1",
|
||||
projectId: "project-1",
|
||||
title: "Watch app scaffold",
|
||||
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 request = (id: string) => ({ id, kind: "command", createdAt: "2026-11-01T09:00:00.000Z" });
|
||||
|
||||
function setup() {
|
||||
const pushes: Push[] = [];
|
||||
const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache());
|
||||
let previous: ThreadShell | undefined;
|
||||
const change = (next: ThreadShell | undefined, initial = false) => {
|
||||
notifier.onThreadChange({ host, previous, next, initial });
|
||||
previous = next;
|
||||
};
|
||||
// Pushes are sent after a summary lookup, which fails fast here.
|
||||
const settle = () => Bun.sleep(5);
|
||||
return { pushes, change, settle, notifier };
|
||||
}
|
||||
|
||||
describe("Notifier", () => {
|
||||
it("stays quiet while loading the first snapshot", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell({ status: "completed" }), true);
|
||||
change(shell({ status: "completed" }));
|
||||
await settle();
|
||||
expect(pushes).toEqual([]);
|
||||
});
|
||||
|
||||
it("notifies once when a running thread finishes", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ status: "completed" }));
|
||||
change(shell({ status: "completed", updatedAt: "2026-11-01T09:01:00.000Z" }));
|
||||
await settle();
|
||||
expect(pushes.map((push) => [push.category, push.subtitle, push.data.threadId])).toEqual([
|
||||
["finished", "Mac mini · wristcode", "thread-1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("notifies for each new approval request, with its request id", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ pendingRuntimeRequest: request("r1") }));
|
||||
change(shell({ pendingRuntimeRequest: request("r2") }));
|
||||
await settle();
|
||||
expect(pushes.map((push) => [push.category, push.data.requestId])).toEqual([
|
||||
["approval", "r1"],
|
||||
["approval", "r2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not announce a turn the user stopped", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ status: "completed", latestRunStopRequested: true }));
|
||||
await settle();
|
||||
expect(pushes).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports failures with the server's error", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ status: "failed", lastError: "Provider quota exceeded" }));
|
||||
await settle();
|
||||
expect(pushes.map((push) => [push.category, push.body])).toEqual([["failed", "Provider quota exceeded"]]);
|
||||
});
|
||||
|
||||
it("warns about a token that expires within a week, once a day", async () => {
|
||||
const { pushes, notifier } = setup();
|
||||
const row = {
|
||||
id: "host-1",
|
||||
label: "Mac mini",
|
||||
url: "https://t3mac.gbrown.org",
|
||||
environment_id: "host-1",
|
||||
token: "t",
|
||||
token_expires_at: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(),
|
||||
created_at: "",
|
||||
};
|
||||
notifier.checkTokenExpiry([row]);
|
||||
notifier.checkTokenExpiry([row]);
|
||||
expect(pushes.map((push) => [push.category, push.body])).toEqual([
|
||||
["host", "The gateway's T3 token expires in 3 days. Pair Mac mini again."],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// The RPC client against an in-process server that frames messages the way Effect RPC does.
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
import { RpcError, RpcSocket } from "../src/t3/rpc.ts";
|
||||
|
||||
type Frame = { _tag: string; id?: string; requestId?: string; tag?: string; payload?: unknown };
|
||||
|
||||
let server: Server<undefined> | undefined;
|
||||
afterEach(() => server?.stop(true));
|
||||
|
||||
/** Starts a server whose `onRequest` decides how to answer each Request frame. */
|
||||
function serve(onRequest: (ws: ServerWebSocket<undefined>, frame: Frame, received: Frame[]) => void) {
|
||||
const received: Frame[] = [];
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response("no", { status: 400 })),
|
||||
websocket: {
|
||||
message(ws, data) {
|
||||
const frame = JSON.parse(String(data)) as Frame;
|
||||
received.push(frame);
|
||||
if (frame._tag === "Request") onRequest(ws, frame, received);
|
||||
},
|
||||
},
|
||||
});
|
||||
return { url: `ws://localhost:${server.port}`, received };
|
||||
}
|
||||
|
||||
const send = (ws: ServerWebSocket<undefined>, frame: object) => ws.send(JSON.stringify(frame));
|
||||
|
||||
describe("RpcSocket", () => {
|
||||
it("resolves a unary call with its success value", async () => {
|
||||
const { url } = serve((ws, frame) =>
|
||||
send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: { echoed: frame.payload } } }),
|
||||
);
|
||||
const socket = await RpcSocket.connect(url);
|
||||
expect(await socket.call("server.probe", { a: 1 })).toEqual({ echoed: { a: 1 } });
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("rejects with the server's typed error", async () => {
|
||||
const error = { _tag: "EnvironmentAuthorizationError", message: "missing scope", requiredScope: "orchestration:operate" };
|
||||
const { url } = serve((ws, frame) =>
|
||||
send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Failure", cause: [{ _tag: "Fail", error }] } }),
|
||||
);
|
||||
const socket = await RpcSocket.connect(url);
|
||||
const failure = await socket.call("orchestration.dispatchCommand", {}).catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(RpcError);
|
||||
expect((failure as RpcError).error).toEqual(error);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("acks every chunk so the server sends the next one", async () => {
|
||||
const { url, received } = serve((ws, frame) => {
|
||||
// Like Effect RPC, send one chunk, then wait for its Ack before the next.
|
||||
const values = [[1, 2], [3]];
|
||||
const next = () => {
|
||||
const value = values.shift();
|
||||
if (value) send(ws, { _tag: "Chunk", requestId: frame.id, values: value });
|
||||
else send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: null } });
|
||||
};
|
||||
const waitForAck = setInterval(() => {
|
||||
const acks = received.filter((f) => f._tag === "Ack").length;
|
||||
if (acks === 2 - values.length) next();
|
||||
if (values.length === 0 && acks === 2) clearInterval(waitForAck);
|
||||
}, 2);
|
||||
next();
|
||||
});
|
||||
const socket = await RpcSocket.connect(url);
|
||||
const seen: unknown[] = [];
|
||||
await socket.stream("orchestration.subscribeShell", {}, (value) => seen.push(value)).done;
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
expect(received.filter((f) => f._tag === "Ack")).toHaveLength(2);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("fails pending calls when the socket closes", async () => {
|
||||
const { url } = serve((ws) => ws.close());
|
||||
const socket = await RpcSocket.connect(url);
|
||||
expect(socket.call("server.probe", {})).rejects.toThrow("socket closed");
|
||||
await socket.closed;
|
||||
});
|
||||
|
||||
it("closes a silent socket", async () => {
|
||||
// This server never answers Pings.
|
||||
const { url } = serve(() => {});
|
||||
const socket = await RpcSocket.connect(url, { pingIntervalMs: 10, silenceTimeoutMs: 40 });
|
||||
await socket.closed;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
||||
import { summarize, 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" },
|
||||
{ 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user