Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/web/src/api/ask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
import { askOf } from "./ask.ts";

test("design concepts outside the schema's limits are not offered", () => {
const concept = { label: "Calm", direction: "Soft greys and wide margins." };
expect(askOf("ask", { concepts: [concept] }).concepts).toEqual([concept]);
expect(
askOf("ask", { concepts: [{ ...concept, label: "x".repeat(81) }] })
.concepts,
).toBeUndefined();
expect(askOf("ask", { concepts: [null] }).concepts).toBeUndefined();
});
29 changes: 10 additions & 19 deletions apps/web/src/api/ask.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type } from "arktype";
import { DesignConcept } from "@corbits-builder/schemas/artifact";
import type { Wait } from "./types.ts";

const Names = type("string[]");

/** What an ask asks, from its native tool name and arguments, as the decision card reads it. */
export function askOf(
toolName: string | undefined,
Expand All @@ -9,33 +13,20 @@ export function askOf(
"toolName" | "packages" | "concepts" | "instruction" | "subject" | "kinds"
> {
const packages = args["packages"];
const kinds = args["kinds"];
const listed = Names(args["kinds"]);
const instruction = args["instruction"];
const subject = Object.values(args).find(
(value) => typeof value === "string",
);
const concepts = args["concepts"];
const designConcepts =
Array.isArray(concepts) &&
concepts.every(
(item: { label?: unknown; direction?: unknown } | null) =>
typeof item?.label === "string" && typeof item.direction === "string",
)
? (concepts as { label: string; direction: string }[])
: undefined;
const installs =
Array.isArray(packages) &&
packages.every((item) => typeof item === "string")
? (packages as string[])
: undefined;
const concepts = DesignConcept.array()(args["concepts"]);
const designConcepts = concepts instanceof type.errors ? undefined : concepts;
const installs = Names(packages);
return {
...(toolName === undefined ? {} : { toolName }),
...(installs === undefined ? {} : { packages: installs }),
...(installs instanceof type.errors ? {} : { packages: installs }),
...(designConcepts === undefined ? {} : { concepts: designConcepts }),
...(typeof instruction === "string" ? { instruction } : {}),
...(typeof subject === "string" ? { subject } : {}),
...(Array.isArray(kinds) && kinds.every((item) => typeof item === "string")
? { kinds: kinds as string[] }
: {}),
...(listed instanceof type.errors ? {} : { kinds: listed }),
};
}
23 changes: 23 additions & 0 deletions apps/web/src/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { afterEach, expect, test } from "bun:test";
import { wording } from "../config.ts";
import { getHubSession } from "./auth.ts";

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

function answer(body: string) {
globalThis.fetch = Object.assign(async () => new Response(body), {
preconnect: realFetch.preconnect,
});
}

test("only better-auth's null means signed out; an unreadable session is an error", async () => {
answer("null");
expect(await getHubSession()).toBeNull();
answer("<html>proxy</html>");
await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable);
answer(JSON.stringify({ user: { id: "u1" } }));
await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable);
});
51 changes: 30 additions & 21 deletions apps/web/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* `sign-up/email`, `sign-out`), all with the session cookie included.
*/
import type { Workspace } from "@corbits-builder/client";
import { type } from "arktype";
import { productName, wording } from "../config.ts";
import { hubCredentials, hubOrigin } from "./origin.ts";
import { productClient, timedCall } from "./transport.ts";
Expand All @@ -21,11 +22,12 @@ export type HubUser = {

export type HubSession = { user: HubUser } | null;

type AuthJson = {
user?: { id?: unknown; name?: unknown; email?: unknown } | null;
message?: unknown;
error?: { code?: unknown; message?: unknown };
};
// better-auth answers with `message` or `error.message` on a refusal, and `user` on a session.
const AuthMessage = type({ message: "string > 0" });
const AuthErrorMessage = type({ error: { message: "string > 0" } });
const AuthSession = type({
user: { id: "string", email: "string", "name?": "unknown" },
});

async function hubAuth(
path: string,
Expand Down Expand Up @@ -63,25 +65,20 @@ async function hubAuth(
}

function messageOf(parsed: unknown, fallback: string): string {
const body = parsed as AuthJson | undefined;
if (typeof body?.message === "string" && body.message.length > 0)
return body.message;
if (typeof body?.error?.message === "string" && body.error.message.length > 0)
return body.error.message;
return fallback;
const body = AuthMessage(parsed);
if (!(body instanceof type.errors)) return body.message;
const nested = AuthErrorMessage(parsed);
return nested instanceof type.errors ? fallback : nested.error.message;
}

function userOf(parsed: unknown): HubUser | null {
const user = (parsed as AuthJson | undefined)?.user;
if (!user || typeof user.id !== "string" || typeof user.email !== "string")
return null;
const session = AuthSession(parsed);
if (session instanceof type.errors) return null;
const { id, email, name } = session.user;
return {
id: user.id,
name:
typeof user.name === "string" && user.name.trim().length > 0
? user.name
: user.email,
email: user.email,
id,
name: typeof name === "string" && name.trim().length > 0 ? name : email,
email,
};
}

Expand All @@ -99,8 +96,20 @@ export async function getHubSession(): Promise<HubSession> {
response.status,
);
}
// better-auth answers a request with no session with a JSON null.
if (parsed === null) return null;
const user = userOf(parsed);
return user ? { user } : null;
if (user === null)
throw new ApiFailure(
{
code: "session_unreadable",
message: wording.auth.accountUnreadable,
correlationId: "-",
retryable: false,
},
response.status,
);
return { user };
}

