summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src/logic.ts
blob: 4e099c4df726d21e4c6109a5192d8843d7426a7c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
import type {
	AgentEvent,
	ChatMessage,
	ConversationStatus,
	ReasoningEffort,
} from "@dispatch/kernel";

const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [
	"low",
	"medium",
	"high",
	"xhigh",
	"max",
];

const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed"];

/**
 * Pure: parse a `?status=` query value into a list of valid ConversationStatus
 * values. Returns `undefined` when the input is missing/empty (no filter).
 * Invalid values are silently dropped; if ALL values are invalid, returns
 * `undefined` (no filter — shows all).
 */
export function parseStatusFilter(
	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;
}

export function isValidReasoningEffort(value: unknown): value is 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;
}

export interface ParseError {
	readonly error: string;
}

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;
}

export function isParseError<T>(result: T | ParseError): result is ParseError {
	return typeof result === "object" && result !== null && "error" in result;
}

export function serializeEventLine(event: AgentEvent): string {
	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;
}

export function isSinceSeqError(result: SinceSeqResult): result is ParseError {
	return typeof result === "object";
}

/**
 * Result of parsing an OPTIONAL positive-integer history-window query param
 * (`limit` / `beforeSeq`): `undefined` = the param was absent (omit it from the
 * store window), a `number` = a valid positive integer, or a {@link ParseError}
 * for a malformed / zero / negative value (the route 400s on it).
 */
export type WindowParamResult = number | undefined | ParseError;

/**
 * Parse an optional `limit` / `beforeSeq` query param. Unlike `sinceSeq` these
 * must be STRICTLY POSITIVE integers when present (zero is rejected, since the
 * store treats a zero bound as absent and would silently return the full log).
 * 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;
}

export function isWindowParamError(result: WindowParamResult): result is ParseError {
	return typeof result === "object" && result !== null;
}

export interface WarmBodyParsed {
	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;
}

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);
}

export function computeExpectedCacheRate(
	cacheReadTokens: number,
	cacheWriteTokens: number,
): number {
	const denom = cacheReadTokens + cacheWriteTokens;
	if (denom <= 0) return 0;
	return Math.round((cacheReadTokens / denom) * 100);
}

/**
 * Parsed body for `POST /conversations/:id/queue` (`QueueRequest`). Only the
 * `text` field — `conversationId` comes from the path param, not the body, so it
 * is deliberately NOT part of this parse result.
 */
export interface QueueBodyParsed {
	readonly text: string;
	readonly workspaceId?: string;
}

/**
 * Parse + validate a `POST /conversations/:id/queue` body (`QueueRequest`).
 * `text` must be a non-empty string after trim — invalid/missing →
 * {@link ParseError}. The TRIMMED text is returned (forwarded to
 * `orchestrator.enqueue`), mirroring how `parseChatBody` forwards a trimmed
 * `message`.
 */
export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError {
	if (body === null || typeof body !== "object") {
		return { error: "Request body must be a JSON object" };
	}

	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 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 };
	}

	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;
}

export function isReasoningEffortParseError(
	result: ReasoningEffort | ParseError,
): result is ParseError {
	return typeof result === "object" && result !== null && "error" in result;
}

/**
 * Parse + validate a `PUT /conversations/:id/model` body (`SetModelRequest`).
 * `model` must be present and either a string (any value, including the empty
 * string — which clears the persisted selection) or `null`. A missing field or
 * a non-string/non-null value → {@link ParseError}. There is no enum
 * validation (the provider resolves model names at turn time).
 *
 * 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;
}

export function isModelParseError(result: string | null | ParseError): result is ParseError {
	return typeof result === "object" && result !== null && "error" in result;
}

/**
 * Extract the text of the last assistant message's last `text` chunk — the
 * "show me the last reply" affordance for `GET /conversations/:id/last`.
 *
 * Scan from the END for the last message with `role: "assistant"`, then within
 * THAT message for the last `type: "text"` chunk. Returns its `text`. Returns
 * `""` when there is no assistant message, or when the last assistant message
 * has no text chunk (e.g. only tool-call chunks).
 *
 * 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 "";
}