diff options
Diffstat (limited to 'packages/transport-http/src/logic.ts')
| -rw-r--r-- | packages/transport-http/src/logic.ts | 466 |
1 files changed, 261 insertions, 205 deletions
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 4e099c4..c703049 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -1,19 +1,19 @@ import type { - AgentEvent, - ChatMessage, - ConversationStatus, - ReasoningEffort, + AgentEvent, + ChatMessage, + ConversationStatus, + ReasoningEffort, } from "@dispatch/kernel"; const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; -const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed"]; +const VALID_STATUSES: readonly ConversationStatus[] = ["active", "queued", "idle", "closed"]; /** * Pure: parse a `?status=` query value into a list of valid ConversationStatus @@ -22,43 +22,60 @@ const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed * `undefined` (no filter — shows all). */ export function parseStatusFilter( - raw: string | undefined, + raw: string | undefined, ): readonly ConversationStatus[] | undefined { - if (raw === undefined) return undefined; - const trimmed = raw.trim(); - if (trimmed.length === 0) return undefined; - const parts = trimmed - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const valid = parts.filter((p): p is ConversationStatus => - VALID_STATUSES.includes(p as ConversationStatus), - ); - return valid.length > 0 ? valid : undefined; + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const parts = trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const valid = parts.filter((p): p is ConversationStatus => + VALID_STATUSES.includes(p as ConversationStatus), + ); + return valid.length > 0 ? valid : undefined; } export function isValidReasoningEffort(value: unknown): value is ReasoningEffort { - return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); + return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); } export interface ChatCommand { - readonly conversationId: string; - readonly message: string; - readonly model?: string; - readonly cwd?: string; - /** - * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded - * to the orchestrator verbatim and never part of the model prompt. When - * absent, the orchestrator resolves the per-conversation → workspace - * default → local chain. - */ - readonly computerId?: string; - readonly reasoningEffort?: ReasoningEffort; - readonly workspaceId?: string; + readonly conversationId: string; + readonly message: string; + readonly model?: string; + readonly cwd?: string; + /** + * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded + * to the orchestrator verbatim and never part of the model prompt. When + * absent, the orchestrator resolves the per-conversation → workspace + * default → local chain. + */ + readonly computerId?: string; + readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; + /** + * A human-readable title for the conversation tab, set at creation time. + * Parsed from the `ChatRequest.title` field; trimmed server-side. A + * whitespace-only value is treated as absent (omitted) so the auto-derived + * title applies. Forwarded to the orchestrator, which persists it via the + * conversation store's `setConversationTitle` AFTER the new-conversation + * workspace setup (so workspace assignment / first-turn system-prompt + * construction are not skipped) and before the first message append. + */ + readonly title?: string; + /** + * Images attached to this turn (data URLs or http URLs). Parsed from the + * `ChatRequest.images` field; forwarded to the orchestrator which converts + * them to `image` chunks on the user message. Each entry must have a non-empty + * string `url`; `mimeType` is optional. + */ + readonly images?: readonly { readonly url: string; readonly mimeType?: string }[]; } export interface ParseError { - readonly error: string; + readonly error: string; } export type ParseResult = ChatCommand | ParseError; @@ -66,83 +83,122 @@ export type ParseResult = ChatCommand | ParseError; export type SinceSeqResult = number | ParseError; export function parseChatBody(body: unknown, generateId: () => string): ParseResult { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record<string, unknown>; - - const message = obj.message; - if (typeof message !== "string" || message.trim().length === 0) { - return { error: "Field 'message' is required and must be a non-empty string" }; - } - - const conversationId = - typeof obj.conversationId === "string" && obj.conversationId.length > 0 - ? obj.conversationId - : generateId(); - - const result: ChatCommand = { conversationId, message: message.trim() }; - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return { error: "Field 'model' must be a string" }; - } - (result as { model?: string }).model = obj.model; - } - - if (obj.cwd !== undefined) { - if (typeof obj.cwd !== "string") { - return { error: "Field 'cwd' must be a string" }; - } - (result as { cwd?: string }).cwd = obj.cwd; - } - - if (obj.computerId !== undefined) { - if (typeof obj.computerId !== "string") { - return { error: "Field 'computerId' must be a string" }; - } - (result as { computerId?: string }).computerId = obj.computerId; - } - - if (obj.reasoningEffort !== undefined) { - if (!isValidReasoningEffort(obj.reasoningEffort)) { - return { - error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, - }; - } - (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; - } - - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string") { - return { error: "Field 'workspaceId' must be a string" }; - } - (result as { workspaceId?: string }).workspaceId = obj.workspaceId; - } - - return result; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record<string, unknown>; + + const message = obj.message; + if (typeof message !== "string" || message.trim().length === 0) { + return { error: "Field 'message' is required and must be a non-empty string" }; + } + + const conversationId = + typeof obj.conversationId === "string" && obj.conversationId.length > 0 + ? obj.conversationId + : generateId(); + + const result: ChatCommand = { conversationId, message: message.trim() }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + (result as { model?: string }).model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + (result as { cwd?: string }).cwd = obj.cwd; + } + + if (obj.computerId !== undefined) { + if (typeof obj.computerId !== "string") { + return { error: "Field 'computerId' must be a string" }; + } + (result as { computerId?: string }).computerId = obj.computerId; + } + + if (obj.reasoningEffort !== undefined) { + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; + } + + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string") { + return { error: "Field 'workspaceId' must be a string" }; + } + (result as { workspaceId?: string }).workspaceId = obj.workspaceId; + } + + if (obj.title !== undefined) { + if (typeof obj.title !== "string") { + return { error: "Field 'title' must be a string" }; + } + const title = obj.title.trim(); + // A whitespace-only title is treated as absent so the auto-derived title + // applies (mirrors omitting the field) — never persist an empty title. + if (title.length > 0) { + (result as { title?: string }).title = title; + } + } + + if (obj.images !== undefined) { + if (!Array.isArray(obj.images)) { + return { error: "Field 'images' must be an array" }; + } + const images: { url: string; mimeType?: string }[] = []; + for (const entry of obj.images) { + if (entry === null || typeof entry !== "object") { + return { error: "Each image must be an object with a 'url' string" }; + } + const img = entry as { url?: unknown; mimeType?: unknown }; + if (typeof img.url !== "string" || img.url.length === 0) { + return { error: "Each image must have a non-empty string 'url'" }; + } + const parsed: { url: string; mimeType?: string } = { url: img.url }; + if (img.mimeType !== undefined) { + if (typeof img.mimeType !== "string") { + return { error: "Field 'mimeType' on an image must be a string" }; + } + parsed.mimeType = img.mimeType; + } + images.push(parsed); + } + if (images.length > 0) { + (result as { images?: readonly { url: string; mimeType?: string }[] }).images = images; + } + } + + return result; } export function isParseError<T>(result: T | ParseError): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } export function serializeEventLine(event: AgentEvent): string { - return `${JSON.stringify(event)}\n`; + return `${JSON.stringify(event)}\n`; } export function parseSinceSeq(raw: string | undefined): SinceSeqResult { - if (raw === undefined || raw === "") return 0; - const n = Number(raw); - if (!Number.isInteger(n) || n < 0) { - return { error: "sinceSeq must be a non-negative integer" }; - } - return n; + if (raw === undefined || raw === "") return 0; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: "sinceSeq must be a non-negative integer" }; + } + return n; } export function isSinceSeqError(result: SinceSeqResult): result is ParseError { - return typeof result === "object"; + return typeof result === "object"; } /** @@ -160,67 +216,67 @@ export type WindowParamResult = number | undefined | ParseError; * Absent (`undefined` / empty) is the valid "no window" case → `undefined`. */ export function parseWindowParam(raw: string | undefined, name: string): WindowParamResult { - if (raw === undefined || raw === "") return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) { - return { error: `${name} must be a positive integer` }; - } - return n; + if (raw === undefined || raw === "") return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + return { error: `${name} must be a positive integer` }; + } + return n; } export function isWindowParamError(result: WindowParamResult): result is ParseError { - return typeof result === "object" && result !== null; + return typeof result === "object" && result !== null; } export interface WarmBodyParsed { - readonly conversationId: string; - readonly model?: string; - readonly cwd?: string; + readonly conversationId: string; + readonly model?: string; + readonly cwd?: string; } export function parseWarmBody(body: unknown): WarmBodyParsed | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record<string, unknown>; - - const conversationId = obj.conversationId; - if (typeof conversationId !== "string" || conversationId.length === 0) { - return { error: "Field 'conversationId' is required and must be a non-empty string" }; - } - - const result: Record<string, unknown> = { conversationId }; - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return { error: "Field 'model' must be a string" }; - } - result.model = obj.model; - } - - if (obj.cwd !== undefined) { - if (typeof obj.cwd !== "string") { - return { error: "Field 'cwd' must be a string" }; - } - result.cwd = obj.cwd; - } - - return result as unknown as WarmBodyParsed; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record<string, unknown>; + + const conversationId = obj.conversationId; + if (typeof conversationId !== "string" || conversationId.length === 0) { + return { error: "Field 'conversationId' is required and must be a non-empty string" }; + } + + const result: Record<string, unknown> = { conversationId }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + result.model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + result.cwd = obj.cwd; + } + + return result as unknown as WarmBodyParsed; } export function computeCachePct(inputTokens: number, cacheReadTokens: number): number { - if (inputTokens <= 0) return 0; - return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); + if (inputTokens <= 0) return 0; + return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); } export function computeExpectedCacheRate( - cacheReadTokens: number, - cacheWriteTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number, ): number { - const denom = cacheReadTokens + cacheWriteTokens; - if (denom <= 0) return 0; - return Math.round((cacheReadTokens / denom) * 100); + const denom = cacheReadTokens + cacheWriteTokens; + if (denom <= 0) return 0; + return Math.round((cacheReadTokens / denom) * 100); } /** @@ -229,8 +285,8 @@ export function computeExpectedCacheRate( * is deliberately NOT part of this parse result. */ export interface QueueBodyParsed { - readonly text: string; - readonly workspaceId?: string; + readonly text: string; + readonly workspaceId?: string; } /** @@ -241,46 +297,46 @@ export interface QueueBodyParsed { * `message`. */ export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } - const obj = body as Record<string, unknown>; + const obj = body as Record<string, unknown>; - const text = obj.text; - if (typeof text !== "string" || text.trim().length === 0) { - return { error: "Field 'text' is required and must be a non-empty string" }; - } + const text = obj.text; + if (typeof text !== "string" || text.trim().length === 0) { + return { error: "Field 'text' is required and must be a non-empty string" }; + } - const result: QueueBodyParsed = { text: text.trim() }; + const result: QueueBodyParsed = { text: text.trim() }; - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string") { - return { error: "Field 'workspaceId' must be a string" }; - } - return { text: text.trim(), workspaceId: obj.workspaceId }; - } + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string") { + return { error: "Field 'workspaceId' must be a string" }; + } + return { text: text.trim(), workspaceId: obj.workspaceId }; + } - return result; + return result; } export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - const obj = body as Record<string, unknown>; - if (!isValidReasoningEffort(obj.reasoningEffort)) { - return { - error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, - }; - } - return obj.reasoningEffort; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record<string, unknown>; + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + return obj.reasoningEffort; } export function isReasoningEffortParseError( - result: ReasoningEffort | ParseError, + result: ReasoningEffort | ParseError, ): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } /** @@ -293,21 +349,21 @@ export function isReasoningEffortParseError( * Returns the validated `model` value (`string | null`) on success. */ export function parseModelBody(body: unknown): string | null | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - const obj = body as Record<string, unknown>; - if (obj.model === undefined) { - return { error: "Field 'model' is required and must be a string or null" }; - } - if (obj.model !== null && typeof obj.model !== "string") { - return { error: "Field 'model' must be a string or null" }; - } - return obj.model as string | null; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record<string, unknown>; + if (obj.model === undefined) { + return { error: "Field 'model' is required and must be a string or null" }; + } + if (obj.model !== null && typeof obj.model !== "string") { + return { error: "Field 'model' must be a string or null" }; + } + return obj.model as string | null; } export function isModelParseError(result: string | null | ParseError): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } /** @@ -322,20 +378,20 @@ export function isModelParseError(result: string | null | ParseError): result is * Pure (input → output); zero I/O, so it tests directly without mocks. */ export function extractLastAssistantText(messages: readonly ChatMessage[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg === undefined || msg.role !== "assistant") continue; - // Found the last assistant message — scan its chunks from the end for - // the last `text` chunk. Stop here (do not keep scanning earlier - // assistant messages): the contract is "the last assistant message's - // last text chunk", not "the most recent text chunk anywhere". - for (let j = msg.chunks.length - 1; j >= 0; j--) { - const chunk = msg.chunks[j]; - if (chunk !== undefined && chunk.type === "text") { - return chunk.text; - } - } - return ""; - } - return ""; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg === undefined || msg.role !== "assistant") continue; + // Found the last assistant message — scan its chunks from the end for + // the last `text` chunk. Stop here (do not keep scanning earlier + // assistant messages): the contract is "the last assistant message's + // last text chunk", not "the most recent text chunk anywhere". + for (let j = msg.chunks.length - 1; j >= 0; j--) { + const chunk = msg.chunks[j]; + if (chunk !== undefined && chunk.type === "text") { + return chunk.text; + } + } + return ""; + } + return ""; } |
