Gateway: T3 RPC client, host connection, awareness port, storage, API contract
This commit is contained in:
1 parent
0fd556e313
commit
972f6e4be5
13 files changed
+1332
-9
No files matched your search
@@ -0,0 +1,267 @@
|
||||
// One T3 Code server. Keeps a shell stream open, holds every project and thread shell in memory,
|
||||
// and reconnects with backoff. Writes go over the same socket, and thread pages go over HTTP.
|
||||
|
||||
import { RpcError, RpcSocket } from "./rpc.ts";
|
||||
import {
|
||||
ORCHESTRATION_PROTOCOL_VERSION,
|
||||
type BoundedThread,
|
||||
type Command,
|
||||
type EnvironmentDescriptor,
|
||||
type LaunchThreadInput,
|
||||
type Project,
|
||||
type ServerConfig,
|
||||
type ShellStreamItem,
|
||||
type ThreadShell,
|
||||
} from "./types.ts";
|
||||
|
||||
export type HostStatus =
|
||||
| "connecting"
|
||||
| "online"
|
||||
| "offline"
|
||||
/** The token expired or was revoked. A new one has to be paired in. */
|
||||
| "auth_invalid"
|
||||
/** The server speaks another protocol version or is a different environment. */
|
||||
| "incompatible";
|
||||
|
||||
export interface HostConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The server's HTTP origin, such as `https://t3mac.gbrown.org`. */
|
||||
url: string;
|
||||
token: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export interface ThreadChange {
|
||||
host: T3Host;
|
||||
previous: ThreadShell | undefined;
|
||||
next: ThreadShell | undefined;
|
||||
/** True while loading the first snapshot after start, when nothing should notify. */
|
||||
initial: boolean;
|
||||
}
|
||||
|
||||
export interface HostOptions {
|
||||
onThreadChange?: (change: ThreadChange) => void;
|
||||
onStatus?: (host: T3Host) => void;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
const BACKOFF_MIN_MS = 1_000;
|
||||
const BACKOFF_MAX_MS = 5 * 60_000;
|
||||
const AUTH_RETRY_MS = 10 * 60_000;
|
||||
|
||||
export class HostError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HostError";
|
||||
}
|
||||
}
|
||||
|
||||
export class T3Host {
|
||||
status: HostStatus = "connecting";
|
||||
lastError: string | null = null;
|
||||
readonly projects = new Map<string, Project>();
|
||||
readonly threads = new Map<string, ThreadShell>();
|
||||
serverConfig: ServerConfig | null = null;
|
||||
|
||||
private socket: RpcSocket | null = null;
|
||||
private sequence: number | null = null;
|
||||
private hasSnapshot = false;
|
||||
private stopped = false;
|
||||
private attempt = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly config: HostConfig,
|
||||
private readonly options: HostOptions = {},
|
||||
) {}
|
||||
|
||||
get id() {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
get label() {
|
||||
return this.config.label;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.stopped = false;
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
this.socket?.close();
|
||||
}
|
||||
|
||||
/** Sends one orchestration command and resolves with its sequence. */
|
||||
async dispatch(command: Command): Promise<{ sequence: number }> {
|
||||
return this.requireSocket().call("orchestration.dispatchCommand", command);
|
||||
}
|
||||
|
||||
async launchThread(input: LaunchThreadInput): Promise<{ threadId: string }> {
|
||||
return this.requireSocket().call("orchestration.launchThread", input);
|
||||
}
|
||||
|
||||
/** The recent window of one thread: its messages and turn items. */
|
||||
bounded(threadId: string): Promise<BoundedThread> {
|
||||
return this.http<BoundedThread>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`);
|
||||
}
|
||||
|
||||
/** The session's expiry, from `/api/auth/session`. */
|
||||
async sessionExpiresAt(): Promise<string | null> {
|
||||
const session = await this.http<{ expiresAt?: string }>("/api/auth/session");
|
||||
return session.expiresAt ?? null;
|
||||
}
|
||||
|
||||
supports(capability: string): boolean {
|
||||
return this.serverConfig?.environment?.capabilities?.[capability] === true;
|
||||
}
|
||||
|
||||
private requireSocket(): RpcSocket {
|
||||
if (this.socket === null || this.status !== "online") {
|
||||
throw new HostError(`${this.label} is ${this.status}`, 503);
|
||||
}
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
private async http<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(new URL(path, this.config.url), {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.config.token}`,
|
||||
"x-t3-orchestration-protocol": String(ORCHESTRATION_PROTOCOL_VERSION),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new HostError(`${this.label} ${path} returned ${response.status}: ${body.slice(0, 200)}`, response.status);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private setStatus(status: HostStatus, error: string | null = null) {
|
||||
this.status = status;
|
||||
this.lastError = error;
|
||||
this.options.onStatus?.(this);
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
if (this.stopped) return;
|
||||
this.setStatus("connecting", this.lastError);
|
||||
try {
|
||||
const descriptor = await this.http<EnvironmentDescriptor>("/.well-known/t3/environment");
|
||||
const protocol = descriptor.orchestrationProtocolVersion ?? 1;
|
||||
if (protocol !== ORCHESTRATION_PROTOCOL_VERSION) {
|
||||
throw new HostError(`speaks orchestration protocol ${protocol}, the gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}`, 426);
|
||||
}
|
||||
if (descriptor.environmentId !== this.config.environmentId) {
|
||||
throw new HostError(`is environment ${descriptor.environmentId}, expected ${this.config.environmentId}`, 409);
|
||||
}
|
||||
const { ticket } = await this.http<{ ticket: string }>("/api/auth/websocket-ticket/rpc", { method: "POST" });
|
||||
const wsUrl = new URL("/ws", this.config.url);
|
||||
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
wsUrl.searchParams.set("wsTicket", ticket);
|
||||
wsUrl.searchParams.set("orchestrationProtocol", String(ORCHESTRATION_PROTOCOL_VERSION));
|
||||
const socket = await RpcSocket.connect(wsUrl.href);
|
||||
this.socket = socket;
|
||||
this.serverConfig = await socket.call<ServerConfig>("server.getConfig", {});
|
||||
this.subscribe(socket);
|
||||
this.attempt = 0;
|
||||
this.setStatus("online");
|
||||
await socket.closed;
|
||||
this.socket = null;
|
||||
this.scheduleRetry("offline", "connection closed");
|
||||
} catch (error) {
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof HostError && error.status === 401) this.scheduleRetry("auth_invalid", message);
|
||||
else if (error instanceof HostError && (error.status === 426 || error.status === 409)) this.scheduleRetry("incompatible", message);
|
||||
else this.scheduleRetry("offline", message);
|
||||
}
|
||||
}
|
||||
|
||||
private subscribe(socket: RpcSocket) {
|
||||
const input = this.hasSnapshot && this.sequence !== null ? { afterSequence: this.sequence } : {};
|
||||
const { done } = socket.stream("orchestration.subscribeShell", input, (value) =>
|
||||
this.apply(value as ShellStreamItem),
|
||||
);
|
||||
// The stream only ends when the socket does, or on a server error. Either way, start over.
|
||||
done.then(
|
||||
() => socket.close(),
|
||||
(error: RpcError) => {
|
||||
this.options.log?.(`${this.label}: shell stream ended: ${error.message}`);
|
||||
socket.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleRetry(status: HostStatus, error: string) {
|
||||
if (this.stopped) return;
|
||||
this.setStatus(status, error);
|
||||
this.options.log?.(`${this.label}: ${status}: ${error}`);
|
||||
const delay =
|
||||
status === "auth_invalid" || status === "incompatible"
|
||||
? AUTH_RETRY_MS
|
||||
: Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** this.attempt++) * (0.5 + Math.random() / 2);
|
||||
this.retryTimer = setTimeout(() => void this.connect(), delay);
|
||||
}
|
||||
|
||||
/** Applies one shell stream item to the in-memory state, reporting each thread that changed. */
|
||||
apply(item: ShellStreamItem) {
|
||||
switch (item.kind) {
|
||||
case "snapshot": {
|
||||
const { snapshot } = item;
|
||||
for (const project of snapshot.projects) this.projects.set(project.id, project);
|
||||
const known = new Set(snapshot.projects.map((project) => project.id));
|
||||
for (const id of this.projects.keys()) if (!known.has(id)) this.projects.delete(id);
|
||||
this.sequence = Math.max(this.sequence ?? 0, snapshot.snapshotSequence);
|
||||
// The server also sends project-only snapshots with no threads: one first on every resume,
|
||||
// and more as project metadata fills in. T3's own client keeps its threads for those, and
|
||||
// so does the gateway.
|
||||
if (snapshot.threads.length === 0 && this.hasSnapshot) return;
|
||||
const initial = !this.hasSnapshot;
|
||||
this.hasSnapshot = true;
|
||||
const next = new Map(snapshot.threads.map((thread) => [thread.id, thread]));
|
||||
for (const [id, previous] of this.threads) {
|
||||
if (!next.has(id)) this.changeThread(previous, undefined, initial);
|
||||
}
|
||||
for (const thread of next.values()) this.changeThread(this.threads.get(thread.id), thread, initial);
|
||||
return;
|
||||
}
|
||||
case "project.updated":
|
||||
this.projects.set(item.project.id, item.project);
|
||||
this.sequence = item.sequence;
|
||||
return;
|
||||
case "project.removed":
|
||||
this.projects.delete(item.projectId);
|
||||
this.sequence = item.sequence;
|
||||
return;
|
||||
case "thread.updated":
|
||||
this.sequence = item.sequence;
|
||||
if (item.location === "active") this.changeThread(this.threads.get(item.thread.id), item.thread, false);
|
||||
else this.changeThread(this.threads.get(item.thread.id), undefined, false);
|
||||
return;
|
||||
case "thread.removed": {
|
||||
this.sequence = item.sequence;
|
||||
const previous = this.threads.get(item.threadId);
|
||||
if (previous) this.changeThread(previous, undefined, false);
|
||||
return;
|
||||
}
|
||||
case "synchronized":
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private changeThread(previous: ThreadShell | undefined, next: ThreadShell | undefined, initial: boolean) {
|
||||
if (next) this.threads.set(next.id, next);
|
||||
else if (previous) this.threads.delete(previous.id);
|
||||
this.options.onThreadChange?.({ host: this, previous, next, initial });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// A minimal client for T3 Code's WebSocket RPC, which speaks Effect RPC's JSON framing.
|
||||
// One socket multiplexes unary calls and streams by request id. Streams are flow controlled: the
|
||||
// server waits for an Ack after every Chunk, so `stream` acks each one after its handler runs.
|
||||
// See docs/t3-protocol.md, section 2.
|
||||
|
||||
type FromServer =
|
||||
| { _tag: "Chunk"; requestId: string; values: unknown[] }
|
||||
| { _tag: "Exit"; requestId: string; exit: Exit }
|
||||
| { _tag: "Defect"; defect: unknown }
|
||||
| { _tag: "Pong" }
|
||||
| { _tag: "ClientProtocolError"; error: unknown };
|
||||
|
||||
type Exit =
|
||||
| { _tag: "Success"; value: unknown }
|
||||
| { _tag: "Failure"; cause: Array<{ _tag: string; error?: unknown; defect?: unknown }> };
|
||||
|
||||
/** A call that ended in a Failure exit, or a socket that closed under it. */
|
||||
export class RpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The typed error from the server, such as `{ _tag: "EnvironmentAuthorizationError", ... }`. */
|
||||
readonly error?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
}
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
onChunk?: (value: unknown) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: RpcError) => void;
|
||||
}
|
||||
|
||||
export interface RpcSocketOptions {
|
||||
pingIntervalMs?: number;
|
||||
/** Close the socket when the server has sent nothing for this long. */
|
||||
silenceTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** One open RPC WebSocket. It never reconnects; the caller makes a new one. */
|
||||
export class RpcSocket {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private lastFrameAt = Date.now();
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
/** Resolves when the socket closes, for any reason. */
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
private constructor(
|
||||
private readonly ws: WebSocket,
|
||||
options: RpcSocketOptions,
|
||||
) {
|
||||
const pingIntervalMs = options.pingIntervalMs ?? 5_000;
|
||||
const silenceTimeoutMs = options.silenceTimeoutMs ?? 15_000;
|
||||
this.closed = new Promise((resolve) => ws.addEventListener("close", () => resolve()));
|
||||
ws.addEventListener("message", (event) => this.onMessage(event.data));
|
||||
ws.addEventListener("close", () => this.failAll("socket closed"));
|
||||
this.timer = setInterval(() => {
|
||||
if (Date.now() - this.lastFrameAt > silenceTimeoutMs) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.send({ _tag: "Ping" });
|
||||
}, pingIntervalMs);
|
||||
void this.closed.then(() => clearInterval(this.timer));
|
||||
}
|
||||
|
||||
/** Opens a socket and resolves once it is connected. */
|
||||
static connect(url: string, options: RpcSocketOptions = {}): Promise<RpcSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const onError = () => reject(new RpcError(`could not connect to ${new URL(url).host}`));
|
||||
ws.addEventListener("error", onError, { once: true });
|
||||
ws.addEventListener(
|
||||
"open",
|
||||
() => {
|
||||
ws.removeEventListener("error", onError);
|
||||
resolve(new RpcSocket(ws, options));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Calls a unary method and resolves with its success value. */
|
||||
call<T = unknown>(tag: string, payload: unknown): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = this.request(tag, payload, { resolve: resolve as (v: unknown) => void, reject });
|
||||
if (id === null) reject(new RpcError("socket is not open"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a stream. `onChunk` runs once per value, in order. The promise settles when the stream
|
||||
* ends or fails. Call `cancel` to interrupt it without closing the socket.
|
||||
*/
|
||||
stream(tag: string, payload: unknown, onChunk: (value: unknown) => void) {
|
||||
let id: string | null = null;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
id = this.request(tag, payload, { onChunk, resolve: () => resolve(), reject });
|
||||
if (id === null) reject(new RpcError("socket is not open"));
|
||||
});
|
||||
return {
|
||||
done,
|
||||
cancel: () => {
|
||||
if (id !== null) this.send({ _tag: "Interrupt", requestId: id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
clearInterval(this.timer);
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
private request(tag: string, payload: unknown, pending: Pending): string | null {
|
||||
if (this.ws.readyState !== WebSocket.OPEN) return null;
|
||||
const id = String(this.nextId++);
|
||||
this.pending.set(id, pending);
|
||||
this.send({ _tag: "Request", id, tag, payload, headers: [] });
|
||||
return id;
|
||||
}
|
||||
|
||||
private send(frame: object) {
|
||||
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private onMessage(data: unknown) {
|
||||
this.lastFrameAt = Date.now();
|
||||
if (typeof data !== "string") return;
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
const frames = (Array.isArray(parsed) ? parsed : [parsed]) as FromServer[];
|
||||
for (const frame of frames) this.onFrame(frame);
|
||||
}
|
||||
|
||||
private onFrame(frame: FromServer) {
|
||||
switch (frame._tag) {
|
||||
case "Chunk": {
|
||||
const requestId = String(frame.requestId);
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
for (const value of frame.values) pending.onChunk?.(value);
|
||||
this.send({ _tag: "Ack", requestId: frame.requestId });
|
||||
return;
|
||||
}
|
||||
case "Exit": {
|
||||
const requestId = String(frame.requestId);
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
if (frame.exit._tag === "Success") pending.resolve(frame.exit.value);
|
||||
else pending.reject(failureToError(frame.exit.cause));
|
||||
return;
|
||||
}
|
||||
case "Defect":
|
||||
this.failAll(`server defect: ${JSON.stringify(frame.defect)}`);
|
||||
this.close();
|
||||
return;
|
||||
case "ClientProtocolError":
|
||||
this.failAll(`protocol error: ${JSON.stringify(frame.error)}`);
|
||||
this.close();
|
||||
return;
|
||||
case "Pong":
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(message: string) {
|
||||
for (const pending of this.pending.values()) pending.reject(new RpcError(message));
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function failureToError(cause: Extract<Exit, { _tag: "Failure" }>["cause"]): RpcError {
|
||||
const first = cause[0];
|
||||
if (first?.error !== undefined) {
|
||||
const error = first.error as { _tag?: string; message?: string };
|
||||
return new RpcError(error.message ?? error._tag ?? "call failed", first.error);
|
||||
}
|
||||
if (first?._tag === "Interrupt") return new RpcError("interrupted");
|
||||
return new RpcError(`call failed: ${JSON.stringify(first?.defect ?? cause)}`);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Narrow types for the parts of T3 Code's orchestration protocol (v2) the gateway reads and writes.
|
||||
// They cover only the fields we use. Unknown fields pass through untouched, and every enum leaves
|
||||
// room for values a newer server may add. Source of truth: docs/t3-protocol.md.
|
||||
|
||||
/** A string union that still accepts values a newer server adds. */
|
||||
type Open<T extends string> = T | (string & {});
|
||||
|
||||
export type RunStatus = Open<
|
||||
| "idle"
|
||||
| "preparing"
|
||||
| "queued"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "completed"
|
||||
| "interrupted"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "rolled_back"
|
||||
>;
|
||||
|
||||
export type RuntimeRequestKind = Open<
|
||||
| "command"
|
||||
| "file-read"
|
||||
| "file-change"
|
||||
| "mcp-elicitation"
|
||||
| "permission"
|
||||
| "user_input"
|
||||
| "dynamic_tool_call"
|
||||
| "auth_refresh"
|
||||
>;
|
||||
|
||||
export type ApprovalDecision = "accept" | "acceptForSession" | "acceptAlways" | "decline" | "cancel";
|
||||
|
||||
export interface ModelSelection {
|
||||
instanceId: string;
|
||||
model: string;
|
||||
options?: unknown;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
workspaceRoot: string;
|
||||
defaultModelSelection: ModelSelection | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadShell {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
deletedAt?: string | null;
|
||||
status: RunStatus;
|
||||
activityRunStatus?: RunStatus | null;
|
||||
activeRunId: string | null;
|
||||
latestRunStopRequested?: boolean;
|
||||
pendingRuntimeRequest: {
|
||||
id: string;
|
||||
kind: RuntimeRequestKind;
|
||||
createdAt: string;
|
||||
blocking?: boolean;
|
||||
} | null;
|
||||
pendingBackgroundTasks?: Array<{ kind: Open<"command" | "monitor" | "subagent" | "background_task">; awaited?: boolean }>;
|
||||
lastError?: string | null;
|
||||
modelSelection: ModelSelection;
|
||||
runtimeMode?: string;
|
||||
lineage: { relationshipToParent: Open<"subagent"> | null };
|
||||
settledOverride?: Open<"settled"> | null;
|
||||
}
|
||||
|
||||
export interface ShellSnapshot {
|
||||
snapshotSequence: number;
|
||||
projects: Project[];
|
||||
threads: ThreadShell[];
|
||||
}
|
||||
|
||||
export type ShellStreamItem =
|
||||
| { kind: "snapshot"; snapshot: ShellSnapshot }
|
||||
| { kind: "project.updated"; sequence: number; project: Project }
|
||||
| { kind: "project.removed"; sequence: number; projectId: string }
|
||||
| { kind: "thread.updated"; sequence: number; location: Open<"active" | "archive">; thread: ThreadShell }
|
||||
| { kind: "thread.removed"; sequence: number; location: Open<"active" | "archive">; threadId: string }
|
||||
| { kind: "synchronized" };
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: Open<"user" | "assistant" | "system">;
|
||||
text: string;
|
||||
streaming?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalOption {
|
||||
decision: ApprovalDecision;
|
||||
label: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface UserInputQuestion {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description: string; value?: string }>;
|
||||
allowCustomAnswer?: boolean;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
/** Any turn item. Narrow with the guards below to read the two types the gateway uses. */
|
||||
export interface TurnItem {
|
||||
type: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestItem extends TurnItem {
|
||||
type: "approval_request";
|
||||
requestId: string;
|
||||
requestKind: RuntimeRequestKind;
|
||||
prompt?: string;
|
||||
reason?: string;
|
||||
options?: ApprovalOption[];
|
||||
}
|
||||
|
||||
export interface UserInputRequestItem extends TurnItem {
|
||||
type: "user_input_request";
|
||||
requestId: string;
|
||||
questions: UserInputQuestion[];
|
||||
}
|
||||
|
||||
export const isApprovalRequest = (item: TurnItem): item is ApprovalRequestItem =>
|
||||
item.type === "approval_request";
|
||||
|
||||
export const isUserInputRequest = (item: TurnItem): item is UserInputRequestItem =>
|
||||
item.type === "user_input_request";
|
||||
|
||||
export interface BoundedThread {
|
||||
snapshotSequence: number;
|
||||
projection: {
|
||||
thread: ThreadShell;
|
||||
messages: ConversationMessage[];
|
||||
turnItems: TurnItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnvironmentDescriptor {
|
||||
environmentId: string;
|
||||
label: string;
|
||||
serverVersion: string;
|
||||
orchestrationProtocolVersion?: number;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
environment?: { capabilities?: Record<string, unknown> };
|
||||
settings?: { defaultModelSelection?: ModelSelection | null };
|
||||
}
|
||||
|
||||
/** Commands sent through `orchestration.dispatchCommand`. */
|
||||
export type Command =
|
||||
| {
|
||||
type: "message.dispatch";
|
||||
commandId: string;
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
attachments: [];
|
||||
createdBy: "user";
|
||||
creationSource: "mobile";
|
||||
dispatchMode: { type: "queue_after_active" };
|
||||
}
|
||||
| {
|
||||
type: "runtime-request.respond";
|
||||
commandId: string;
|
||||
threadId: string;
|
||||
requestId: string;
|
||||
decision?: ApprovalDecision;
|
||||
answers?: Record<string, unknown>;
|
||||
}
|
||||
| { type: "thread.stop"; commandId: string; threadId: string }
|
||||
| { type: "run.interrupt"; commandId: string; threadId: string; runId: string };
|
||||
|
||||
export interface LaunchThreadInput {
|
||||
commandId: string;
|
||||
creationSource: "mobile";
|
||||
projectId: string;
|
||||
title: string;
|
||||
generateTitle: boolean;
|
||||
modelSelection: ModelSelection;
|
||||
runtimeMode: string;
|
||||
interactionMode: "default";
|
||||
workspaceStrategy: { type: "root" };
|
||||
initialMessage: { messageId: string; text: string; attachments: [] };
|
||||
}
|
||||
|
||||
/** The protocol version this gateway speaks. A server on another version is refused. */
|
||||
export const ORCHESTRATION_PROTOCOL_VERSION = 2;
|
||||
Reference in new issue
Block a user