From e16a1911cba1b9b7b9a7bca9c7705b7ca5c8adb8 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Sat, 11 Jul 2026 18:57:12 +0800 Subject: [PATCH] feat: add remote run SDK layer --- README.md | 6 +- docs/architecture/live-events.md | 19 +- docs/guides/programmatic/README.md | 13 +- .../programmatic/conversation-engine.md | 7 +- .../guides/programmatic/integration-layers.md | 32 ++- docs/guides/programmatic/remote-runs.md | 163 +++++++++++++++ .../01-hosted-service/README.md | 2 +- .../01-hosted-service/agent-service.ts | 8 +- .../05-hosted-agent/02-http-sse-api/README.md | 3 +- .../02-http-sse-api/contracts.ts | 40 +--- .../02-http-sse-api/http-api.ts | 3 +- .../03-browser-client/README.md | 4 +- .../03-browser-client/browser-client.ts | 4 +- .../05-hosted-agent/03-browser-client/run.ts | 78 +++++-- examples/sdk/05-hosted-agent/README.md | 21 +- examples/sdk/README.md | 5 +- package.json | 8 + .../sdk/public-hosting-entrypoints.test.ts | 31 +++ .../conversation-run-stream-service.test.ts | 74 ------- .../core/conversation-run-consumer.test.ts | 114 +++++++++++ .../conversation-run-protocol-codec.test.ts | 107 ++++++++++ src/advanced.ts | 8 +- ...trol-plane-session-subscription-service.ts | 13 +- src/cli-v2/state/README.md | 4 +- .../conversation-run-stream/README.md | 23 --- .../conversation-run-stream-service.ts | 134 ------------ .../services/conversation-run-stream/index.ts | 19 +- src/core/chat/remote/README.md | 50 +++++ src/core/chat/remote/consumer-service.ts | 193 ++++++++++++++++++ src/core/chat/remote/index.ts | 24 +++ src/core/chat/remote/protocol-codec.ts | 74 +++++++ src/core/chat/remote/types.ts | 71 +++++++ src/core/chat/runs/README.md | 2 + src/hosted.ts | 17 ++ src/index.ts | 2 + src/remote.ts | 3 + src/web-v2/README.md | 5 +- .../sessions/useControlPlaneSessionEvents.ts | 17 +- 38 files changed, 1059 insertions(+), 342 deletions(-) create mode 100644 docs/guides/programmatic/remote-runs.md create mode 100644 src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts delete mode 100644 src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts create mode 100644 src/__tests__/unit/core/conversation-run-consumer.test.ts create mode 100644 src/__tests__/unit/core/conversation-run-protocol-codec.test.ts delete mode 100644 src/client-shared/services/conversation-run-stream/README.md delete mode 100644 src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts create mode 100644 src/core/chat/remote/README.md create mode 100644 src/core/chat/remote/consumer-service.ts create mode 100644 src/core/chat/remote/index.ts create mode 100644 src/core/chat/remote/protocol-codec.ts create mode 100644 src/core/chat/remote/types.ts create mode 100644 src/hosted.ts create mode 100644 src/remote.ts diff --git a/README.md b/README.md index 9d35e60c..9192c985 100644 --- a/README.md +++ b/README.md @@ -506,10 +506,11 @@ More: [Heartbeat guide](docs/guides/heartbeat.md) ### Programmatic Runtime -Heddle is not only a CLI. The npm package exposes three main programmatic layers: +Heddle is not only a CLI. The npm package exposes explicit programmatic layers: - `createConversationEngine`: an alpha API for persisted multi-turn sessions with session storage, compaction, approvals, traces, semantic activity, and custom frontends or local hosts -- `ConversationRunService`: process-local run identity, ordered activity, bounded replay, cancellation, and approval resolution for reconnectable hosted conversations +- `@roackb2/heddle/hosted`: process-local run identity, ordered activity, bounded replay, cancellation, and approval resolution for reconnectable hosted conversations +- `@roackb2/heddle/remote`: runtime-validated public run envelopes plus shared cursor, duplicate, gap, terminal, and reconnect correctness for remote clients - `AgentLoopRuntimeService.run(...)`: a lower-level single-run execution loop for hosts that do not need persisted chat or session behavior Advanced hosts can also reuse lower-level class APIs such as `ToolRegistry`, `ToolExecutionService`, `TraceRecorder`, `TraceConsoleFormatter`, and `ReviewDiffParser` when they intentionally assemble custom runtime or review surfaces. @@ -518,6 +519,7 @@ Other exported primitives include `HeartbeatRunnerAgent.run`, `HeartbeatSchedule More: [Programmatic hosts](docs/guides/programmatic/README.md), [choosing an integration layer](docs/guides/programmatic/integration-layers.md), +[remote conversation runs](docs/guides/programmatic/remote-runs.md), and the runnable [hosted service → HTTP/SSE → browser client example](examples/sdk/05-hosted-agent/README.md). diff --git a/docs/architecture/live-events.md b/docs/architecture/live-events.md index 6a2f5789..533231ac 100644 --- a/docs/architecture/live-events.md +++ b/docs/architecture/live-events.md @@ -24,7 +24,9 @@ The ownership split is: - `src/core/chat/runs` owns run identity, ordering, replay, terminal state, cancellation, and pending approval coordination; - the control plane owns transport projection and lifecycle discovery; -- `src/client-shared` owns frontend-neutral cursor and reconnect correctness; +- `src/core/chat/remote` owns frontend-neutral cursor and reconnect correctness; +- `src/client-shared` exposes that browser-safe policy to Heddle interfaces + without giving presentation code direct access to core internals; - CLI and web own presentation state only. ## Activity And Run Vocabularies @@ -75,8 +77,9 @@ flowchart TD Run --> Adapter["Control-plane run-stream adapter"] Adapter --> RunRPC["sessionRunEvents
runId + afterSequence"] Adapter --> Lifecycle["sessionEvents
run started or settled"] - RunRPC --> Shared["client-shared run cursor service"] + RunRPC --> Remote["ConversationRunConsumerService
cursor and reconnect policy"] Lifecycle --> Shared + Remote --> Shared["client-shared public barrel"] Shared --> CLI["cli-v2 presentation"] Shared --> Web["web-v2 presentation"] Engine --> Trace["TraceEvent
durable evidence"] @@ -92,9 +95,10 @@ The main implementation path is: - `chat-session-events.ts` fans lifecycle, queue, and approval signals through a workspace/session-scoped `EventEmitter`; - `controlPlane.sessionRunEvents` exposes the replayable run stream; -- `ClientSharedConversationRunStreamService` enforces duplicate suppression, +- `ConversationRunConsumerService` enforces duplicate suppression, sequence-gap detection, terminal detection, and reconnect cursors; -- cli-v2 and web-v2 bind that shared behavior to their local state models. +- `src/client-shared` reexports that public remote-run service so cli-v2 and + web-v2 can bind it to their local state models without importing core. Conversation activities are published only into `ConversationRunService`. They are not also copied onto the session `EventEmitter`; one activity must not @@ -205,7 +209,8 @@ The server keeps a bounded replay buffer for retained runs. A reconnecting client sends the last accepted `sequence` as `afterSequence`; the server replays only newer items and then resumes live delivery. -`ClientSharedConversationRunStreamService` is the mandatory CLI/web policy: +`ConversationRunConsumerService` from `@roackb2/heddle/remote` is the +mandatory CLI/web policy: - ignore duplicate sequence numbers; - reject a gap instead of silently losing activity; @@ -249,8 +254,8 @@ When adding shared behavior: 1. Add structured activity fields at the owning core origin. 2. Publish through the existing run context; do not add a second session bus. 3. Keep server projection limited to real transport/security work. -4. Put cursor and reconnect policy in `client-shared` when both interfaces need - it. +4. Put transport-neutral cursor and reconnect policy in `src/core/chat/remote`; + expose it to Heddle presentation clients through `src/client-shared`. 5. Keep CLI/web differences limited to input and presentation. 6. Test run identity, ordered replay, terminal delivery, and reconnect behavior before relying on interface-level snapshots. diff --git a/docs/guides/programmatic/README.md b/docs/guides/programmatic/README.md index 4aa9accf..33fd7371 100644 --- a/docs/guides/programmatic/README.md +++ b/docs/guides/programmatic/README.md @@ -8,14 +8,19 @@ tools, host extensions, and approval callbacks. These guides follow a **progressive-disclosure ladder**. Start at rung 1 and go deeper only when you need to. This mirrors how the public API is organized. -There are two import entry points: +The public entry points span two independent axes: customization depth and +hosting assumptions. - `@roackb2/heddle` — the **curated** default surface (rungs 1–5): everything a product host needs to build an agentic experience. -- `@roackb2/heddle/advanced` — the **full** surface: the curated exports plus +- `@roackb2/heddle/hosted` — process-local run identity, replay, cancellation, + and approvals for a long-lived host process. +- `@roackb2/heddle/remote` — browser-safe runtime contracts plus transport- + neutral cursor, duplicate, gap, terminal, and reconnect correctness. +- `@roackb2/heddle/advanced` — the **deep core customization** surface: the curated exports plus lower-level building blocks (LLM adapters, individual tools, trace, memory, models, awareness) and specialized runtimes (agent loop, heartbeat, - integrations). Reach for this only when the curated surface is not enough. + integrations). It does not implicitly opt into remote hosting or a transport. Before choosing an API, read [Choose a Programmatic Integration Layer](integration-layers.md). It maps common @@ -38,6 +43,8 @@ split between Heddle and the host explicit for developers and coding agents. - [Conversation engine](conversation-engine.md): engine setup, state roots, and persisted sessions. - [Approvals](approvals.md): own policy decisions in the host. + - [Remote conversation runs](remote-runs.md): consume reconnectable runs + through any transport with runtime-validated public payloads. - [Hosted agent stack](../../../examples/sdk/05-hosted-agent/README.md): compose a transport-neutral run service, Express/SSE API, and browser client while keeping each layer replaceable. diff --git a/docs/guides/programmatic/conversation-engine.md b/docs/guides/programmatic/conversation-engine.md index 479fdf1c..f867de48 100644 --- a/docs/guides/programmatic/conversation-engine.md +++ b/docs/guides/programmatic/conversation-engine.md @@ -84,7 +84,8 @@ process-local run identity, cancellation, ordered activity delivery, approvals, and bounded replay for reconnecting subscribers. ```ts -import { ConversationRunService, createConversationEngine } from '@roackb2/heddle' +import { createConversationEngine } from '@roackb2/heddle' +import { ConversationRunService } from '@roackb2/heddle/hosted' const runs = new ConversationRunService({ replay: { maxEventsPerRun: 512, retentionMs: 300_000 }, @@ -121,6 +122,10 @@ The replay buffer is intentionally process-local and bounded. Durable final conversation state remains in the engine's session repository; transports and cross-process delivery remain host responsibilities. +When a remote client owns a reconnect cursor, pair this service with the +[remote run consumer and protocol codec](remote-runs.md) rather than rebuilding +duplicate/gap/terminal/retry behavior in the client. + For a complete runnable host, follow the [hosted agent stack example](../../../examples/sdk/05-hosted-agent/README.md). It uses this exact service for account-scoped start/subscribe/cancel, then adds an diff --git a/docs/guides/programmatic/integration-layers.md b/docs/guides/programmatic/integration-layers.md index 451feac9..13770efd 100644 --- a/docs/guides/programmatic/integration-layers.md +++ b/docs/guides/programmatic/integration-layers.md @@ -15,9 +15,9 @@ in control. HOST-OWNED PRODUCT UI state and rendering | - transport client and public wire contract optional + @roackb2/heddle/remote consumer + public contract | - server adapter, auth, tenancy, rate limits optional + transport client/server adapter optional | application service and composition root (product session IDs, tools, config, repositories, policy) @@ -44,10 +44,11 @@ not transport infrastructure. | Agent execution | Model/tool loop, tool registry/execution, host-extension composition, traces, and artifacts | Product tools, system context, model choice, credentials, and capability policy | | Approvals | Approval request/resolution lifecycle and run integration | Whether an action is allowed, authenticated approver, and approval UI/policy | | Active runs | Run ID, ordered sequence, process-local replay, subscription, cancellation, and terminal result | Lifetime of the run service, address scope, process routing, draining, and multi-process delivery | +| Remote consumption | Cursor advancement, duplicate suppression, sequence-gap failure, terminal detection, bounded reconnect timing, and runtime envelope validation | Public activity/result schemas, actual transport timer/handle, and error UX | | Persistence | File-backed defaults plus injectable session/artifact repository ports | Production repository implementations, retention, encryption, backup, and tenancy | | Identity and authorization | No identity-provider assumption | Authentication, tenant/user mapping, authorization for start/subscribe/cancel | | Transport/API | No HTTP, tRPC, SSE, or WebSocket assumption | Framework, routes/procedures, wire schemas, errors, limits, CORS, and rate limiting | -| Client experience | Semantic activities and terminal run events | Messages, tool rendering, UI state, retries, notifications, and product-specific result presentation | +| Client experience | Semantic activities, terminal run events, and remote cursor/retry calculations | Messages, tool rendering, UI state, transport timers, retry UX, notifications, and product-specific result presentation | Do not rebuild Heddle-owned conversation or run behavior in the host. In particular, avoid replaying product chat history into every prompt, generating a @@ -61,9 +62,10 @@ disconnect as implicit cancellation. | Nothing beyond a TypeScript process | `runQuickstartConversationCli` | [`01-interactive-chat.ts`](../../../examples/sdk/01-interactive-chat.ts) | | A local loop that needs product tools or MCP | Quickstart plus tools/host extensions | [`02-add-a-tool.ts`](../../../examples/sdk/02-add-a-tool.ts), [`03-add-an-mcp-server.ts`](../../../examples/sdk/03-add-an-mcp-server.ts) | | Its own output sink or local UI | `createConversationEngine` + `createConversationTextHost` or host callbacks | [`04-custom-output.ts`](../../../examples/sdk/04-custom-output.ts) | -| A server/worker that owns transport | `createConversationEngine` + `ConversationRunService` | [`05-hosted-agent/01-hosted-service`](../../../examples/sdk/05-hosted-agent/01-hosted-service) | +| A server/worker that owns transport | `@roackb2/heddle` + `@roackb2/heddle/hosted` | [`05-hosted-agent/01-hosted-service`](../../../examples/sdk/05-hosted-agent/01-hosted-service) | | Express with REST + SSE | Same core plus a host adapter | [`05-hosted-agent/02-http-sse-api`](../../../examples/sdk/05-hosted-agent/02-http-sse-api) | -| A browser using that exact REST/SSE contract | Host API plus the protocol client | [`05-hosted-agent/03-browser-client`](../../../examples/sdk/05-hosted-agent/03-browser-client) | +| A remote client over any transport | `@roackb2/heddle/remote` plus a host transport | [Remote conversation runs](remote-runs.md) | +| A browser using the example REST/SSE contract | Remote layer plus the example protocol client | [`05-hosted-agent/03-browser-client`](../../../examples/sdk/05-hosted-agent/03-browser-client) | For tRPC, Fastify, Hono, Nest, WebSocket, Electron IPC, queues, or another transport, stop at the hosted-service layer and implement the adapter in the @@ -93,6 +95,20 @@ lifetimes differ, clients can reconnect, or a turn needs a separately addressable cancel/approval lifecycle. It is a process-local coordinator above the engine, not a transport or durable message broker. +Import it from `@roackb2/heddle/hosted` so the hosted-process assumption is +visible. The root export remains available for compatibility. + +### Remote run protocol + +Use `ConversationRunProtocolCodec` and `ConversationRunConsumerService` from +`@roackb2/heddle/remote` when events cross an untrusted transport boundary or a +client can reconnect. The host supplies public activity/result schemas; Heddle +owns envelope validation, JSON safety, cursor advancement, duplicate/gap +handling, terminal detection, and retry calculation. + +This layer does not own HTTP, SSE, tRPC, timers, auth, or UI state. See +[Remote conversation runs](remote-runs.md). + ### Transport adapter Add a host-owned adapter when a remote client needs start, subscribe, cancel, or @@ -100,6 +116,10 @@ approval operations. Validate all untrusted wire data and project internal run results into an explicitly public schema. Authentication and authorization must happen before resolving the Heddle run address. +Web-standard HTTP/SSE helpers are a planned higher assumption layer; Express +and tRPC remain framework recipes until their residual code proves a meaningful +adapter boundary. + ### Client protocol and UI Add a transport client only when it matches the host's API. Keep protocol @@ -118,6 +138,8 @@ normal client architecture. | Approval behavior | `ConversationEngineHost.approvals` and the product's policy/UI | | Session/artifact storage | `ChatSessionRepository` and `ArtifactRepository` | | Public API fields | Host-owned validation schemas and terminal-result projection | +| Remote cursor/retry correctness | `ConversationRunConsumerService` | +| Runtime run-envelope validation | `ConversationRunProtocolCodec` with host activity/result schemas | | REST, tRPC, SSE, WebSocket, or IPC | Host transport adapter above the application service | | React or other UI state | Product client/application layer above the protocol client | | Multi-process live delivery | Host-selected shared routing/broker infrastructure | diff --git a/docs/guides/programmatic/remote-runs.md b/docs/guides/programmatic/remote-runs.md new file mode 100644 index 00000000..ea4ae77d --- /dev/null +++ b/docs/guides/programmatic/remote-runs.md @@ -0,0 +1,163 @@ +# Remote Conversation Runs + +Use the remote-run layer when a client observes a Heddle conversation through +a transport and may disconnect or reconnect while the run continues. + +The public layers are intentionally separate: + +```ts +import { createConversationEngine } from '@roackb2/heddle' +import { ConversationRunService } from '@roackb2/heddle/hosted' +import { + ConversationRunConsumerService, + ConversationRunProtocolCodec, +} from '@roackb2/heddle/remote' +``` + +- `@roackb2/heddle` owns persisted conversation semantics. +- `@roackb2/heddle/hosted` owns process-local active-run coordination. +- `@roackb2/heddle/remote` owns client cursor correctness and runtime wire + validation without choosing HTTP, SSE, tRPC, WebSocket, React, or auth. + +## Define the public wire payload + +Heddle owns the run envelope and terminal vocabulary. The host must explicitly +choose which activity and result fields are safe for remote clients. + +```ts +import { z } from 'zod' +import { ConversationRunProtocolCodec } from '@roackb2/heddle/remote' + +const PublicActivitySchema = z.object({ + type: z.string().min(1), +}).passthrough() + +const PublicResultSchema = z.object({ + outcome: z.string().min(1), + summary: z.string(), +}) + +const protocol = new ConversationRunProtocolCodec({ + activity: PublicActivitySchema, + result: PublicResultSchema, +}) +``` + +`protocol.parseEvent(untrustedValue)` validates: + +- non-empty `runId`; +- positive safe-integer `sequence`; +- ISO timestamp; +- one of `activity`, `result`, `cancelled`, or `error`; +- the host-supplied activity/result schema; +- JSON-safe values after schema parsing. + +`protocol.stringifyEvent(value)` applies the same validation before JSON +serialization. This prevents passthrough or `unknown` payloads from carrying +values such as bigint, functions, symbols, non-finite numbers, or `undefined` +into a transport. + +Use strict public schemas when internal activity can contain tool inputs, +results, filesystem paths, or other sensitive data. The codec validates the +schema you choose; it does not decide product authorization or sanitize secrets +on your behalf. + +## Consume one run correctly + +The consumer is a transport-neutral state machine. A reference may include any +host fields as long as it has a stable `runId`: + +```ts +import { ConversationRunConsumerService } from '@roackb2/heddle/remote' + +type ProductRunReference = { + accountId: string + sessionId: string + runId: string +} + +const consumer = new ConversationRunConsumerService({ + retry: { + maxAttempts: 6, + baseDelayMs: 500, + maxDelayMs: 4_000, + }, +}) + +consumer.select({ accountId, sessionId, runId }) +``` + +Before opening a subscription, ask the consumer for the canonical cursor: + +```ts +const input = consumer.subscriptionInput() + +if (input) { + await transport.subscribe({ + ...input, + onEvent(rawEvent) { + const event = protocol.parseEvent(rawEvent) + const acceptance = consumer.accept(event) + if (acceptance.accepted) { + renderProductEvent(event) + } + }, + }) +} +``` + +`accept(...)`: + +- ignores an event for another run; +- ignores an already accepted replay sequence; +- throws on a sequence gap; +- advances the cursor only after accepting an event; +- recognizes result/cancel/error as terminal; +- rejects a later event after terminal. + +When a transport disconnects before terminal, request the next bounded retry: + +```ts +const retry = consumer.nextRetry() +if (retry) { + await delay(retry.delayMs) + // reconnect with retry.input / consumer.subscriptionInput() +} +``` + +The consumer computes retry correctness and timing. The host still owns the +actual timer, subscription handle, error presentation, online/offline policy, +and UI state. Accepted progress resets the retry attempt budget. + +## Server-side run ownership + +Use one host-long-lived `ConversationRunService` from the hosted entrypoint: + +```ts +import { ConversationRunService } from '@roackb2/heddle/hosted' + +const runs = new ConversationRunService({ + addressKey: ({ accountId, sessionId }) => JSON.stringify([accountId, sessionId]), +}) +``` + +Authentication and authorization happen before the host constructs or resolves +the address. Do not treat possession of `runId` as authorization. + +The run service remains process-local. Its replay buffer is bounded and does +not promise restart recovery or cross-instance delivery. Add shared routing or +durable delivery only when the deployment explicitly requires that additional +assumption layer. + +## What remains host-owned + +- start/cancel routes or procedures; +- HTTP/SSE/tRPC/WebSocket adapters; +- authentication, tenancy, CORS, rate limits, and audit; +- engine construction, credentials, tools, and approval policy; +- public activity/result projection; +- product finalization and UI state. + +See the [hosted-agent example](../../../examples/sdk/05-hosted-agent/README.md) +for a runnable service → Express/SSE → browser flow. Its browser runner and +Heddle's own CLI/web clients reuse this same consumer implementation. diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/README.md b/examples/sdk/05-hosted-agent/01-hosted-service/README.md index 92af50fb..1f805209 100644 --- a/examples/sdk/05-hosted-agent/01-hosted-service/README.md +++ b/examples/sdk/05-hosted-agent/01-hosted-service/README.md @@ -17,7 +17,7 @@ for the runnable flow and production checklist. ## Read in this order 1. [`agent-service.ts`](agent-service.ts) — application-owned scope and lifecycle - composed over Heddle's `ConversationRunService`. + composed over `ConversationRunService` from `@roackb2/heddle/hosted`. 2. [`example-agent.ts`](example-agent.ts) — replaceable local composition and demo policy. 3. [`run.ts`](run.ts) — disconnect, cursor replay, and explicit cancellation in diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts b/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts index 12e282a5..3ee56142 100644 --- a/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts +++ b/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts @@ -6,14 +6,16 @@ * transport, or UI dependency; those belong in later optional stages. */ import { - ConversationRunService, type ConversationEngine, type ConversationEngineHost, + type ConversationTurnResultSummary, +} from '../../../../src/index.js'; +import { + ConversationRunService, type ConversationRunHandle, type ConversationRunReplayOptions, type ConversationRunStreamItem, - type ConversationTurnResultSummary, -} from '../../../../src/index.js'; +} from '../../../../src/hosted.js'; const DEFAULT_RUN_RETENTION_MS = 5 * 60_000; diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/README.md b/examples/sdk/05-hosted-agent/02-http-sse-api/README.md index df55c35a..e4bec1a6 100644 --- a/examples/sdk/05-hosted-agent/02-http-sse-api/README.md +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/README.md @@ -14,7 +14,8 @@ for curl commands, lifecycle details, and production replacements. ## Read in this order -1. [`contracts.ts`](contracts.ts) — Zod schemas for untrusted public wire data. +1. [`contracts.ts`](contracts.ts) — host-owned public payload schemas composed + with `ConversationRunProtocolCodec` from `@roackb2/heddle/remote`. 2. [`http-api.ts`](http-api.ts) — authenticated start, cursor subscribe, and explicit cancel handlers. 3. [`server.ts`](server.ts) — runnable local composition with deliberately diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts index 4f907935..3310fa9f 100644 --- a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts @@ -5,6 +5,7 @@ * Extend these schemas only with product data safe for remote clients. */ import { z } from 'zod'; +import { ConversationRunProtocolCodec } from '../../../../src/remote.js'; export const StartHostedAgentRunInputSchema = z.object({ sessionId: z.string().trim().min(1).max(128), @@ -18,38 +19,17 @@ export const StartHostedAgentRunResultSchema = z.object({ sessionId: z.string().min(1), }); -const RunEventEnvelopeSchema = z.object({ - runId: z.string().min(1), - sequence: z.number().int().positive().safe(), - timestamp: z.iso.datetime(), +export const HostedAgentRunProtocol = new ConversationRunProtocolCodec({ + activity: z.object({ + type: z.string().min(1), + }).passthrough(), + result: z.object({ + outcome: z.string().min(1), + summary: z.string(), + }), }); -export const HostedAgentRunEventSchema = z.discriminatedUnion('kind', [ - RunEventEnvelopeSchema.extend({ - kind: z.literal('activity'), - activity: z.object({ - type: z.string().min(1), - }).passthrough(), - }), - RunEventEnvelopeSchema.extend({ - kind: z.literal('result'), - result: z.object({ - outcome: z.string().min(1), - summary: z.string(), - }), - }), - RunEventEnvelopeSchema.extend({ - kind: z.literal('cancelled'), - reason: z.string(), - }), - RunEventEnvelopeSchema.extend({ - kind: z.literal('error'), - error: z.object({ - code: z.literal('run_failed'), - message: z.string(), - }), - }), -]); +export const HostedAgentRunEventSchema = HostedAgentRunProtocol.eventSchema; export const CancelHostedAgentRunResultSchema = z.object({ cancelled: z.boolean(), diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts index 658b37d9..565fdcfb 100644 --- a/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts @@ -22,6 +22,7 @@ import { import { CancelHostedAgentRunResultSchema, HostedAgentApiErrorSchema, + HostedAgentRunProtocol, HostedAgentRunEventSchema, StartHostedAgentRunInputSchema, StartHostedAgentRunResultSchema, @@ -153,7 +154,7 @@ async function writeSseEvent( event: HostedAgentRunEvent, signal: AbortSignal, ): Promise { - const frame = `event: ${event.kind}\nid: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`; + const frame = `event: ${event.kind}\nid: ${event.sequence}\ndata: ${HostedAgentRunProtocol.stringifyEvent(event)}\n\n`; if (response.write(frame)) { return; } diff --git a/examples/sdk/05-hosted-agent/03-browser-client/README.md b/examples/sdk/05-hosted-agent/03-browser-client/README.md index d6d623fa..45563822 100644 --- a/examples/sdk/05-hosted-agent/03-browser-client/README.md +++ b/examples/sdk/05-hosted-agent/03-browser-client/README.md @@ -15,8 +15,8 @@ for the runnable reconnect/cancel flow. 1. [`browser-client.ts`](browser-client.ts) — URL/auth/error/SSE parsing, schema validation, cursor validation, and abort propagation. -2. [`run.ts`](run.ts) — application-owned reconnect, terminal-event, and cancel - policy without a UI framework. +2. [`run.ts`](run.ts) — Heddle's public remote consumer composed with + application-owned transport timers, terminal rendering, and cancel policy. Keep messages, tool rendering, optimistic state, retry UX, notifications, and product-specific results above this protocol client. A React application should diff --git a/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts b/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts index 26af4cb3..11cbb9c4 100644 --- a/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts +++ b/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts @@ -12,7 +12,7 @@ import type { ZodType } from 'zod'; import { CancelHostedAgentRunResultSchema, HostedAgentApiErrorSchema, - HostedAgentRunEventSchema, + HostedAgentRunProtocol, StartHostedAgentRunResultSchema, type CancelHostedAgentRunResult, type HostedAgentRunEvent, @@ -161,7 +161,7 @@ function parseEvent(message: EventSourceMessage): HostedAgentRunEvent { throw new HostedAgentClientError('Hosted agent event contained invalid JSON.'); } - const event = HostedAgentRunEventSchema.parse(body); + const event = HostedAgentRunProtocol.parseEvent(body); if (message.id !== String(event.sequence)) { throw new HostedAgentClientError('Hosted agent event ID did not match its canonical sequence.'); } diff --git a/examples/sdk/05-hosted-agent/03-browser-client/run.ts b/examples/sdk/05-hosted-agent/03-browser-client/run.ts index 64160dfc..2e5dd0e0 100644 --- a/examples/sdk/05-hosted-agent/03-browser-client/run.ts +++ b/examples/sdk/05-hosted-agent/03-browser-client/run.ts @@ -6,12 +6,16 @@ * Start the stage-2 server first, then run this script with the same token. */ import { setTimeout as delay } from 'node:timers/promises'; +import { ConversationRunConsumerService } from '../../../../src/remote.js'; import { HostedAgentClient, HostedAgentClientError, } from './browser-client.js'; import type { HostedAgentRunEvent } from '../02-http-sse-api/contracts.js'; +type HostedAgentRunReference = { runId: string }; +type HostedAgentRunConsumer = ConversationRunConsumerService; + const bearerToken = process.env.HEDDLE_EXAMPLE_BEARER_TOKEN; if (!bearerToken) { throw new Error('Set HEDDLE_EXAMPLE_BEARER_TOKEN to the secret used by the hosted API example.'); @@ -32,9 +36,11 @@ const client = new HostedAgentClient({ const accepted = await client.start({ sessionId, prompt }); console.log(`Accepted ${accepted.runId}.`); -const cursor = await disconnectAfterFirstActivity(client, accepted.runId); +const consumer = createRunConsumer(accepted.runId); +await disconnectAfterFirstActivity(client, consumer); +const cursor = requireSubscriptionInput(consumer).afterSequence; console.log(`Browser subscription disconnected at sequence ${cursor}; reconnecting.`); -const terminal = await consumeUntilTerminal(client, accepted.runId, cursor); +const terminal = await consumeUntilTerminal(client, consumer); console.log(`Run settled as ${terminal.kind}.`); if (cancelDemo) { @@ -44,22 +50,38 @@ if (cancelDemo) { }); const cancelled = await client.cancel(cancellation.runId); console.log(`Cancellation accepted: ${cancelled.cancelled}.`); - const cancelledTerminal = await consumeUntilTerminal(client, cancellation.runId, 0); + const cancelledTerminal = await consumeUntilTerminal( + client, + createRunConsumer(cancellation.runId), + ); if (cancelledTerminal.kind !== 'cancelled') { throw new Error(`Expected a cancelled terminal, received ${cancelledTerminal.kind}.`); } } -async function disconnectAfterFirstActivity(agent: HostedAgentClient, runId: string): Promise { +function createRunConsumer(runId: string): HostedAgentRunConsumer { + const consumer = new ConversationRunConsumerService({ + retry: { maxAttempts: 5, baseDelayMs: 250, maxDelayMs: 2_000 }, + }); + consumer.select({ runId }); + return consumer; +} + +async function disconnectAfterFirstActivity( + agent: HostedAgentClient, + consumer: HostedAgentRunConsumer, +): Promise { const subscription = new AbortController(); - let cursor = 0; let disconnected = false; try { await agent.subscribe({ - runId, + ...requireSubscriptionInput(consumer), signal: subscription.signal, onEvent: (event) => { - cursor = event.sequence; + const acceptance = consumer.accept(event); + if (!acceptance.accepted) { + return; + } renderEvent(event); if (event.kind === 'activity') { disconnected = true; @@ -75,47 +97,63 @@ async function disconnectAfterFirstActivity(agent: HostedAgentClient, runId: str if (!disconnected) { throw new Error('The first subscription ended before receiving an activity.'); } - return cursor; } async function consumeUntilTerminal( agent: HostedAgentClient, - runId: string, - initialCursor: number, + consumer: HostedAgentRunConsumer, ): Promise { - let cursor = initialCursor; let terminal: HostedAgentRunEvent | undefined; + let lastError: unknown; - for (let attempt = 0; attempt < 5 && !terminal; attempt += 1) { + while (!terminal) { try { await agent.subscribe({ - runId, - afterSequence: cursor, + ...requireSubscriptionInput(consumer), onEvent: (event) => { - cursor = Math.max(cursor, event.sequence); + const acceptance = consumer.accept(event); + if (!acceptance.accepted) { + return; + } renderEvent(event); - if (event.kind !== 'activity') { + if (acceptance.terminal) { terminal = event; } }, }); + if (!terminal) { + lastError = new Error('Hosted agent event stream ended before a terminal event.'); + } } catch (error) { if (error instanceof HostedAgentClientError && (error.status === undefined || error.status < 500)) { throw error; } + lastError = error; } + if (!terminal) { - await delay(Math.min(250 * (2 ** attempt), 2_000)); + const retry = consumer.nextRetry(); + if (!retry) { + throw lastError instanceof Error + ? lastError + : new Error('Hosted agent run exhausted its reconnect attempts.'); + } + await delay(retry.delayMs); } } - if (!terminal) { - throw new Error(`Run ${runId} did not reach a terminal event after bounded reconnects.`); - } return terminal; } +function requireSubscriptionInput(consumer: HostedAgentRunConsumer) { + const input = consumer.subscriptionInput(); + if (!input) { + throw new Error('Hosted agent run no longer accepts subscriptions.'); + } + return input; +} + function renderEvent(event: HostedAgentRunEvent): void { if (event.kind === 'activity') { console.log(`[${event.sequence}] activity ${event.activity.type}`); diff --git a/examples/sdk/05-hosted-agent/README.md b/examples/sdk/05-hosted-agent/README.md index 32593795..91d24ecf 100644 --- a/examples/sdk/05-hosted-agent/README.md +++ b/examples/sdk/05-hosted-agent/README.md @@ -23,8 +23,9 @@ Only the first two assumptions are fundamental to the hosted-service pattern. Express, SSE, bearer auth, and the sample browser client are replaceable host choices. -When copying this example into another project, replace the relative -`src/index.js` imports with `@roackb2/heddle`. Stage 01 requires Heddle only; +When copying this example into another project, replace the relative source +imports with `@roackb2/heddle`, `@roackb2/heddle/hosted`, and +`@roackb2/heddle/remote` as shown by each layer. Stage 01 requires Heddle only; stage 02 additionally uses `express`, its TypeScript types, and `zod`; stage 03 uses `eventsource-parser` and the shared Zod wire contracts. Declare those libraries directly in the host project instead of relying on transitive @@ -51,14 +52,14 @@ dependencies. | v 02-http-sse-api/ - contracts.ts untrusted public wire schemas + contracts.ts public schemas + Heddle remote protocol codec http-api.ts Express start/subscribe/cancel + SSE adapter server.ts runnable local server and demo authentication | v 03-browser-client/ browser-client.ts typed fetch/SSE protocol adapter - run.ts reconnect, cursor, and terminal-policy demo + run.ts public consumer + transport reconnect demo ``` The dependency direction is intentional: @@ -145,7 +146,8 @@ POST /api/agent/runs/:runId/cancel ### What it showcases [`contracts.ts`](02-http-sse-api/contracts.ts) validates untrusted wire data and -defines the public browser contract. [`http-api.ts`](02-http-sse-api/http-api.ts) +defines the public browser contract through Heddle's +`ConversationRunProtocolCodec`. [`http-api.ts`](02-http-sse-api/http-api.ts) adapts HTTP operations to the transport-neutral service: - start returns `202 Accepted` after Heddle assigns a stable run identity; @@ -216,10 +218,11 @@ parsing with `eventsource-parser`, Zod validation, event-ID validation, and abort propagation. [`run.ts`](03-browser-client/run.ts) is the consuming application policy. It -retains the greatest received sequence, deliberately disconnects after one -activity, reconnects with bounded exponential backoff, and stops on a -result/cancel/error terminal. Add `--cancel-demo` to exercise explicit -cancellation. +uses Heddle's `ConversationRunConsumerService` for cursor advancement, +duplicate/gap handling, terminal detection, and bounded reconnect timing. The +runner deliberately disconnects after one activity while retaining ownership +of the actual timer and transport lifecycle. Add `--cancel-demo` to exercise +explicit cancellation. ### Responsibility boundary diff --git a/examples/sdk/README.md b/examples/sdk/README.md index 4bbeb504..94ff5c7c 100644 --- a/examples/sdk/README.md +++ b/examples/sdk/README.md @@ -30,7 +30,7 @@ platform, an identity provider, or a database. | A process that already owns output/rendering | [`04-custom-output.ts`](04-custom-output.ts) | Output sink and presentation policy | | A server, worker, Electron backend, or custom transport | [`05-hosted-agent/01-hosted-service`](05-hosted-agent/01-hosted-service) | Identity scope, durable session IDs, engine composition, and process lifetime | | An Express server using REST + SSE | [`05-hosted-agent/02-http-sse-api`](05-hosted-agent/02-http-sse-api) | Authentication, API policy, HTTP operations, and deployment | -| A browser consuming that REST + SSE contract | [`05-hosted-agent/03-browser-client`](05-hosted-agent/03-browser-client) | UI state, rendering, retry UX, and product result handling | +| A browser consuming that REST + SSE contract | [`05-hosted-agent/03-browser-client`](05-hosted-agent/03-browser-client) | Transport lifecycle, UI state, rendering, retry UX, and product result handling | If your server already uses tRPC, Fastify, Hono, Nest, WebSocket, or another transport, follow the hosted-service stage and write an adapter for that stack. @@ -89,7 +89,8 @@ This directory contains its own numbered path: 1. `01-hosted-service` — transport-neutral account/session/run lifecycle. 2. `02-http-sse-api` — optional Express + REST/SSE adapter. -3. `03-browser-client` — optional framework-neutral browser protocol client. +3. `03-browser-client` — optional framework-neutral browser protocol client + composed with Heddle's public remote-run consumer. Read the [hosted-agent walkthrough](05-hosted-agent/README.md) before copying this stage. Each later folder depends only on the earlier layer it extends, so diff --git a/package.json b/package.json index b5d688c7..18e16ef3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,14 @@ "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" }, + "./hosted": { + "types": "./dist/src/hosted.d.ts", + "import": "./dist/src/hosted.js" + }, + "./remote": { + "types": "./dist/src/remote.d.ts", + "import": "./dist/src/remote.js" + }, "./advanced": { "types": "./dist/src/advanced.d.ts", "import": "./dist/src/advanced.js" diff --git a/src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts b/src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts new file mode 100644 index 00000000..b194370c --- /dev/null +++ b/src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { ConversationRunService as CuratedConversationRunService } from '../../../index.js'; +import { ConversationRunService as HostedConversationRunService } from '../../../hosted.js'; +import { + ConversationRunConsumerService, + ConversationRunProtocolCodec, +} from '../../../remote.js'; +import { z } from 'zod'; + +describe('public hosting entrypoints', () => { + it('exposes the existing run coordinator through the explicit hosted subpath', () => { + expect(HostedConversationRunService).toBe(CuratedConversationRunService); + }); + + it('exposes the browser-safe remote consumer and protocol codec', () => { + const consumer = new ConversationRunConsumerService(); + const codec = new ConversationRunProtocolCodec({ + activity: z.object({ type: z.string() }), + result: z.object({ summary: z.string() }), + }); + + expect(consumer.select({ runId: 'run-1' })).toBe(true); + expect(codec.parseEvent({ + kind: 'result', + runId: 'run-1', + sequence: 1, + timestamp: '2026-07-11T00:00:00.000Z', + result: { summary: 'Done' }, + })).toMatchObject({ kind: 'result', result: { summary: 'Done' } }); + }); +}); diff --git a/src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts b/src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts deleted file mode 100644 index b19ae57e..00000000 --- a/src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ClientSharedConversationRunStreamService } from '@/client-shared/services/conversation-run-stream/index.js'; -import type { ControlPlaneSessionRunEventEnvelope } from '@/client-shared/api/types.js'; - -describe('ClientSharedConversationRunStreamService', () => { - it('advances replay cursors, suppresses duplicates, and resumes from the latest sequence', () => { - const service = new ClientSharedConversationRunStreamService(); - service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-1' }); - - expect(service.accept(activity(1))).toEqual({ accepted: true, terminal: false }); - expect(service.accept(activity(1))).toEqual({ accepted: false, terminal: false }); - expect(service.nextRetry()).toEqual({ - attempt: 1, - delayMs: 500, - input: { - workspaceId: 'workspace-1', - sessionId: 'session-1', - runId: 'run-1', - afterSequence: 1, - }, - }); - }); - - it('rejects sequence gaps instead of silently losing run activity', () => { - const service = new ClientSharedConversationRunStreamService(); - service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-1' }); - - expect(() => service.accept(activity(2))).toThrow('expected 1, received 2'); - }); - - it('stops reconnecting after a terminal item and resets for the next run', () => { - const service = new ClientSharedConversationRunStreamService(); - service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-1' }); - - expect(service.accept(result(1))).toEqual({ accepted: true, terminal: true }); - expect(service.nextRetry()).toBeUndefined(); - - expect(service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-2' })).toBe(true); - expect(service.subscriptionInput()).toEqual({ - workspaceId: 'workspace-1', - sessionId: 'session-1', - runId: 'run-2', - afterSequence: 0, - }); - }); -}); - -function activity(sequence: number): ControlPlaneSessionRunEventEnvelope { - return { - kind: 'activity', - runId: 'run-1', - sequence, - timestamp: '2026-07-11T00:00:00.000Z', - activity: { - source: 'agent-loop', - type: 'assistant.stream', - runId: 'run-1', - step: 1, - text: 'Working', - done: false, - timestamp: '2026-07-11T00:00:00.000Z', - }, - }; -} - -function result(sequence: number): ControlPlaneSessionRunEventEnvelope { - return { - kind: 'result', - runId: 'run-1', - sequence, - timestamp: '2026-07-11T00:00:01.000Z', - result: {}, - }; -} diff --git a/src/__tests__/unit/core/conversation-run-consumer.test.ts b/src/__tests__/unit/core/conversation-run-consumer.test.ts new file mode 100644 index 00000000..1911b5db --- /dev/null +++ b/src/__tests__/unit/core/conversation-run-consumer.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + ConversationRunConsumerService, + ConversationRunSequenceGapError, + ConversationRunTerminalViolationError, + type ConversationRunConsumerEvent, + type ConversationRunReference, +} from '@/core/chat/remote/index.js'; + +type TestRunReference = ConversationRunReference & { + workspaceId: string; + sessionId: string; +}; + +describe('ConversationRunConsumerService', () => { + it('advances cursors, suppresses duplicates, and preserves host reference fields', () => { + const service = createConsumer(); + service.select(run('run-1')); + + expect(service.accept(event('activity', 1))).toEqual({ accepted: true, terminal: false }); + expect(service.accept(event('activity', 1))).toEqual({ accepted: false, terminal: false }); + expect(service.nextRetry()).toEqual({ + attempt: 1, + delayMs: 500, + input: { + workspaceId: 'workspace-1', + sessionId: 'session-1', + runId: 'run-1', + afterSequence: 1, + }, + }); + }); + + it('ignores events from another run without changing the selected cursor', () => { + const service = createConsumer(); + service.select(run('run-1')); + + expect(service.accept(event('activity', 1, 'run-2'))).toEqual({ accepted: false, terminal: false }); + expect(service.subscriptionInput()?.afterSequence).toBe(0); + }); + + it('rejects sequence gaps instead of silently losing activity', () => { + const service = createConsumer(); + service.select(run('run-1')); + + expect(() => service.accept(event('activity', 2))).toThrow(ConversationRunSequenceGapError); + expect(() => service.accept(event('activity', 2))).toThrow('expected 1, received 2'); + }); + + it('stops after one terminal and rejects later non-duplicate events', () => { + const service = createConsumer(); + service.select(run('run-1')); + + expect(service.accept(event('result', 1))).toEqual({ accepted: true, terminal: true }); + expect(service.accept(event('result', 1))).toEqual({ accepted: false, terminal: true }); + expect(service.nextRetry()).toBeUndefined(); + expect(() => service.accept(event('error', 2))).toThrow(ConversationRunTerminalViolationError); + }); + + it('resets retry attempts after progress and caps exponential delay', () => { + const service = new ConversationRunConsumerService({ + retry: { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 150 }, + }); + service.select(run('run-1')); + + expect(service.nextRetry()?.delayMs).toBe(100); + expect(service.nextRetry()?.delayMs).toBe(150); + expect(service.accept(event('activity', 1))).toEqual({ accepted: true, terminal: false }); + expect(service.nextRetry()).toMatchObject({ attempt: 1, delayMs: 100 }); + expect(service.nextRetry()).toMatchObject({ attempt: 2, delayMs: 150 }); + expect(service.nextRetry()).toMatchObject({ attempt: 3, delayMs: 150 }); + expect(service.nextRetry()).toBeUndefined(); + }); + + it('resets cleanly for a new run and rejects invalid inputs', () => { + const service = createConsumer(); + service.select(run('run-1')); + service.accept(event('cancelled', 1)); + + expect(service.select(run('run-2'))).toBe(true); + expect(service.subscriptionInput()).toMatchObject({ runId: 'run-2', afterSequence: 0 }); + expect(() => service.select(run(' '))).toThrow('non-empty runId'); + expect(() => service.accept(event('activity', 0, 'run-2'))).toThrow('positive safe integer'); + }); + + it('validates retry configuration once at construction', () => { + expect(() => new ConversationRunConsumerService({ + retry: { maxAttempts: -1 }, + })).toThrow('non-negative safe integer'); + expect(() => new ConversationRunConsumerService({ + retry: { baseDelayMs: 500, maxDelayMs: 100 }, + })).toThrow('cannot be less than its base delay'); + }); +}); + +function createConsumer(): ConversationRunConsumerService { + return new ConversationRunConsumerService(); +} + +function run(runId: string): TestRunReference { + return { + workspaceId: 'workspace-1', + sessionId: 'session-1', + runId, + }; +} + +function event( + kind: ConversationRunConsumerEvent['kind'], + sequence: number, + runId = 'run-1', +): ConversationRunConsumerEvent { + return { kind, runId, sequence }; +} diff --git a/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts b/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts new file mode 100644 index 00000000..720b3a44 --- /dev/null +++ b/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { ConversationRunProtocolCodec } from '@/core/chat/remote/index.js'; + +const PublicActivitySchema = z.object({ + type: z.string().min(1), +}).passthrough(); +const PublicResultSchema = z.object({ + outcome: z.string().min(1), + summary: z.string(), +}); + +describe('ConversationRunProtocolCodec', () => { + it('validates every canonical event kind', () => { + const codec = createCodec(); + + expect(codec.parseEvent(envelope({ + kind: 'activity', + activity: { type: 'assistant.stream', text: 'Working' }, + }))).toMatchObject({ kind: 'activity', activity: { text: 'Working' } }); + expect(codec.parseEvent(envelope({ + kind: 'result', + result: { outcome: 'done', summary: 'Finished' }, + }))).toMatchObject({ kind: 'result', result: { outcome: 'done' } }); + expect(codec.parseEvent(envelope({ + kind: 'cancelled', + reason: 'Cancelled by user', + }))).toMatchObject({ kind: 'cancelled' }); + expect(codec.parseEvent(envelope({ + kind: 'error', + error: { code: 'run_failed', message: 'Failed' }, + }))).toMatchObject({ kind: 'error' }); + }); + + it('applies the host result schema before data crosses the boundary', () => { + const event = createCodec().parseEvent(envelope({ + kind: 'result', + result: { + outcome: 'done', + summary: 'Public summary', + internalSession: { secret: true }, + }, + })); + + expect(event).toEqual(envelope({ + kind: 'result', + result: { outcome: 'done', summary: 'Public summary' }, + })); + }); + + it('rejects malformed envelopes and host payloads', () => { + const codec = createCodec(); + + expect(() => codec.parseEvent({ + ...envelope({ kind: 'cancelled', reason: 'Cancelled' }), + sequence: 0, + })).toThrow(); + expect(() => codec.parseEvent({ + ...envelope({ kind: 'cancelled', reason: 'Cancelled' }), + timestamp: 'not-a-date', + })).toThrow(); + expect(() => codec.parseEvent(envelope({ + kind: 'result', + result: { outcome: 'done' }, + }))).toThrow(); + }); + + it('rejects non-JSON-safe values after host schema parsing', () => { + const codec = createCodec(); + + expect(() => codec.parseEvent(envelope({ + kind: 'activity', + activity: { type: 'tool.calling', unsafe: 1n }, + }))).toThrow('JSON-safe'); + expect(() => codec.parseEvent(envelope({ + kind: 'activity', + activity: { type: 'tool.calling', unsafe: undefined }, + }))).toThrow('JSON-safe'); + }); + + it('round-trips validated events through JSON serialization', () => { + const codec = createCodec(); + const input = envelope({ + kind: 'result', + result: { outcome: 'done', summary: 'Finished' }, + }); + + expect(codec.parseEvent(JSON.parse(codec.stringifyEvent(input)))).toEqual(input); + expect(codec.safeParseEvent(input).success).toBe(true); + }); +}); + +function createCodec() { + return new ConversationRunProtocolCodec({ + activity: PublicActivitySchema, + result: PublicResultSchema, + }); +} + +function envelope(payload: Record) { + return { + runId: 'run-1', + sequence: 1, + timestamp: '2026-07-11T00:00:00.000Z', + ...payload, + }; +} diff --git a/src/advanced.ts b/src/advanced.ts index 7052e3e2..372fe682 100644 --- a/src/advanced.ts +++ b/src/advanced.ts @@ -1,9 +1,11 @@ // =========================================================================== // Heddle — Advanced / full surface (`@roackb2/heddle/advanced`) // =========================================================================== -// The complete public surface: the curated SDK (rungs 1-5, re-exported below) -// plus lower-level building blocks and specialized runtimes. Reach for this -// entry when you need LLM adapters, individual ready-made tools, trace/memory +// The deep core-customization surface: the curated SDK (rungs 1-5, re-exported +// below) plus lower-level building blocks and specialized runtimes. Remote +// hosting remains an orthogonal opt-in through `@roackb2/heddle/hosted` and +// `@roackb2/heddle/remote`; those subpaths are not re-exported here. Reach for +// this entry when you need LLM adapters, individual ready-made tools, trace/memory // internals, the agent loop, heartbeat, or integrations. Most product hosts // only need the curated default entry (`@roackb2/heddle`, see src/index.ts). // =========================================================================== diff --git a/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts b/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts index 8f5d58c8..ed3fdcf5 100644 --- a/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts +++ b/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts @@ -5,14 +5,19 @@ import type { ControlPlaneSessionsEventEnvelope, } from '@/client-shared/api/types.js'; import { - ClientSharedConversationRunStreamService, - type ClientSharedConversationRunReference, + ConversationRunConsumerService, + type ConversationRunReference, } from '@/client-shared/services/conversation-run-stream/index.js'; type SubscriptionHandle = { unsubscribe: () => void; }; +type ControlPlaneConversationRunReference = ConversationRunReference & { + workspaceId: string; + sessionId: string; +}; + export type ControlPlaneSessionSubscriptionServiceOptions = { client: ControlPlaneProxyClient; onSessionsUpdated: () => void; @@ -37,7 +42,7 @@ export class ControlPlaneSessionSubscriptionService { private runSubscription?: SubscriptionHandle; private runReconnectTimer?: ReturnType; private sessionAddress?: { workspaceId: string; sessionId: string }; - private readonly runStream = new ClientSharedConversationRunStreamService(); + private readonly runStream = new ConversationRunConsumerService(); constructor(private readonly options: ControlPlaneSessionSubscriptionServiceOptions) {} @@ -77,7 +82,7 @@ export class ControlPlaneSessionSubscriptionService { }); } - subscribeToRun(run: ClientSharedConversationRunReference): void { + subscribeToRun(run: ControlPlaneConversationRunReference): void { const sessionAddress = this.sessionAddress; if ( !sessionAddress diff --git a/src/cli-v2/state/README.md b/src/cli-v2/state/README.md index aecf8920..b5276d3d 100644 --- a/src/cli-v2/state/README.md +++ b/src/cli-v2/state/README.md @@ -43,8 +43,8 @@ snapshot and call user-intent methods. control-plane APIs and `client-shared` types/services. - Attach detailed activity through `sessionRunEvents`; keep `sessionEvents` limited to lifecycle discovery, approval, queue, and persisted-state signals. -- Reuse `ClientSharedConversationRunStreamService` for sequence cursors, - duplicate suppression, gap detection, and reconnect policy. +- Reuse `ConversationRunConsumerService` through `client-shared` for sequence + cursors, duplicate suppression, gap detection, and reconnect policy. - Do not add a controller that only forwards calls. A controller must own real workflow behavior: ordering, state transitions, lifecycle reset, event reduction, or terminal UX coordination. diff --git a/src/client-shared/services/conversation-run-stream/README.md b/src/client-shared/services/conversation-run-stream/README.md deleted file mode 100644 index 6f72c732..00000000 --- a/src/client-shared/services/conversation-run-stream/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Conversation Run Stream Client State - -This service owns frontend-neutral cursor and reconnect semantics for the -control plane's run-addressed stream. - -## Owns - -- selecting one accepted run identity; -- advancing the canonical sequence cursor; -- suppressing replayed duplicates; -- detecting sequence gaps; -- recognizing result, cancellation, and error terminals; -- bounded exponential reconnect timing from the latest accepted sequence. - -## Does not own - -- tRPC, React Query, EventSource, timers, or subscription handles; -- CLI/Ink or browser/React state; -- conversation activity presentation; -- server run coordination or replay storage. - -CLI-v2 and web-v2 each own their transport lifecycle but must use this service -for cursor correctness so Heddle's interfaces behave like SDK-built hosts. diff --git a/src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts b/src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts deleted file mode 100644 index be969675..00000000 --- a/src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { ControlPlaneSessionRunEventEnvelope } from '@/client-shared/api/types.js'; - -const DEFAULT_MAX_RECONNECT_ATTEMPTS = 6; -const DEFAULT_RECONNECT_BASE_DELAY_MS = 500; -const DEFAULT_RECONNECT_MAX_DELAY_MS = 4_000; - -export type ClientSharedConversationRunReference = { - workspaceId: string; - sessionId: string; - runId: string; -}; - -export type ClientSharedConversationRunSubscriptionInput = ClientSharedConversationRunReference & { - afterSequence: number; -}; - -export type ClientSharedConversationRunRetry = { - attempt: number; - delayMs: number; - input: ClientSharedConversationRunSubscriptionInput; -}; - -export type ClientSharedConversationRunEventAcceptance = { - accepted: boolean; - terminal: boolean; -}; - -/** - * Owns frontend-neutral run cursor correctness and bounded reconnect policy. - * - * Transport adapters remain host-owned. This service prevents CLI and web from - * diverging on run selection, duplicate suppression, sequence gaps, terminal - * detection, and replay cursor advancement. - */ -export class ClientSharedConversationRunStreamService { - private run?: ClientSharedConversationRunReference; - private sequence = 0; - private terminal = false; - private retryAttempt = 0; - - select(run: ClientSharedConversationRunReference): boolean { - if (this.isCurrent(run)) { - return false; - } - - this.run = run; - this.sequence = 0; - this.terminal = false; - this.retryAttempt = 0; - return true; - } - - accept(event: ControlPlaneSessionRunEventEnvelope): ClientSharedConversationRunEventAcceptance { - if (!this.run || event.runId !== this.run.runId) { - return { accepted: false, terminal: false }; - } - if (event.sequence <= this.sequence) { - return { accepted: false, terminal: this.terminal }; - } - - const expectedSequence = this.sequence + 1; - if (event.sequence !== expectedSequence) { - throw new Error( - `Conversation run stream sequence gap for ${event.runId}: expected ${expectedSequence}, received ${event.sequence}.`, - ); - } - - this.sequence = event.sequence; - this.terminal = event.kind !== 'activity'; - this.retryAttempt = 0; - return { accepted: true, terminal: this.terminal }; - } - - subscriptionInput(): ClientSharedConversationRunSubscriptionInput | undefined { - if (!this.run || this.terminal) { - return undefined; - } - - return { - ...this.run, - afterSequence: this.sequence, - }; - } - - nextRetry(options: { - maxAttempts?: number; - baseDelayMs?: number; - maxDelayMs?: number; - } = {}): ClientSharedConversationRunRetry | undefined { - const input = this.subscriptionInput(); - if (!input) { - return undefined; - } - - const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS; - const nextAttempt = this.retryAttempt + 1; - if (nextAttempt > maxAttempts) { - return undefined; - } - - this.retryAttempt = nextAttempt; - const baseDelayMs = options.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; - const maxDelayMs = options.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; - return { - attempt: nextAttempt, - delayMs: Math.min(baseDelayMs * 2 ** (nextAttempt - 1), maxDelayMs), - input, - }; - } - - isCurrent(run: ClientSharedConversationRunReference): boolean { - return Boolean( - this.run - && this.run.workspaceId === run.workspaceId - && this.run.sessionId === run.sessionId - && this.run.runId === run.runId, - ); - } - - isTerminal(): boolean { - return this.terminal; - } - - clear(runId?: string): void { - if (runId !== undefined && this.run?.runId !== runId) { - return; - } - - this.run = undefined; - this.sequence = 0; - this.terminal = false; - this.retryAttempt = 0; - } -} diff --git a/src/client-shared/services/conversation-run-stream/index.ts b/src/client-shared/services/conversation-run-stream/index.ts index f625423a..03655ac4 100644 --- a/src/client-shared/services/conversation-run-stream/index.ts +++ b/src/client-shared/services/conversation-run-stream/index.ts @@ -1,9 +1,14 @@ +// Presentation clients stay on the client-shared boundary while reusing the +// exact public remote-run implementation exported by the Heddle SDK. export { - ClientSharedConversationRunStreamService, -} from './conversation-run-stream-service.js'; + ConversationRunConsumerService, + ConversationRunSequenceGapError, + ConversationRunTerminalViolationError, +} from '@/core/chat/remote/index.js'; export type { - ClientSharedConversationRunEventAcceptance, - ClientSharedConversationRunReference, - ClientSharedConversationRunRetry, - ClientSharedConversationRunSubscriptionInput, -} from './conversation-run-stream-service.js'; + ConversationRunConsumerEvent, + ConversationRunEventAcceptance, + ConversationRunReference, + ConversationRunRetry, + ConversationRunSubscriptionInput, +} from '@/core/chat/remote/index.js'; diff --git a/src/core/chat/remote/README.md b/src/core/chat/remote/README.md new file mode 100644 index 00000000..8653ad53 --- /dev/null +++ b/src/core/chat/remote/README.md @@ -0,0 +1,50 @@ +# Remote Conversation Runs + +This domain owns browser-safe, transport-neutral correctness for consuming a +conversation run across a remote boundary. + +## Owns + +- selecting one accepted run reference; +- advancing the greatest accepted sequence cursor; +- suppressing replayed duplicates; +- rejecting sequence gaps and post-terminal events; +- recognizing result, cancellation, and error terminals; +- bounded exponential reconnect timing; +- runtime validation of the canonical run envelope; +- JSON-safety validation before a transport serializes an event. + +## Does not own + +- server run coordination or replay storage (`src/core/chat/runs` owns those); +- HTTP, SSE, tRPC, WebSocket, React, or transport handles/timers; +- authentication, authorization, tenant policy, or route design; +- which activity/result fields are safe to expose publicly; +- product messages, tool rendering, finalization, conflict handling, or UI. + +## Public usage + +Import this layer explicitly so the remote-hosting assumption is visible: + +```ts +import { + ConversationRunConsumerService, + ConversationRunProtocolCodec, +} from '@roackb2/heddle/remote' + +const protocol = new ConversationRunProtocolCodec({ + activity: PublicActivitySchema, + result: PublicResultSchema, +}) + +const consumer = new ConversationRunConsumerService({ + retry: { maxAttempts: 6, baseDelayMs: 500, maxDelayMs: 4_000 }, +}) +``` + +The host must supply schemas that expose only authorized public payloads. The +codec owns the envelope and JSON safety; it does not sanitize sensitive product +or tool data on the host's behalf. + +CLI-v2, web-v2, and SDK examples must reuse this service. Do not add another +cursor/retry state machine in a client adapter. diff --git a/src/core/chat/remote/consumer-service.ts b/src/core/chat/remote/consumer-service.ts new file mode 100644 index 00000000..1da27a05 --- /dev/null +++ b/src/core/chat/remote/consumer-service.ts @@ -0,0 +1,193 @@ +import type { + ConversationRunConsumerEvent, + ConversationRunConsumerServiceOptions, + ConversationRunEventAcceptance, + ConversationRunReference, + ConversationRunRetry, + ConversationRunSubscriptionInput, +} from './types.js'; + +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 6; +const DEFAULT_RECONNECT_BASE_DELAY_MS = 500; +const DEFAULT_RECONNECT_MAX_DELAY_MS = 4_000; +const EVENT_KINDS = new Set(['activity', 'result', 'cancelled', 'error']); + +type ResolvedRetryOptions = { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; +}; + +export class ConversationRunSequenceGapError extends Error { + constructor( + readonly runId: string, + readonly expectedSequence: number, + readonly receivedSequence: number, + ) { + super( + `Conversation run stream sequence gap for ${runId}: expected ${expectedSequence}, received ${receivedSequence}.`, + ); + } +} + +export class ConversationRunTerminalViolationError extends Error { + constructor(readonly runId: string, readonly receivedSequence: number) { + super(`Conversation run ${runId} received sequence ${receivedSequence} after its terminal event.`); + } +} + +/** + * Owns transport-neutral cursor correctness and bounded reconnect policy for + * one remotely observed conversation run. + */ +export class ConversationRunConsumerService< + Reference extends ConversationRunReference = ConversationRunReference, +> { + private readonly retry: ResolvedRetryOptions; + private run?: Reference; + private sequence = 0; + private terminal = false; + private retryAttempt = 0; + + constructor(options: ConversationRunConsumerServiceOptions = {}) { + this.retry = ConversationRunConsumerService.resolveRetryOptions(options.retry); + } + + select(run: Reference): boolean { + ConversationRunConsumerService.assertRunId(run.runId); + if (this.isCurrent(run)) { + return false; + } + + this.run = run; + this.sequence = 0; + this.terminal = false; + this.retryAttempt = 0; + return true; + } + + accept(event: ConversationRunConsumerEvent): ConversationRunEventAcceptance { + if (!this.run || event.runId !== this.run.runId) { + return { accepted: false, terminal: false }; + } + + ConversationRunConsumerService.assertEvent(event); + if (event.sequence <= this.sequence) { + return { accepted: false, terminal: this.terminal }; + } + if (this.terminal) { + throw new ConversationRunTerminalViolationError(event.runId, event.sequence); + } + + const expectedSequence = this.sequence + 1; + if (event.sequence !== expectedSequence) { + throw new ConversationRunSequenceGapError(event.runId, expectedSequence, event.sequence); + } + + this.sequence = event.sequence; + this.terminal = event.kind !== 'activity'; + this.retryAttempt = 0; + return { accepted: true, terminal: this.terminal }; + } + + subscriptionInput(): ConversationRunSubscriptionInput | undefined { + if (!this.run || this.terminal) { + return undefined; + } + + return { + ...this.run, + afterSequence: this.sequence, + }; + } + + nextRetry(): ConversationRunRetry | undefined { + const input = this.subscriptionInput(); + if (!input) { + return undefined; + } + + const nextAttempt = this.retryAttempt + 1; + if (nextAttempt > this.retry.maxAttempts) { + return undefined; + } + + this.retryAttempt = nextAttempt; + return { + attempt: nextAttempt, + delayMs: Math.min( + this.retry.baseDelayMs * 2 ** (nextAttempt - 1), + this.retry.maxDelayMs, + ), + input, + }; + } + + isCurrent(run: Reference): boolean { + return this.run?.runId === run.runId; + } + + isTerminal(): boolean { + return this.terminal; + } + + clear(runId?: string): void { + if (runId !== undefined && this.run?.runId !== runId) { + return; + } + + this.run = undefined; + this.sequence = 0; + this.terminal = false; + this.retryAttempt = 0; + } + + private static resolveRetryOptions( + options: ConversationRunConsumerServiceOptions['retry'] = {}, + ): ResolvedRetryOptions { + const resolved = { + maxAttempts: options.maxAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS, + baseDelayMs: options.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS, + maxDelayMs: options.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + }; + + ConversationRunConsumerService.assertNonNegativeSafeInteger( + 'Conversation run max reconnect attempts', + resolved.maxAttempts, + ); + ConversationRunConsumerService.assertNonNegativeSafeInteger( + 'Conversation run reconnect base delay', + resolved.baseDelayMs, + ); + ConversationRunConsumerService.assertNonNegativeSafeInteger( + 'Conversation run reconnect maximum delay', + resolved.maxDelayMs, + ); + if (resolved.maxDelayMs < resolved.baseDelayMs) { + throw new Error('Conversation run reconnect maximum delay cannot be less than its base delay.'); + } + return resolved; + } + + private static assertEvent(event: ConversationRunConsumerEvent): void { + ConversationRunConsumerService.assertRunId(event.runId); + if (!Number.isSafeInteger(event.sequence) || event.sequence < 1) { + throw new Error('Conversation run event sequence must be a positive safe integer.'); + } + if (!EVENT_KINDS.has(event.kind)) { + throw new Error(`Unsupported conversation run event kind: ${String(event.kind)}.`); + } + } + + private static assertRunId(runId: string): void { + if (typeof runId !== 'string' || !runId.trim()) { + throw new Error('Conversation run references require a non-empty runId.'); + } + } + + private static assertNonNegativeSafeInteger(label: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer.`); + } + } +} diff --git a/src/core/chat/remote/index.ts b/src/core/chat/remote/index.ts new file mode 100644 index 00000000..1135889f --- /dev/null +++ b/src/core/chat/remote/index.ts @@ -0,0 +1,24 @@ +export { + ConversationRunConsumerService, + ConversationRunSequenceGapError, + ConversationRunTerminalViolationError, +} from './consumer-service.js'; +export { + ConversationRunProtocolCodec, + ConversationRunReferenceSchema, + ConversationRunReplayCursorSchema, +} from './protocol-codec.js'; +export type { + ConversationRunConsumerEvent, + ConversationRunConsumerRetryOptions, + ConversationRunConsumerServiceOptions, + ConversationRunEventAcceptance, + ConversationRunProtocolCodecOptions, + ConversationRunProtocolEnvelope, + ConversationRunProtocolError, + ConversationRunProtocolEvent, + ConversationRunProtocolEventKind, + ConversationRunReference, + ConversationRunRetry, + ConversationRunSubscriptionInput, +} from './types.js'; diff --git a/src/core/chat/remote/protocol-codec.ts b/src/core/chat/remote/protocol-codec.ts new file mode 100644 index 00000000..e714912f --- /dev/null +++ b/src/core/chat/remote/protocol-codec.ts @@ -0,0 +1,74 @@ +import { z, type ZodType } from 'zod'; +import type { + ConversationRunProtocolCodecOptions, + ConversationRunProtocolEvent, +} from './types.js'; + +const NonBlankStringSchema = z.string().refine((value) => Boolean(value.trim()), { + message: 'Expected a non-empty string.', +}); + +export const ConversationRunReferenceSchema = z.object({ + runId: NonBlankStringSchema, +}); + +export const ConversationRunReplayCursorSchema = z.number().int().nonnegative().safe(); + +const ConversationRunProtocolEnvelopeSchema = z.object({ + runId: NonBlankStringSchema, + sequence: z.number().int().positive().safe(), + timestamp: z.iso.datetime(), +}); + +/** + * Owns runtime validation and JSON-safe serialization for the canonical remote + * conversation-run envelope. Hosts supply their public activity/result schemas. + */ +export class ConversationRunProtocolCodec { + readonly eventSchema: ZodType>; + + constructor(options: ConversationRunProtocolCodecOptions) { + const schema = z.discriminatedUnion('kind', [ + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('activity'), + activity: options.activity, + }), + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('result'), + result: options.result, + }), + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('cancelled'), + reason: z.string(), + }), + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('error'), + error: z.object({ + code: NonBlankStringSchema, + message: z.string(), + }), + }), + ]).superRefine((event, context) => { + if (!z.json().safeParse(event).success) { + context.addIssue({ + code: 'custom', + message: 'Conversation run events must contain only JSON-safe values.', + }); + } + }); + + this.eventSchema = schema as ZodType>; + } + + parseEvent(input: unknown): ConversationRunProtocolEvent { + return this.eventSchema.parse(input); + } + + safeParseEvent(input: unknown) { + return this.eventSchema.safeParse(input); + } + + stringifyEvent(input: unknown): string { + return JSON.stringify(this.parseEvent(input)); + } +} diff --git a/src/core/chat/remote/types.ts b/src/core/chat/remote/types.ts new file mode 100644 index 00000000..cf5ec027 --- /dev/null +++ b/src/core/chat/remote/types.ts @@ -0,0 +1,71 @@ +import type { ZodType } from 'zod'; + +export type ConversationRunReference = { + runId: string; +}; + +export type ConversationRunProtocolEventKind = 'activity' | 'result' | 'cancelled' | 'error'; + +export type ConversationRunConsumerEvent = { + runId: string; + sequence: number; + kind: ConversationRunProtocolEventKind; +}; + +export type ConversationRunSubscriptionInput = + Reference & { afterSequence: number }; + +export type ConversationRunConsumerRetryOptions = { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +}; + +export type ConversationRunConsumerServiceOptions = { + retry?: ConversationRunConsumerRetryOptions; +}; + +export type ConversationRunRetry = { + attempt: number; + delayMs: number; + input: ConversationRunSubscriptionInput; +}; + +export type ConversationRunEventAcceptance = { + accepted: boolean; + terminal: boolean; +}; + +export type ConversationRunProtocolEnvelope = { + runId: string; + sequence: number; + timestamp: string; +}; + +export type ConversationRunProtocolError = { + code: string; + message: string; +}; + +export type ConversationRunProtocolEvent = + | (ConversationRunProtocolEnvelope & { + kind: 'activity'; + activity: Activity; + }) + | (ConversationRunProtocolEnvelope & { + kind: 'result'; + result: Result; + }) + | (ConversationRunProtocolEnvelope & { + kind: 'cancelled'; + reason: string; + }) + | (ConversationRunProtocolEnvelope & { + kind: 'error'; + error: ConversationRunProtocolError; + }); + +export type ConversationRunProtocolCodecOptions = { + activity: ZodType; + result: ZodType; +}; diff --git a/src/core/chat/runs/README.md b/src/core/chat/runs/README.md index b9307b3e..940dc5ad 100644 --- a/src/core/chat/runs/README.md +++ b/src/core/chat/runs/README.md @@ -25,6 +25,8 @@ service. Do not add a second run coordinator under `src/server` or a product adapter. ```ts +import { ConversationRunService } from '@roackb2/heddle/hosted' + const runs = new ConversationRunService({ replay: { maxEventsPerRun: 512, retentionMs: 300_000 }, }); diff --git a/src/hosted.ts b/src/hosted.ts new file mode 100644 index 00000000..838cd8d5 --- /dev/null +++ b/src/hosted.ts @@ -0,0 +1,17 @@ +// Public hosted-process entrypoint. This adds no new implementation: it makes +// the ConversationRunService hosting assumption explicit in package imports. +export { ConversationRunService } from './core/chat/runs/index.js'; +export type { + ConversationRunAccepted, + ConversationRunAddress, + ConversationRunContext, + ConversationRunHandle, + ConversationRunReplayOptions, + ConversationRunServiceOptions, + ConversationRunStreamItem, + PendingConversationRunApproval, + StartConversationContinueRunInput, + StartConversationRunInput, + StartConversationTurnRunInput, + SubscribeConversationRunInput, +} from './core/chat/runs/index.js'; diff --git a/src/index.ts b/src/index.ts index a2c0b177..817af0f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ // 4. Advanced: lifecycle — drive the engine, sessions, and approvals // 5. Advanced: storage — back artifacts/credentials with your own store // +// Remote-hosting assumptions are explicit peer entrypoints: +// `@roackb2/heddle/hosted` and `@roackb2/heddle/remote`. // Lower-level runtime plumbing (LLM adapters, individual tools, trace, memory, // models, awareness, the agent loop, heartbeat, integrations, utilities) lives // behind the `@roackb2/heddle/advanced` subpath — see src/advanced.ts. diff --git a/src/remote.ts b/src/remote.ts new file mode 100644 index 00000000..64dacd1f --- /dev/null +++ b/src/remote.ts @@ -0,0 +1,3 @@ +// Public browser-safe remote-run entrypoint. Transport/framework adapters live +// outside this module and depend inward on these contracts and services. +export * from './core/chat/remote/index.js'; diff --git a/src/web-v2/README.md b/src/web-v2/README.md index e0ffb946..4338d5a7 100644 --- a/src/web-v2/README.md +++ b/src/web-v2/README.md @@ -66,5 +66,6 @@ Web-v2 uses the same accepted-run model exposed by the Heddle SDK: - cancellation includes the currently observed `runId`. Run cursor, duplicate, sequence-gap, and reconnect rules belong to -`ClientSharedConversationRunStreamService`, shared with cli-v2. React hooks own -only tRPC binding, cache refresh, and browser presentation state. +`ConversationRunConsumerService` from the public remote-run SDK layer, shared +with cli-v2 through `client-shared`. React hooks own only tRPC binding, cache +refresh, and browser presentation state. diff --git a/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts b/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts index 42fc2b1c..3b97b5c5 100644 --- a/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts +++ b/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts @@ -8,8 +8,9 @@ import { type ControlPlaneSessionRunReference, } from '@web/api/client'; import { - ClientSharedConversationRunStreamService, - type ClientSharedConversationRunSubscriptionInput, + ConversationRunConsumerService, + type ConversationRunReference, + type ConversationRunSubscriptionInput, } from '@/client-shared/services/conversation-run-stream'; import { ClientSharedSessionActivityService } from '@/client-shared/services/session-activities'; import { ClientSharedNotificationIntentService, type ClientSharedNotificationIntent } from '@/client-shared/services/notifications'; @@ -23,6 +24,10 @@ import type { RefreshControlPlaneSession } from './useControlPlaneSessionLoader' type SessionRunUpdate = Extract; type SessionRunTerminal = Exclude; +type ControlPlaneConversationRunReference = ConversationRunReference & { + workspaceId: string; + sessionId: string; +}; type UseControlPlaneSessionEventsArgs = { workspaceId?: string; @@ -64,9 +69,13 @@ export function useControlPlaneSessionEvents({ }: UseControlPlaneSessionEventsArgs): ControlPlaneSessionEventsState { const utils = trpcReact.useUtils(); const [streamConnected, setStreamConnected] = useState(false); - const [runSubscriptionInput, setRunSubscriptionInput] = useState(); + const [runSubscriptionInput, setRunSubscriptionInput] = useState< + ConversationRunSubscriptionInput + >(); const activeAddressRef = useRef({ workspaceId, sessionId }); - const runStreamRef = useRef(new ClientSharedConversationRunStreamService()); + const runStreamRef = useRef( + new ConversationRunConsumerService(), + ); const reconnectTimerRef = useRef | undefined>(undefined); const invalidateWorkspaceDiff = useCallback(() => { void utils.controlPlane.workspaceChanges.invalidate(workspaceId ? { workspaceId } : undefined);