import { WireTransport, WireChunk, WireMessage } from '@theokit/presenter/wire'; import { TheokitAgentError } from '@theokit/sdk/errors'; /** * M41 (ADR-0050 D2) — a HITL approval decision sent to settle a paused gated-tool call. Mirrors the * `POST /api/agents//approve/` wire body (`approve-agent.ts` `parseApprovalBody`). Defined * on the client side (not imported from `server/`) as the client's view of the boundary contract. */ interface ApprovalDecision { /** Whether the paused gated tool may proceed. */ approved: boolean; /** Optional reason surfaced to the model on denial. */ reason?: string; /** Optional small structured note (edited args, a reviewer comment) — capped server-side at 16 KiB. */ payload?: unknown; } /** * M43 (ADR-0052) — per-request context attached uniformly across every transport. * * `headers` is the serializable, HTTP-native half (an auth token → request headers on `HttpTransport`); * `metadata` is the structured, same-process half forwarded to the in-process runner * (`InProcessTransport`) / the Tauri `invoke` (`ChannelTransport`). Threaded through the seam's existing * `ChatRequestOptions` — NOT a new channel. The turn input stays the request `body`. */ interface RequestContext { /** Per-request headers (e.g. auth). HTTP-native; mapped to request headers by `HttpTransport`. */ headers?: Record; /** Structured per-request context (tenant, provider, …) forwarded to in-process / channel runners. */ metadata?: unknown; } /** * M41 (ADR-0050 D1/D2) — the client transport seam. * * It IS `ai`'s `ChatTransport` (the adopted SOTA interface — we do NOT invent a parallel * one) plus ONE optional method, `approve`, for TheoKit's OUT-OF-BAND HITL (the server pauses the run; * the client settles it via a separate call). `ai`'s `ChatTransport` has no `approve` because its HITL * is in-band (a re-sent message part); ours is out-of-band, and the settle route differs per transport * (HTTP `POST /approve/` vs an inline callback), so it belongs on the transport. `approve` is * optional — agents without gated tools never settle an approval. * * `HttpTransport` (web) and `InProcessTransport` (TUI/desktop) implement this; `useAgent` drives it. */ type AgentTransport = WireTransport & { approve?(approvalId: string, decision: ApprovalDecision): Promise; }; /** An inline approval request handed to the transport's resolver (structural — no server import). */ interface InProcessApprovalRequestLike { approvalId: string; toolName: string; opts: unknown; } /** Resolve one gated-tool approval inline (mirrors the SDK's `boolean | HitlDecision` return). */ type InProcessAwaitApproval = (req: InProcessApprovalRequestLike) => Promise; /** The input the injected runner receives (structurally compatible with `StreamAgentTurnInProcessInput`). */ interface InProcessRunInput { message: string; sessionId?: string; signal?: AbortSignal; awaitApproval?: InProcessAwaitApproval; /** M43 — per-request context (from `sendMessages`'s `metadata`) — tenant / provider / auth for the runner. */ context?: unknown; } /** * The in-process turn runner. The consumer binds `streamAgentTurnInProcess(mod, apiKey, …)`: * `new InProcessTransport({ run: (input) => streamAgentTurnInProcess(mod, apiKey, input) })`. * Injecting it keeps this client module decoupled from `server/` and makes the transport testable. */ type InProcessRunner = (input: InProcessRunInput) => AsyncGenerator; interface InProcessTransportOptions { run: InProcessRunner; } /** * M41 (ADR-0050 D4) — `ChatTransport` over the in-process seam (`streamAgentTurnInProcess`), for the * terminal/desktop surfaces that run client + server in ONE process (no HTTP loopback). * * - `sendMessages`: bridge the injected runner's `AsyncGenerator` into a * `ReadableStream` (honoring `abortSignal`, which the runner forwards to the SDK). * - `reconnectToStream`: always `null` — a single process has no dropped server-side stream to resume * (mirrors `ai`'s `DirectChatTransport`). * - `approve`: resolve the pending inline approval by id (the run parks on `awaitApproval`). An unknown * id rejects (fail-fast, Rule 8 — never a silent resolve). * * Error asymmetry vs `HttpTransport` (by design, matching the `ChatTransport` contract): a runner that * throws SYNCHRONOUSLY surfaces the error when the stream is READ (via `controller.error`), not from the * `sendMessages` promise — whereas `HttpTransport` throws from `sendMessages` on a non-2xx response. */ /** * A parked approval was discarded because the turn ended with no decision. * * M92 — typed on purpose. Before, the promise simply **never** settled and the SDK tool call hung; * `resolve(false)` would be worse still, because it is indistinguishable from "the user denied". */ /** * M80 — extends {@link TheokitAgentError}, not plain `Error`. * * `isTransientError` is defined over `TheokitAgentError`, so a class outside that hierarchy is * INVISIBLE to it and the only recourse left to a consumer is matching on message text. `code` is * stable across a rename; `isRetryable` is DECLARED, because a default would be a retry policy * nobody chose. */ declare class ApprovalAbortedError extends TheokitAgentError { readonly approvalId: string; readonly name = "ApprovalAbortedError"; constructor(approvalId: string, reason: string); } declare class InProcessTransport implements AgentTransport { #private; constructor(options: InProcessTransportOptions); /** How many approvals are parked. Exists so the test can prove the eviction. */ get pending(): number; sendMessages(options: Parameters[0]): Promise>; reconnectToStream(): Promise | null>; approve(approvalId: string, decision: ApprovalDecision): Promise; } /** Handlers the transport hands to the injected push source for one turn. */ interface ChannelTurnHandlers { /** One pushed JSONL line (a serialized `UIMessageChunk`). */ onLine: (line: string) => void; /** The turn ended — no more lines. */ onClose: () => void; /** The push source failed. */ onError?: (err: unknown) => void; } /** * The injected push source — a Tauri `Channel`/`invoke` bridge, kept STRUCTURAL so core adds no * `@tauri-apps/*` dependency and the transport is testable with a fake (ADR-0051 D2). The Tauri app * wires it: `new Channel()`, `channel.onmessage = onLine`, `invoke('run_agent', { message, channel })`, * returning a teardown that aborts the sidecar turn. */ interface ChannelPushSource { /** * Start a turn; deliver each JSONL `UIMessageChunk` line to `onLine`, then `onClose`. Return a teardown. * `turn.context` (M43) is the per-request context (from the seam's `metadata`) — the Tauri `invoke` * forwards it to the sidecar. When no context is set it is present as `context: undefined` (the key is * NOT absent) — a sidecar that checks `'context' in turn` should treat `undefined` as "no context". */ start(turn: { message: string; context?: unknown; }, handlers: ChannelTurnHandlers): () => void; /** Optional HITL settle (another Tauri `invoke`). */ settle?(approvalId: string, decision: ApprovalDecision): Promise; } interface ChannelTransportOptions { source: ChannelPushSource; } /** * M42 (ADR-0051) — `ChatTransport` over a Tauri-`Channel`-shaped push source, for the desktop webview. * * - `sendMessages`: start the turn via the injected source and bridge its pushed JSONL frames into a * `ReadableStream` (built in `start` — a Channel is push, so the stream's queue buffers * frames; ADR-0051 D3). A malformed JSONL line is SKIPPED, never fatal (ADR-0051 D4, Rule 8). `abortSignal` * tears down the source and closes the stream. * - `reconnectToStream`: always `null` — the M36 sidecar runs the turn directly (no durable server stream); * this is the honest parity for a single-process push surface (ADR-0051 D5; mirrors `InProcessTransport`). * - `approve`: routes to the injected `settle` (another Tauri `invoke`); absent `settle` → a typed error. * * The push source is INJECTED — core stays Tauri-agnostic and this transport is unit-tested with a fake. */ declare class ChannelTransport implements AgentTransport { #private; constructor(options: ChannelTransportOptions); sendMessages(options: Parameters[0]): Promise>; reconnectToStream(): Promise | null>; approve(approvalId: string, decision: ApprovalDecision): Promise; } type UseAgentStatus = 'idle' | 'streaming' | 'done' | 'error'; /** * The stream ended before the run did — theokit#384. * * ## Why the status is `'error'` and not a new `'interrupted'` member * * `UseAgentStatus` is published, and every surface in and out of this repo switches on it. Adding a * member fixes the lie only for the consumers who then update their switch; for everyone else the * new value falls through to the same "not streaming, not an error" branch that shows a finished * turn — the exact symptom, preserved. Reusing `'error'` fixes it for all of them at once, and it * is already the value the rest of this store treats correctly: `send()` refuses to commit a turn * that did not settle on `'done'`, so a half-answer stops being written into history as complete, * and the reconnect wiring the issue found disabled (`status === 'error'` → `reconnect()`) starts * firing without the consumer changing a line. * * ## Why the discrimination is a typed error rather than a status * * "The provider refused" and "the connection died" want opposite reactions — one is a failure to * report, the other is a turn that can be resumed — and this framework already has a place for that * difference: `TheokitAgentError`'s `code` + `isRetryable`, which `isTransientError` reads. A class * outside that hierarchy is invisible to it and leaves the consumer matching on message text (M80). * * ## Why this is NOT a `stopReason` (theokit#379) * * The two are orthogonal, and collapsing them would be a category error. `stopReason` says why the * RUN stopped and rides the `finish` chunk's metadata — it exists only when a terminal frame * arrived. An interruption is the ABSENCE of that frame: the run may still be going, and this * client cannot know why it stopped, because it never heard. Spelling it as a third `stopReason` * member would put a value on a `done` turn that no producer produced, and would reintroduce the * defect it fixes — a truncated run reported through the field that means "the agent finished". * Transport termination and execution termination are separate axes; this is the transport one. */ declare class AgentStreamInterruptedError extends TheokitAgentError { readonly chunksReceived: number; readonly name = "AgentStreamInterruptedError"; constructor(chunksReceived: number); } /** * One HITL decision the run is parked on, flattened for the surface that renders it * (usetheokit/theokit#392). * * ## Why the store publishes this at all, when the part already carries it * * `approve()` needs an id. Before this field the snapshot had four keys and none of them was that * id, so an application had two options: scan every part of every message for `state === * 'approval-requested'`, or poll `GET /api/agents//approvals` out of band — which is what the * measured J2 client did, at twelve lines and two React primitives, through an endpoint under a * security advisory. Publishing the id is what removes the reason to reach for either. * * ## Why it is DERIVED and not accumulated * * It is computed from the current turn's parts on each emit, never written by a second reducer. A * separate list would need its own settle path and could then disagree with the transcript about * whether a decision is outstanding — and the transcript is what renders. Deriving makes the two * unable to drift: a settled gate leaves `approval-requested` on the same chunk that fills in the * output, so it leaves this array in the same step. * * ## Why plural * * The SDK dispatches a round's calls concurrently (`mapWithConcurrency`), so two gated calls of the * same tool can be outstanding at once — pinned by * `tests/integration/hitl-call-correlation.test.ts`. A singular field would have to pick one and * would be silently wrong exactly when a human has two things to decide. */ interface PendingApproval { /** The id `approve()` settles. */ readonly approvalId: string; /** The call this gate holds — the same id `tool-input-available` announced. */ readonly toolCallId: string; /** The gated tool's name, off the part. `undefined` only if the producer announced none. */ readonly toolName: string | undefined; /** The resolved arguments the human is authorising, off the part. */ readonly input: unknown; /** The question declared on the gate, when the producer sent one. */ readonly question?: string; /** The window before the gate settles itself, in ms, when the producer sent one. */ readonly timeoutMs?: number; } /** The observable state the store exposes (stable reference between emits — `useSyncExternalStore` contract). */ interface AgentClientState { /** The CURRENT turn's assistant messages (per-turn; reset each `send`). Back-compat — unchanged since M41. */ messages: WireMessage[]; /** * M46 — the full conversation: committed turns + the current turn's user message + in-flight assistant. * Accumulated across sends (never reset except by `reset()`), with stable ids committed exactly once. * Render this instead of hand-rolling a transcript from `messages`. */ thread: WireMessage[]; status: UseAgentStatus; error: Error | undefined; /** * The HITL decisions this turn is parked on, newest last. Empty whenever nothing is outstanding. * Each entry carries the id `approve()` takes plus what the prompt needs to name the action. */ pendingApprovals: PendingApproval[]; } /** * M41 (ADR-0050 D6) — the framework-agnostic agent client store. * * Holds `messages`/`status`/`error`, drives an {@link AgentTransport}, and notifies subscribers on * change. It is the SINGLE consolidation point: web (`HttpTransport`) and terminal/desktop * (`InProcessTransport`) run the SAME store. `useAgent` is a thin React binding over it via * `useSyncExternalStore`; a standalone (no-React) client (M44) can subscribe directly. Being * framework-agnostic, it is unit-tested without a DOM. */ /** * M92 — client options. Additive: without them, the behaviour is exactly as before. */ interface AgentClientOptions { /** * Coalescing window in ms. `0` or absent = emit per token delta (the pre-M92 behaviour). * * Opt-in on purpose: this is a published package, and changing the emit frequency by default would * change the observable behaviour of anyone counting emits or depending on first-token latency. */ readonly emitIntervalMs?: number; /** * The conversation this client continues. Absent = a fresh one is drawn. * * The id is not decorative: the HTTP transport sends it as the top-level `id`, which the server * reads as the session id (`client/http-transport.ts:88-92`). It used to be drawn in the field * declaration with no way to supply or read it, so every `new AgentClient(...)` was a new * conversation and a page reload silently abandoned the thread the server still held * (usetheokit/theokit#364). * * Supply it to resume; read `client.chatId` to persist it. Both halves are needed: reading without * supplying lets an application store an id it can never restore, and supplying without reading * leaves it nothing to store. */ readonly chatId?: string; } declare class AgentClient { #private; constructor(transport: AgentTransport, contextResolver?: () => RequestContext | undefined, options?: AgentClientOptions); /** * The conversation this client is on — the id the server keys the session by. * * Readable so an application can persist it and pass it back through * {@link AgentClientOptions.chatId} after a reload (usetheokit/theokit#364). */ get chatId(): string; /** Subscribe to state changes; returns an unsubscribe fn. */ subscribe: (listener: () => void) => (() => void); /** The current immutable snapshot (stable reference until the next emit). */ getSnapshot: () => AgentClientState; /** Send a typed input; opens a fresh stream (replaces prior messages). */ send: (input: TInput) => void; /** Resume an interrupted stream via the transport's `reconnectToStream` (no-op when unavailable). */ reconnect: () => void; /** Abort an in-flight stream (not an error — leaves messages as-is). */ abort: () => void; /** Clear messages + error, back to idle. */ reset: () => void; /** * Settle a paused HITL approval via the transport's HITL path (HTTP POST or inline callback). * * The signature is unchanged by usetheokit/theokit#392, deliberately. What that issue reported was * not that `approve` takes an id — it was that nothing HANDED the caller one, so the id had to be * mined out of band. `pendingApprovals` hands it over, and taking the entry instead of its * `approvalId` would save no line while adding a second accepted shape to a published method that * surfaces outside this repository (`@theokit/tui`, `@theokit/ui`). */ approve: (approvalId: string, decision: ApprovalDecision) => Promise; } /** * M47 (ADR-M47-2) — a typed, client-safe handle for an exposed agent. * * It carries ONLY the HTTP `path` at runtime plus phantom `input`/`toolNames` types (never populated) — so * `useAgent(chat)` / `createAgentClient(chat…)` bind with NO magic string (the path is generated from the * `@Expose` exposure, not hand-typed) and NO duplicated input type (the input type flows through the phantom * generic, inferred from the agent's `.input()`). This mirrors tRPC/Hono's type-only handle: the client * pulls the agent's TYPE via `import type`, never its server runtime. The generated `@theo/agents` module * emits one `export const = agentHandle('/api/agents/')` per agent, typed with the phantoms. */ interface AgentHandle { /** The agent's HTTP endpoint path (e.g. `/api/agents/chat`). The only serializable/runtime-bearing field. */ readonly path: string; /** * M47 — bind this agent in-process (TUI / single-process): wraps the app's runner in an * {@link InProcessTransport}. `useAgent(chat.inProcess(run))` drives the SAME agent without HTTP. */ inProcess(run: InProcessRunner): InProcessTransport; /** * M47 — bind this agent over a push channel (Tauri desktop webview): wraps the source in a * {@link ChannelTransport}. `createAgentClient(chat.channel(source))` drives the SAME agent. */ channel(source: ChannelPushSource): ChannelTransport; /** Phantom — the agent's `input` type, carried for `useAgent(handle).send` inference. Never populated. */ readonly __input?: TInput; /** Phantom — the agent's tool-name union, carried end-to-end. Never populated. */ readonly __toolNames?: TToolNames; } /** * Build an {@link AgentHandle} from an agent's HTTP path. Types are supplied by the caller/codegen. The * `inProcess`/`channel` binders are methods (dropped by `JSON.stringify`, so the `{ path }` core stays * serializable + client-safe) that produce the M41 transports for the non-web surfaces. */ declare function agentHandle(path: string): AgentHandle; /** Narrow an unknown binding to an {@link AgentHandle} (has a string `path`, is not a transport). */ declare function isAgentHandle(value: unknown): value is AgentHandle; export { type ApprovalDecision as A, type ChannelPushSource as C, type InProcessApprovalRequestLike as I, type PendingApproval as P, type RequestContext as R, type UseAgentStatus as U, type AgentHandle as a, type AgentTransport as b, AgentClient as c, type AgentClientOptions as d, type AgentClientState as e, AgentStreamInterruptedError as f, ApprovalAbortedError as g, ChannelTransport as h, type ChannelTransportOptions as i, type ChannelTurnHandlers as j, type InProcessAwaitApproval as k, type InProcessRunInput as l, type InProcessRunner as m, InProcessTransport as n, type InProcessTransportOptions as o, agentHandle as p, isAgentHandle as q };