/** * Streaming chunk publisher for v0.4 task streams. * * Subject: `agents.task.stream...` (NATS core), * built via `subjects.stream(...)` from Slice 2. * * Semantics: * - Best-effort, fire-and-forget. Late observers MAY miss chunks that * fired before they subscribed. Durability is the Messages KV's job * (Slice 3), not stream.ts's. * - No queue group — every subscriber receives every event (fan-out). * - Payload is JSON, one A2A `StreamResponse` event per NATS message. */ import type { NatsConnection } from "@nats-io/transport-node"; import { stream as streamSubject } from "./subjects.ts"; import type { TaskStateWire } from "./taskstate.ts"; import type { Part } from "./types.ts"; /** * Scope kinds permitted on the stream subject. Matches `objects.ts`'s * `ObjectURLParts.scopeKind` so the same scope ref can be reused across * stream + artifact + object surfaces. */ export type ScopeKind = "task" | "session" | "machine"; export interface ScopeRef { scopeKind: ScopeKind; scopeId: string; } /** * Per-task status snapshot — emitted whenever the task itself transitions * state or wants observers to see updated metadata. Mirrors A2A's `Task` * StreamResponse variant. */ export interface TaskEvent { id: string; contextId?: string; status: { state: TaskStateWire }; metadata?: Record; } /** * Incremental message frame — emitted when an adapter has produced a new * Message (e.g. an LLM assistant turn). Mirrors A2A's `Message` * StreamResponse variant. Parts are the flat `Part` shape from `types.ts`. */ export interface MessageEvent { messageId: string; role: "agent" | "user"; parts: Part[]; taskId?: string; contextId?: string; metadata?: Record; } /** * Status transition with a timestamp + optional human-readable note. * Distinct from `TaskEvent` in that the focus is the state change itself, * not the full task envelope. Mirrors A2A's `StatusUpdateEvent`. */ export interface StatusUpdateEvent { taskId: string; state: TaskStateWire; timestamp: string; message?: string; final?: boolean; } /** * Artifact chunk — emitted by adapters that stream large outputs (files, * binary blobs) as a sequence of chunks. The Artifact envelope itself is * written separately via `artifacts.putArtifact` once the last chunk lands. * Mirrors A2A's `ArtifactUpdateEvent`. */ export interface ArtifactUpdateEvent { taskId: string; artifactId: string; lastChunk: boolean; chunkIndex: number; parts: Part[]; } /** * The A2A `StreamResponse` union narrowed to the four event shapes we emit. * The `kind` discriminator is the field that lets subscribers route a single * subject's messages into per-variant handlers. */ export type StreamEvent = | { kind: "task"; task: TaskEvent } | { kind: "message"; message: MessageEvent } | { kind: "statusUpdate"; statusUpdate: StatusUpdateEvent } | { kind: "artifactUpdate"; artifactUpdate: ArtifactUpdateEvent }; /** * Publish a single stream event on the per-task NATS core subject. * * Fire-and-forget — NATS publish errors surface through the connection's * status listener, not as a thrown error from this call. */ export function publishStreamEvent( nc: NatsConnection, scope: ScopeRef, taskId: string, event: StreamEvent, ): void { const subject = streamSubject({ scopeKind: scope.scopeKind, scopeId: scope.scopeId, taskId, }); const payload = new TextEncoder().encode(JSON.stringify(event)); nc.publish(subject, payload); } /** * Typed constructors. Thin sugar that ensures the `kind` discriminator is * always set correctly — callers can't construct a `task` event labeled as * `message` by accident. */ export const streamTask = (task: TaskEvent): StreamEvent => ({ kind: "task", task }); export const streamMessage = (message: MessageEvent): StreamEvent => ({ kind: "message", message }); export const streamStatusUpdate = (statusUpdate: StatusUpdateEvent): StreamEvent => ({ kind: "statusUpdate", statusUpdate, }); export const streamArtifactUpdate = (artifactUpdate: ArtifactUpdateEvent): StreamEvent => ({ kind: "artifactUpdate", artifactUpdate, });