async function emailAuth(
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/api/run-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test";
import { expect, spyOn, test } from "bun:test";
import { subscribeWorkflowRun, type RunStreamSource } from "./run-stream.ts";

test("a dropped stream says so, retries, and a reopen reports the gap", () => {
Expand Down Expand Up @@ -48,3 +48,29 @@ test("a dropped stream says so, retries, and a reopen reports the gap", () => {
});
expect(seen).toEqual(["inference.start", "change", "error", "reconnect"]);
});

test("a run frame that is not a run event never reaches the handler", () => {
const listeners = new Map<string, (event: { data: string }) => void>();
const seen: string[] = [];
const warn = spyOn(console, "warn").mockImplementation(() => {});
subscribeWorkflowRun(
"tenant",
"run_1",
{ onEvent: (event) => seen.push(event.type) },
{
createEventSource: (): RunStreamSource => ({
addEventListener: (type, listener) => listeners.set(type, listener),
close() {},
}),
setTimeout: () => 0,
clearTimeout: () => {},
},
);
const run = listeners.get("run");
run?.({ data: "not json" });
run?.({ data: "null" });
run?.({ data: JSON.stringify({ type: 7 }) });
run?.({ data: JSON.stringify({ type: "inference.done" }) });
expect(seen).toEqual(["inference.done"]);
warn.mockRestore();
});
66 changes: 39 additions & 27 deletions apps/web/src/api/run-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,38 @@
* `status` frames say the room's durable state moved (mail delivered for the
* run, or its sidecar route lost) without carrying that state.
*/
import { type } from "arktype";
import { hubEventSourceCredentials, hubOrigin } from "./origin.ts";
import { openSharedEventSource } from "../shared-event-source.ts";
import { readJson } from "../json.ts";

export type RunStreamEvent = {
type: string;
seq?: number;
data?: {
partial?: {
text?: string;
thinking?: string;
toolCalls?: readonly {
id?: unknown;
name?: unknown;
partialArguments?: unknown;
}[];
};
callId?: unknown;
name?: unknown;
arguments?: unknown;
};
};
// The fields this interface reads from Interchange's `InferenceEvent` variants; a frame carries
// more. A tool frame's `partial` is its output text, so it may be a string.
const RunStreamEvent = type({
type: "string",
"seq?": "number",
"data?": {
"partial?": type({
"text?": "string",
"thinking?": "string",
"toolCalls?": type({
"id?": "unknown",
"name?": "unknown",
"partialArguments?": "unknown",
}).array(),
}).or("string"),
"callId?": "unknown",
"name?": "unknown",
"arguments?": "unknown",
},
});
export type RunStreamEvent = typeof RunStreamEvent.infer;

