130 lines
6.7 KiB
TypeScript
130 lines
6.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { UIMessageChunk } from "ai";
|
|
import { z } from "zod";
|
|
import { agentTriggerContextSchema } from "../routes/agentTrigger.js";
|
|
|
|
const text = z.string().min(1).max(10000);
|
|
const inputMessage = z.object({
|
|
type: z.literal("message").optional(), role: z.literal("user"),
|
|
content: z.union([text, z.array(z.object({ type: z.literal("input_text"), text }).strict()).min(1).max(20)]),
|
|
}).strict();
|
|
|
|
// A Responses text-generation surface backed by the owned OpenCode session.
|
|
// Unsupported OpenAI options fail validation instead of being silently ignored.
|
|
export const responseRequestSchema = z.object({
|
|
input: z.union([text, z.array(inputMessage).min(1).max(20)]),
|
|
model: z.string().min(1).optional(),
|
|
stream: z.boolean().default(false),
|
|
conversation: z.union([z.string().min(1).max(128), z.object({ id: z.string().min(1).max(128) }).strict()]).optional(),
|
|
metadata: z.record(z.string().max(64), z.string().max(512)).refine((value) => Object.keys(value).length <= 16).optional(),
|
|
tjwater: z.object({
|
|
topic_id: z.string().uuid().optional(),
|
|
client_message_id: z.string().min(1).max(128).optional(),
|
|
delivery: z.enum(["steer", "queue"]).optional(),
|
|
input_id: z.string().regex(/^msg_[a-f0-9]{32}$/).optional(),
|
|
interaction_id: z.string().min(1).max(128).optional(),
|
|
approval_mode: z.enum(["request", "always"]).default("request"),
|
|
capabilities: z.array(z.string().max(64)).max(20).default([]),
|
|
trigger_context: agentTriggerContextSchema.optional(),
|
|
task_context: z.object({
|
|
report_revision: z.number().int().positive().optional(),
|
|
evidence_id: z.string().min(1).max(128).optional(), gap_id: z.string().min(1).max(128).optional(),
|
|
}).strict().optional(),
|
|
}).strict().default({}),
|
|
}).strict();
|
|
|
|
export function responsePrompt(input: z.infer<typeof responseRequestSchema>["input"]) {
|
|
return typeof input === "string" ? input : input.map((message) => typeof message.content === "string"
|
|
? message.content : message.content.map((part) => part.text).join("\n")).join("\n\n");
|
|
}
|
|
|
|
type OutputText = { type: "output_text"; text: string; annotations: unknown[]; logprobs: unknown[] };
|
|
type OutputMessage = { id: string; type: "message"; status: "in_progress" | "completed" | "incomplete"; role: "assistant"; content: OutputText[] };
|
|
export type ResponseObject = {
|
|
id: string; object: "response"; created_at: number; completed_at: number | null;
|
|
status: "in_progress" | "completed" | "failed" | "incomplete"; model: string;
|
|
conversation: { id: string }; output: OutputMessage[]; usage: null;
|
|
error: { code: string; message: string } | null; incomplete_details: { reason: string } | null;
|
|
metadata: Record<string, string>;
|
|
};
|
|
export type ResponseEvent = Record<string, unknown> & { type: string; sequence_number: number };
|
|
|
|
export function createResponsesProjection(options: {
|
|
model: string; conversationId: string; metadata?: Record<string, string>;
|
|
emit: (event: ResponseEvent) => void;
|
|
}) {
|
|
const response: ResponseObject = {
|
|
id: `resp_${randomUUID().replaceAll("-", "")}`, object: "response", created_at: Math.floor(Date.now() / 1000),
|
|
completed_at: null, status: "in_progress", model: options.model, conversation: { id: options.conversationId },
|
|
output: [], usage: null, error: null, incomplete_details: null, metadata: options.metadata ?? {},
|
|
};
|
|
let sequence = 0;
|
|
let started = false;
|
|
const textIndices = new Map<string, number>();
|
|
const closedText = new Set<string>();
|
|
const emit = (type: string, value: Record<string, unknown>) => options.emit({ type, sequence_number: sequence++, ...structuredClone(value) });
|
|
const begin = () => {
|
|
if (started) return;
|
|
started = true;
|
|
emit("response.created", { response });
|
|
emit("response.in_progress", { response });
|
|
};
|
|
const message = (id?: string) => {
|
|
if (!response.output.length) {
|
|
response.output.push({ id: id ?? `msg_${randomUUID().replaceAll("-", "")}`, type: "message", status: "in_progress", role: "assistant", content: [] });
|
|
emit("response.output_item.added", { output_index: 0, item: response.output[0] });
|
|
}
|
|
return response.output[0]!;
|
|
};
|
|
const openText = (id: string) => {
|
|
const item = message();
|
|
if (!textIndices.has(id)) {
|
|
const index = item.content.length;
|
|
textIndices.set(id, index);
|
|
item.content.push({ type: "output_text", text: "", annotations: [], logprobs: [] });
|
|
emit("response.content_part.added", { item_id: item.id, output_index: 0, content_index: index, part: item.content[index] });
|
|
}
|
|
return { item, index: textIndices.get(id)! };
|
|
};
|
|
const endText = (id: string) => {
|
|
if (!textIndices.has(id) || closedText.has(id)) return;
|
|
const { item, index } = openText(id);
|
|
closedText.add(id);
|
|
emit("response.output_text.done", { item_id: item.id, output_index: 0, content_index: index, text: item.content[index]!.text, logprobs: [] });
|
|
emit("response.content_part.done", { item_id: item.id, output_index: 0, content_index: index, part: item.content[index] });
|
|
};
|
|
const finish = (reason: "completed" | "failed" | "cancelled" = "completed") => {
|
|
if (response.status !== "in_progress") return;
|
|
begin();
|
|
for (const id of textIndices.keys()) endText(id);
|
|
for (const item of response.output) {
|
|
item.status = reason === "completed" ? "completed" : "incomplete";
|
|
emit("response.output_item.done", { output_index: 0, item });
|
|
}
|
|
response.status = reason === "cancelled" ? "incomplete" : reason;
|
|
if (reason === "completed") response.completed_at = Math.floor(Date.now() / 1000);
|
|
emit(`response.${response.status}`, { response });
|
|
};
|
|
return {
|
|
response, begin, finish,
|
|
write(chunk: UIMessageChunk) {
|
|
if (response.status !== "in_progress") return;
|
|
begin();
|
|
if (chunk.type === "start") message(chunk.messageId);
|
|
else if (chunk.type === "text-start") openText(chunk.id);
|
|
else if (chunk.type === "text-delta") {
|
|
const { item, index } = openText(chunk.id);
|
|
item.content[index]!.text += chunk.delta;
|
|
emit("response.output_text.delta", { item_id: item.id, output_index: 0, content_index: index, delta: chunk.delta, logprobs: [] });
|
|
} else if (chunk.type === "text-end") endText(chunk.id);
|
|
else if (chunk.type === "error") response.error = { code: "agent_execution_error", message: chunk.errorText };
|
|
else if (chunk.type.startsWith("data-")) {
|
|
const data = chunk as { type: string; data: unknown; id?: string; transient?: boolean };
|
|
emit(`tjwater.${chunk.type.slice(5)}`, { response_id: response.id, data: data.data,
|
|
...(data.id ? { id: data.id } : {}), ...(data.transient ? { transient: true } : {}) });
|
|
}
|
|
// Finalization belongs to the route, after task completion and persistence.
|
|
},
|
|
};
|
|
}
|