/** * Responder-side durable reply routing — the `reply_to` half of the * request/reply loop, owned once in the SDK so all five adapters stay in sync. * * The contract (see sesh-channels #67): a requester may set the `reply_to` * header on its inbound prompt to its OWN prompt subject * (`agents.prompt...`). When the responder finishes, * it must deliver the final reply to that subject AS A NEW INBOUND TURN — i.e. * a valid v2 envelope the requester's own prompt handler accepts — *in addition * to* the ephemeral inbox reply when that inbox is still alive. This is what * lets a long-running response land even after the requester's `nc.request` * timed out and its `_INBOX.*` died. * * THE BUG THIS FIXES (claude half, root cause): claude-nats-channel published * `{ prompt: text }` to the reply_to subject. The requester's inbound prompt * handler validates bytes with `parseV2Envelope`, which REQUIRES * `{messageId, role, parts}` — so `{prompt:text}` is 400-rejected and the reply * is silently dropped on the floor. The capture + publish both fired; the * payload SHAPE was wrong. The other four adapters had no reply_to delivery at * all. Both are fixed by routing through `deliverDurableReply` here, which * emits a genuine v2 envelope (role "agent"). */ import { buildV2Envelope } from "./envelope.ts"; /** * Canonical inbound header carrying the requester's durable reply subject. * Lowercase `reply_to` — MUST match the value the requester's `request` tool * stamps on the outbound message (and the Go shim, if it ever forwards one). */ export const HEADER_REPLY_TO = "reply_to" as const; /** * Minimal structural view of the readable side of a NATS headers map. Matches * `MsgHdrs.get` (returns `""` for absent keys) without importing the type. */ export interface ReadableHeaders { get(key: string): string; } /** * Minimal structural view of the `nc.publish` surface this module needs. * `@nats-io/transport-node`'s `NatsConnection` already satisfies it. */ export interface PublishCapable { publish(subject: string, payload: Uint8Array | string): void; } /** * Read the requester's durable reply subject from inbound message headers. * * Returns the trimmed `reply_to` value, or `undefined` when the header is * absent/empty or no headers object was supplied. Centralizes the header name * + casing so all five adapters capture it identically. */ export function captureReplyTo(headers: ReadableHeaders | undefined | null): string | undefined { if (!headers || typeof headers.get !== "function") return undefined; const v = (headers.get(HEADER_REPLY_TO) ?? "").trim(); return v.length > 0 ? v : undefined; } /** Options for {@link deliverDurableReply}. */ export interface DeliverDurableReplyOpts { /** Outbound payload cap. When set and the envelope exceeds it, throws. */ maxPayloadBytes?: number; /** * Optional contextId stamped on the reply envelope so the requester can * correlate the inbound turn with the original request/task. Omit to leave * it off the envelope. */ contextId?: string; /** Override the minted messageId (defaults to a fresh UUID). */ messageId?: string; } /** * Deliver a responder's final reply to the requester's `reply_to` subject as a * NEW INBOUND TURN — a valid v2 envelope with `role: "agent"` carrying the * reply text. This is the shape the requester's own prompt handler accepts * (unlike the legacy `{prompt:text}` body, which `parseV2Envelope` rejects). * * No-op when `replyToSubject` is falsy or `text` is empty — callers can pass * the captured subject straight through without guarding. Returns `true` when * a reply was published, `false` when it was a no-op. * * Throws when the enveloped payload exceeds `maxPayloadBytes` (the publish is * fire-and-forget, so an oversized payload would be a silent broker drop — we * surface it loudly to the caller instead). */ export function deliverDurableReply( nc: PublishCapable, replyToSubject: string | undefined | null, text: string, opts: DeliverDurableReplyOpts = {}, ): boolean { if (!replyToSubject || text.length === 0) return false; const envelope = buildV2Envelope({ messageId: opts.messageId ?? crypto.randomUUID(), role: "agent", parts: [{ text }], ...(opts.contextId !== undefined ? { contextId: opts.contextId } : {}), }); if (opts.maxPayloadBytes !== undefined && envelope.byteLength > opts.maxPayloadBytes) { throw new Error( `durable reply envelope exceeds broker max_payload (${opts.maxPayloadBytes} bytes)`, ); } nc.publish(replyToSubject, envelope); return true; }