/** An inference frame's partial message; a tool frame's text output is not one. */
export function partialOf(event: RunStreamEvent) {
const partial = event.data?.partial;
return typeof partial === "object" ? partial : undefined;
}

const MAX_BACKOFF_MS = 30_000;
const BASE_BACKOFF_MS = 1_000;
Expand All @@ -44,8 +55,11 @@ export function applyRunStreamEvent(
): { live: string; refresh: boolean } {
if (event.type === "inference.start") return { live: "", refresh: false };
if (event.type === "inference.text.delta") {
const text = event.data?.partial?.text ?? "";
return { live: text.length > 0 ? text : live, refresh: false };
const text = partialOf(event)?.text;
return {
live: text !== undefined && text.length > 0 ? text : live,
refresh: false,
};
}
if (event.type === "inference.error") return { live: "", refresh: true };
if (event.type === "inference.done") return { live, refresh: true };
Expand All @@ -69,8 +83,8 @@ export type SubscribeRunStreamDeps = {
const defaultDeps: SubscribeRunStreamDeps = {
createEventSource: (url, withCredentials) =>
openSharedEventSource(url, withCredentials),
setTimeout: (callback, ms) => setTimeout(callback, ms) as unknown as number,
clearTimeout: (handle) => clearTimeout(handle),
setTimeout: (callback, ms) => window.setTimeout(callback, ms),
clearTimeout: (handle) => window.clearTimeout(handle),
};

export type RunStreamHandlers = {
Expand Down Expand Up @@ -108,11 +122,9 @@ export function subscribeWorkflowRun(
});
es.addEventListener("run", (message) => {
if (closed) return;
let event: RunStreamEvent;
try {
event = JSON.parse(message.data) as RunStreamEvent;
} catch {
// A frame that is not JSON is not a run event.
const event = readJson(message.data, RunStreamEvent);
if (event === null) {
console.warn("A run frame was not a run event.");
return;
}
handlers.onEvent(event);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,8 @@ export const wording = {
addLater: "You can add one later in Settings → Providers.",
login: (label: string) => `Login with ${label} (Subscription)`,
loginConnection: (label: string) => `${productName} ${label} login`,
loginUnsupported: (label: string) =>
`${productName} cannot sign in to ${label} yet.`,
signIn: "Log in",
signInAgain: "Log in again",
waiting: "Waiting for sign-in…",
Expand All @@ -1059,6 +1061,8 @@ export const wording = {
keylessHint: `For servers that need no API key; ${productName} sends a placeholder.`,
custom: {
tile: "Custom endpoint",
modelsUnreadable:
"This provider's models could not be read, so nothing was saved.",
tileHint: "Your own server, on the API it speaks.",
addTitle: "Add a custom endpoint",
addLead: "Connect your own server. It is saved once the test passes.",
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
import { type } from "arktype";
import { readJson } from "./json.ts";

const Shape = type({ count: "number" });

test("JSON of the wrong shape, JSON null and text that is not JSON all read as null", () => {
expect(readJson('{"count":2}', Shape)).toEqual({ count: 2 });
expect(readJson('{"count":"2"}', Shape)).toBeNull();
expect(readJson("null", Shape)).toBeNull();
expect(readJson("{not json", Shape)).toBeNull();
});
21 changes: 21 additions & 0 deletions apps/web/src/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* JSON this interface did not write: hub content, mail, stream frames and
* browser storage. Text that is not JSON, or JSON of the wrong shape, reads as
* null so the caller shows its own unreadable state.
*/
import { type, type Type } from "arktype";

export function readJson<S extends Type>(
text: string,
schema: S,
): S["infer"] | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (cause) {
if (!(cause instanceof SyntaxError)) throw cause;
return null;
}
const read = schema(parsed);
return read instanceof type.errors ? null : read;
}
Loading