summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src/logic.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/transport-http/src/logic.ts')
-rw-r--r--packages/transport-http/src/logic.ts466
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 "";
}