diff --git a/apps/web/src/api/ask.test.ts b/apps/web/src/api/ask.test.ts new file mode 100644 index 00000000..96bf7734 --- /dev/null +++ b/apps/web/src/api/ask.test.ts @@ -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(); +}); diff --git a/apps/web/src/api/ask.ts b/apps/web/src/api/ask.ts index 5dad3568..79f4574d 100644 --- a/apps/web/src/api/ask.ts +++ b/apps/web/src/api/ask.ts @@ -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, @@ -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 }), }; } diff --git a/apps/web/src/api/auth.test.ts b/apps/web/src/api/auth.test.ts new file mode 100644 index 00000000..e98a5d7a --- /dev/null +++ b/apps/web/src/api/auth.test.ts @@ -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("proxy"); + await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable); + answer(JSON.stringify({ user: { id: "u1" } })); + await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable); +}); diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 9394451b..dda02da2 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -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"; @@ -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, @@ -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, }; } @@ -99,8 +96,20 @@ export async function getHubSession(): Promise { 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( diff --git a/apps/web/src/api/run-stream.test.ts b/apps/web/src/api/run-stream.test.ts index b6ccc816..f1a34a0b 100644 --- a/apps/web/src/api/run-stream.test.ts +++ b/apps/web/src/api/run-stream.test.ts @@ -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", () => { @@ -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 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(); +}); diff --git a/apps/web/src/api/run-stream.ts b/apps/web/src/api/run-stream.ts index a7e5390d..c2ea329f 100644 --- a/apps/web/src/api/run-stream.ts +++ b/apps/web/src/api/run-stream.ts @@ -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; @@ -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 }; @@ -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 = { @@ -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); diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 54b587d5..bdbe5501 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -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…", @@ -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.", diff --git a/apps/web/src/json.test.ts b/apps/web/src/json.test.ts new file mode 100644 index 00000000..f4d76199 --- /dev/null +++ b/apps/web/src/json.test.ts @@ -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(); +}); diff --git a/apps/web/src/json.ts b/apps/web/src/json.ts new file mode 100644 index 00000000..35675d41 --- /dev/null +++ b/apps/web/src/json.ts @@ -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( + 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; +} diff --git a/apps/web/src/pages/custom-providers.tsx b/apps/web/src/pages/custom-providers.tsx index 78b7d224..575097b3 100644 --- a/apps/web/src/pages/custom-providers.tsx +++ b/apps/web/src/pages/custom-providers.tsx @@ -31,12 +31,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Ellipsis, X } from "lucide-react"; import { useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { toast } from "sonner"; -import type { +import { CustomApi, - CustomModel, - CustomProviderCreate, - ModelCatalog, - ModelProvider, + type CustomModel, + type CustomProviderCreate, + type ModelCatalog, + type ModelProvider, } from "@corbits-builder/schemas/models"; import { Button, QuietState } from "../components.jsx"; import { keys } from "../queries/keys.ts"; @@ -318,10 +318,16 @@ function keylessOf(provider: ModelProvider): boolean { return provider.connections.some((connection) => connection.keyless); } -function draftOf(provider: ModelProvider): CustomDraft { +/** A custom provider whose plugin is an API this form can edit. */ +type EditableProvider = ModelProvider & { plugin: CustomApi }; + +const editable = (provider: ModelProvider): provider is EditableProvider => + CustomApi.allows(provider.plugin); + +function draftOf(provider: EditableProvider): CustomDraft { return { name: provider.label, - api: provider.plugin as CustomApi, + api: provider.plugin, baseURL: provider.baseURL, secret: "", keyless: keylessOf(provider), @@ -403,7 +409,7 @@ async function testConnection(draft: CustomDraft): Promise { * One custom provider's connection form: its rows, whether it can be submitted, whether * submitting tests a new connection first, and `save`, which throws a failed test's reason. */ -export function useCustomProviderForm(apis: Apis, original?: ModelProvider) { +export function useCustomProviderForm(apis: Apis, original?: EditableProvider) { const [draft, setDraft] = useState(() => original ? draftOf(original) : BLANK_DRAFT, ); @@ -437,6 +443,10 @@ export function useCustomProviderForm(apis: Apis, original?: ModelProvider) { ), save: async (): Promise => { + const models = parsedModels( + original ? original.models.map(modelDraftOf) : [draft.model], + ); + if (models === null) throw new Error(copy.custom.modelsUnreadable); if (tests) await testConnection(draft); const tenantId = await workspaceTenantId(); const client = productClient(); @@ -444,14 +454,14 @@ export function useCustomProviderForm(apis: Apis, original?: ModelProvider) { if (!original) { await client.createCustomProvider(tenantId, { name, - models: parsedModels([draft.model])!, + models, connection: endpointOf(draft), }); return; } await client.updateCustomProvider(tenantId, original.id, { name, - models: parsedModels(original.models.map(modelDraftOf))!, + models, ...(tests ? { connection: endpointOf(draft) } : {}), }); }, @@ -848,6 +858,7 @@ function CustomProviderActions({ }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); + const editableProvider = editable(provider) ? provider : null; const [removing, setRemoving] = useState(false); // Removing takes two clicks, both in the menu, as deleting a project does. const [confirming, setConfirming] = useState(false); @@ -886,7 +897,10 @@ function CustomProviderActions({ - setEditing(true)}> + setEditing(true)} + > {copy.custom.edit} @@ -912,9 +926,9 @@ function CustomProviderActions({ )} - {editing ? ( + {editing && editableProvider !== null ? ( setEditing(false)} onSaved={onChanged} @@ -930,7 +944,7 @@ function EditCustomProviderDialog({ onClose, onSaved, }: { - provider: ModelProvider; + provider: EditableProvider; apis: Apis; onClose: () => void; onSaved: () => void; diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/providers.tsx index bc4ff525..1efef7ad 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/providers.tsx @@ -15,11 +15,12 @@ import { DialogFieldLabel } from "@corbits/react-ui/ui/dialog"; import { QueryObserver, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; -import type { - ModelProvider, - ProviderStatus, +import { + ModelLoginPlugin, + type ModelProvider, + type ProviderStatus, } from "@corbits-builder/schemas/models"; -import type { ModelLoginPlugin } from "@corbits-builder/client"; +import { type } from "arktype"; import { Banner, Button, QuietState } from "../components.jsx"; import { queryClient } from "../queries/client.ts"; import { keys } from "../queries/keys.ts"; @@ -62,7 +63,9 @@ export function useModelCatalog() { async function logIn(provider: ModelProvider) { const tenantId = await workspaceTenantId(); const client = productClient(); - const plugin = provider.plugin as ModelLoginPlugin; + const plugin = ModelLoginPlugin(provider.plugin); + if (plugin instanceof type.errors) + throw new Error(copy.loginUnsupported(provider.label)); const connection = copy.loginConnection(provider.label); const login = await client.startModelLogin(tenantId, plugin, connection); window.open(login.authorizeUrl, "_blank", "noopener"); diff --git a/apps/web/src/pages/workspace/conversation-parts.ts b/apps/web/src/pages/workspace/conversation-parts.ts index 46b9537f..1d89e524 100644 --- a/apps/web/src/pages/workspace/conversation-parts.ts +++ b/apps/web/src/pages/workspace/conversation-parts.ts @@ -14,6 +14,7 @@ import type { } from "@corbits/react-ui"; import { applyRunStreamEvent, + partialOf, type RunStreamEvent, } from "../../api/run-stream.ts"; @@ -124,7 +125,7 @@ export function foldLiveTurn( if (event.type === "inference.start" || event.type === "inference.error") { return { turn: { text: folded.live, parts: [] }, refresh: folded.refresh }; } - const partial = event.data?.partial; + const partial = partialOf(event); const thinking = typeof partial?.thinking === "string" ? partial.thinking diff --git a/apps/web/src/pages/workspace/design-preview.tsx b/apps/web/src/pages/workspace/design-preview.tsx index 28c18b89..9567444e 100644 --- a/apps/web/src/pages/workspace/design-preview.tsx +++ b/apps/web/src/pages/workspace/design-preview.tsx @@ -16,6 +16,8 @@ import { } from "react"; import { useQuery } from "@tanstack/react-query"; import { wording } from "../../config.ts"; +import { DesignBundle } from "@corbits-builder/schemas/artifact"; +import { readJson } from "../../json.ts"; import { Markdown } from "../../markdown.jsx"; import { keys } from "../../queries/keys.ts"; import { readProjectActivity } from "../../api/activity.ts"; @@ -87,14 +89,7 @@ const stateHash = (screen: string, state: string | null) => state === null ? `#/${screen}` : `#/${screen}/${state}`; export function parseBundle(content: string): Record | null { - try { - const parsed = JSON.parse(content) as { files?: unknown }; - return typeof parsed.files === "object" && parsed.files !== null - ? (parsed.files as Record) - : null; - } catch { - return null; - } + return readJson(content, DesignBundle)?.files ?? null; } const SCREEN_PREFIX = "designs/screens/"; diff --git a/apps/web/src/pages/workspace/load-room.ts b/apps/web/src/pages/workspace/load-room.ts index 6bafb3de..b2c238d4 100644 --- a/apps/web/src/pages/workspace/load-room.ts +++ b/apps/web/src/pages/workspace/load-room.ts @@ -266,13 +266,11 @@ export async function loadProjectRoom( ); for (const key of settled.keys()) { if (generation.get(projectId) !== ticket) break; - try { - const owner = JSON.parse(key) as unknown; - if (Array.isArray(owner) && owner[0] === projectId && !live.has(key)) - settled.delete(key); - } catch { - // A key this loader did not write. Leave it. - } + // Every key is a `roomDocumentKey`, written by this loader or restored from the saved cache + // it persisted, so it always parses. + const owner: unknown = JSON.parse(key); + if (Array.isArray(owner) && owner[0] === projectId && !live.has(key)) + settled.delete(key); } const settleDocument = async (artifact: ProjectArtifact, key: string) => { diff --git a/apps/web/src/pages/workspace/room-reload.ts b/apps/web/src/pages/workspace/room-reload.ts index 7e1d7419..86a29dff 100644 --- a/apps/web/src/pages/workspace/room-reload.ts +++ b/apps/web/src/pages/workspace/room-reload.ts @@ -13,8 +13,8 @@ export type RoomReloadDeps = { const defaultDeps: RoomReloadDeps = { now: () => Date.now(), - 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 function createRoomReloader( diff --git a/apps/web/src/pages/workspace/seen-versions.ts b/apps/web/src/pages/workspace/seen-versions.ts index 83b2581e..673150c1 100644 --- a/apps/web/src/pages/workspace/seen-versions.ts +++ b/apps/web/src/pages/workspace/seen-versions.ts @@ -1,5 +1,7 @@ /** The version of each document this viewer last opened, kept in this browser. */ import { useMemo } from "react"; +import { type } from "arktype"; +import { readJson } from "../../json.ts"; import { useStoredPreference, writeStoredPreference, @@ -9,17 +11,12 @@ const SEEN_KEY = "builder-seen-document-versions"; export type SeenVersions = Readonly>; +const Seen = type({ "[string]": "number" }); +// Nothing stored, or nothing readable, means no version has been opened yet. +const NONE_SEEN: SeenVersions = {}; + function parseSeen(raw: string): SeenVersions { - try { - const parsed: unknown = JSON.parse(raw); - return typeof parsed === "object" && - parsed !== null && - !Array.isArray(parsed) - ? (parsed as SeenVersions) - : {}; - } catch { - return {}; - } + return readJson(raw, Seen) ?? NONE_SEEN; } export function useSeenVersions(): SeenVersions { diff --git a/apps/web/src/pages/workspace/specialist-updates.test.ts b/apps/web/src/pages/workspace/specialist-updates.test.ts new file mode 100644 index 00000000..0de39d84 --- /dev/null +++ b/apps/web/src/pages/workspace/specialist-updates.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test"; +import { specialistUpdate } from "./specialist-updates.tsx"; + +test("mail is read for its summary, and JSON that is not an envelope reads as text", () => { + const summary = JSON.stringify({ result: { summary: "Brief drafted." } }); + expect(specialistUpdate("writer", summary, 1)?.text).toBe("Brief drafted."); + expect(specialistUpdate("writer", "null", 1)?.text).toBe("null"); + expect( + specialistUpdate("writer", JSON.stringify({ result: { summary: 3 } }), 1), + ).toBeNull(); +}); diff --git a/apps/web/src/pages/workspace/specialist-updates.tsx b/apps/web/src/pages/workspace/specialist-updates.tsx index e148a502..a7531d0b 100644 --- a/apps/web/src/pages/workspace/specialist-updates.tsx +++ b/apps/web/src/pages/workspace/specialist-updates.tsx @@ -18,6 +18,8 @@ import { import { Markdown } from "../../markdown.jsx"; import type { ConversationThread } from "../../api/conversation.ts"; import { participantLabel, visibleChatText } from "./visible-chat.ts"; +import { type } from "arktype"; +import { readJson } from "../../json.ts"; export type SpecialistUpdate = { readonly sender: string; @@ -31,16 +33,16 @@ export type UpdateGroup = { readonly updates: readonly SpecialistUpdate[]; }; +const MailEnvelope = type("object"); +const MailResult = type({ result: { summary: "string" } }); + // Mail between agents is a JSON envelope; its prose field is what a person can read. // A task envelope is the hand-off, already shown as that call in the coordinator's turn. function mailProse(text: string): string { - try { - const value = JSON.parse(text) as { result?: { summary?: unknown } }; - if (typeof value.result?.summary === "string") return value.result.summary; - } catch { - return text; - } - return ""; + const envelope = readJson(text, MailEnvelope); + if (envelope === null) return text; + const result = MailResult(envelope); + return result instanceof type.errors ? "" : result.result.summary; } /** One specialist turn as an update, or null when nothing in it is for a person. */ diff --git a/packages/client/src/client/credentials.ts b/packages/client/src/client/credentials.ts index 57316c42..02880102 100644 --- a/packages/client/src/client/credentials.ts +++ b/packages/client/src/client/credentials.ts @@ -1,3 +1,4 @@ +import type { ModelLoginPlugin } from "@corbits-builder/schemas/models"; import { tenantPath, type ClientDeps, type ProductApiClient } from "./shared"; /** @@ -62,7 +63,13 @@ export type ModelLoginState = Readonly< | { status: "completed"; credentialId: string } | { status: "failed"; message: string } >; -export type ModelLoginPlugin = "codex" | "xai"; +export type { ModelLoginPlugin }; + +// CODEX_BASE_URL and XAI_OAUTH_PROXY_BASE_URL: the only endpoints these logins' tokens work on. +const LOGIN_BASE_URLS: Record = { + codex: "https://chatgpt.com/backend-api", + xai: "https://cli-chat-proxy.grok.com/v1", +}; const DEFAULT_BASE_URLS: Record = { openai: "https://api.openai.com/v1", @@ -70,9 +77,7 @@ const DEFAULT_BASE_URLS: Record = { anthropic: "https://api.anthropic.com/v1", "google-genai": "https://generativelanguage.googleapis.com/v1beta", "openai-compatible": "", - // CODEX_BASE_URL and XAI_OAUTH_PROXY_BASE_URL: the only endpoints these logins' tokens work on. - codex: "https://chatgpt.com/backend-api", - xai: "https://cli-chat-proxy.grok.com/v1", + ...LOGIN_BASE_URLS, }; function uniqueSuffix(): string { @@ -204,7 +209,7 @@ export function buildCredentialsMethods( // tenant-owned credential. A new credential name per sign-in, because a tenant-owned login // never replaces an existing credential. async startModelLogin(tenantId, plugin, name, signal) { - const baseURL = DEFAULT_BASE_URLS[plugin]!; + const baseURL = LOGIN_BASE_URLS[plugin]; const nativeProvider = await ensureNativeProvider( tenantId, plugin, @@ -250,7 +255,7 @@ export function buildCredentialsMethods( const created = (await request(`${root}/catalog/providers`, signal, { name: `${name} ${uniqueSuffix()}`, plugin, - baseURL: DEFAULT_BASE_URLS[plugin], + baseURL: LOGIN_BASE_URLS[plugin], credentialId, })) as { id?: unknown }; if (typeof created?.id !== "string") diff --git a/packages/schemas/src/models.ts b/packages/schemas/src/models.ts index a69cafb4..784b6ea6 100644 --- a/packages/schemas/src/models.ts +++ b/packages/schemas/src/models.ts @@ -42,6 +42,10 @@ export const CustomModel = type({ ); export type CustomModel = typeof CustomModel.infer; +// The subscriptions a person signs in to through the hub instead of pasting a key. +export const ModelLoginPlugin = type("'codex' | 'xai'"); +export type ModelLoginPlugin = typeof ModelLoginPlugin.infer; + // The wire API a custom provider speaks, named by the native adapter plugin that serves it. export const CustomApi = type( "'openai-compatible' | 'openai-responses' | 'anthropic'",