@robota-sdk/agent-transport-http 3.0.0-beta.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The relay's own failure modes — the ones an HTTP request cannot conveniently reach.
3
+ *
4
+ * `reportStreamFailure` runs after the headers are out, and `onAbort` runs when the client is
5
+ * already gone; both are places where "what does the other side learn" and "does the claim come
6
+ * back" have to be asked directly.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
12
+
13
+ import { relayTurn, reportStreamFailure } from '../submit-stream.js';
14
+
15
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
16
+ import type { SSEStreamingApi } from 'hono/streaming';
17
+
18
+ function fakeStream(overrides: Partial<SSEStreamingApi> = {}): {
19
+ stream: SSEStreamingApi;
20
+ written: Array<{ event?: string; data: string }>;
21
+ } {
22
+ const written: Array<{ event?: string; data: string }> = [];
23
+ const stream = {
24
+ writeSSE: (message: { event?: string; data: string }) => {
25
+ written.push(message);
26
+ return Promise.resolve();
27
+ },
28
+ onAbort: () => {},
29
+ ...overrides,
30
+ } as unknown as SSEStreamingApi;
31
+ return { stream, written };
32
+ }
33
+
34
+ describe('what a client learns from a stream that failed after it opened', () => {
35
+ it('is a generic line, not the exception', async () => {
36
+ // Review, twice: this route can be mounted outside a trust boundary, and an exception ESCAPING
37
+ // the stream callback is not a message anything composed for a client — it can carry provider
38
+ // internals, paths, stack fragments. The detail goes to the package logger; the client learns
39
+ // that the stream failed, which is the part it can act on.
40
+ const { stream, written } = fakeStream();
41
+
42
+ await reportStreamFailure(new Error('ENOENT /home/op/.fixture-state/providers/key.json'), stream);
43
+
44
+ expect(written).toHaveLength(1);
45
+ expect(written[0].data).not.toContain('key.json');
46
+ expect(written[0].data).toContain('the stream failed on the server');
47
+ });
48
+
49
+ it('swallows a write to a stream that is already gone', async () => {
50
+ const { stream } = fakeStream({
51
+ writeSSE: () => Promise.reject(new Error('closed')),
52
+ });
53
+
54
+ await expect(reportStreamFailure(new Error('x'), stream)).resolves.toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe('a failure in the callback never escapes to the runner', () => {
59
+ it('reports generically, hands the detail to the injected listener, and does not throw', async () => {
60
+ // Hono's runner follows any `onError` by writing the raw `e.message` to the stream — measured:
61
+ // the body carried the generic line AND the leak. So the boundary holds only if the callback
62
+ // catches its own failures, which is what this pins.
63
+ const detail = vi.fn();
64
+ const session = createTestInteractiveSession({
65
+ on: ((event: string) => {
66
+ if (event === 'text_delta') throw new Error('ENOENT /home/op/.fixture-state/key.json');
67
+ }) as IInteractiveSession['on'],
68
+ });
69
+ const { stream, written } = fakeStream();
70
+
71
+ await expect(relayTurn(session, 'p', () => {}, detail)(stream)).resolves.toBeUndefined();
72
+
73
+ expect(detail).toHaveBeenCalledTimes(1);
74
+ expect(detail.mock.calls[0][0].message).toContain('key.json');
75
+ expect(written.map((w) => w.data).join('\n')).not.toContain('key.json');
76
+ expect(written.map((w) => w.data).join('\n')).toContain('the stream failed on the server');
77
+ });
78
+
79
+ it('still tells the client when the listener itself throws', async () => {
80
+ const { stream, written } = fakeStream();
81
+
82
+ await reportStreamFailure(new Error('detail'), stream, () => {
83
+ throw new Error('the host listener is broken');
84
+ });
85
+
86
+ expect(written).toHaveLength(1);
87
+ expect(written[0].data).toContain('the stream failed on the server');
88
+ });
89
+ });
90
+
91
+ describe('a client disconnect releases the claim even when abort throws', () => {
92
+ it('settles and releases', async () => {
93
+ // Review: before the claim registry this cost a listener leak; now an `abort()` that throws
94
+ // would hold the claim forever and 409 every future /submit to this session. `abort()` is typed
95
+ // as a synchronous void and the shipped one does not throw — this case is what turns that from
96
+ // a load-bearing assumption into an implementation detail.
97
+ const release = vi.fn();
98
+ // The conformant double, not a cast — the contract-cast ratchet refuses another one, and it is
99
+ // right to. `submit` resolves at once so the relay parks on `done`, which only the abort path
100
+ // settles here.
101
+ const session = createTestInteractiveSession({
102
+ abort: () => {
103
+ throw new Error('abort blew up');
104
+ },
105
+ });
106
+
107
+ let abortHandler!: () => void;
108
+ const { stream } = fakeStream({
109
+ onAbort: (handler: () => void) => {
110
+ abortHandler = handler;
111
+ },
112
+ });
113
+
114
+ const detail = vi.fn();
115
+ const relaying = relayTurn(session, 'prompt', release, detail)(stream);
116
+ // The abort handler runs inside Hono's abort dispatch, OUTSIDE the relay's try/catch — review
117
+ // traced where a rethrow lands (an unhandled rejection, not a reported failure). So it does not
118
+ // throw: the detail goes to the injected listener and the relay still settles.
119
+ expect(() => abortHandler()).not.toThrow();
120
+ await relaying;
121
+
122
+ expect(release).toHaveBeenCalledTimes(1);
123
+ expect(detail).toHaveBeenCalledTimes(1);
124
+ expect(detail.mock.calls[0][0].message).toBe('abort blew up');
125
+ });
126
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * RUNTIME-38 — the claim registry's own contract.
3
+ *
4
+ * It was exercised only through `routes.test.ts`, which asks HTTP questions and gets HTTP answers.
5
+ * Review asked for these directly, and the reason is the one this repository keeps rediscovering: a
6
+ * branch reached only through a wrapper is a branch whose behaviour is inferred. `keyFor` has three
7
+ * outcomes and two of them are about a session that misbehaves — the shapes an HTTP case would have
8
+ * to build a broken session to reach at all.
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
14
+
15
+ import { createTurnClaims } from '../turn-claims.js';
16
+
17
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
18
+
19
+ /**
20
+ * A session that names itself however the case needs it to.
21
+ *
22
+ * Built on the PUBLISHED conformant double rather than cast to the contract — `contract-cast-ratchet`
23
+ * refuses another cast, and it is right to: a cast is a partial re-implementation nothing checks
24
+ * against the real interface, so a member the contract gains later is silently absent from it.
25
+ */
26
+ function sessionNamed(id: string): IInteractiveSession {
27
+ return createTestInteractiveSession({
28
+ getSession: () => ({ getSessionId: () => id }) as ReturnType<IInteractiveSession['getSession']>,
29
+ });
30
+ }
31
+
32
+ describe('the key a session is claimed under', () => {
33
+ it('is the id the session declares', () => {
34
+ expect(createTurnClaims().keyFor(sessionNamed('session_a'))).toBe('session_a');
35
+ });
36
+
37
+ it('does NOT depend on object identity', () => {
38
+ // The defect the id-keying replaced: a `sessionFactory` returning a fresh wrapper per call for
39
+ // one logical session made every request look unclaimed, and the guard was silently absent.
40
+ const claims = createTurnClaims();
41
+
42
+ expect(claims.keyFor(sessionNamed('same'))).toBe(claims.keyFor(sessionNamed('same')));
43
+ });
44
+
45
+ it('is undefined when the session names itself with an empty string', () => {
46
+ // Not `''`, which is a key: every unnameable session would collide into it and 409 each other.
47
+ expect(createTurnClaims().keyFor(sessionNamed(''))).toBeUndefined();
48
+ });
49
+
50
+ it('is undefined when the session THROWS while naming itself', () => {
51
+ const throwing = createTestInteractiveSession({
52
+ getSession: () => {
53
+ throw new Error('no session bound');
54
+ },
55
+ });
56
+
57
+ expect(createTurnClaims().keyFor(throwing)).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('holding and releasing a claim', () => {
62
+ it('reports a key as held only between hold and release', () => {
63
+ const claims = createTurnClaims();
64
+
65
+ expect(claims.isHeld('a')).toBe(false);
66
+ claims.hold('a');
67
+ expect(claims.isHeld('a')).toBe(true);
68
+ claims.release('a');
69
+ expect(claims.isHeld('a')).toBe(false);
70
+ });
71
+
72
+ it('holds each session separately', () => {
73
+ // PER SESSION, not one flag for the router: a busy neighbour is not a reason to refuse you, and
74
+ // a single flag made one tenant's turn refuse every other tenant's.
75
+ const claims = createTurnClaims();
76
+
77
+ claims.hold('tenant_a');
78
+
79
+ expect(claims.isHeld('tenant_a')).toBe(true);
80
+ expect(claims.isHeld('tenant_b')).toBe(false);
81
+ });
82
+
83
+ it('releases a key that was never held without complaint', () => {
84
+ // The teardown path runs in a `finally` and cannot know whether the claim was taken — a throw
85
+ // there would replace the error being unwound with one about bookkeeping.
86
+ const claims = createTurnClaims();
87
+
88
+ expect(() => claims.release('never-held')).not.toThrow();
89
+ });
90
+
91
+ it('is idempotent on a second hold, so one release still frees it', () => {
92
+ // The route holds once per request and releases once per request. Stating it here means a
93
+ // future counting implementation has to decide deliberately rather than by accident.
94
+ const claims = createTurnClaims();
95
+
96
+ claims.hold('a');
97
+ claims.hold('a');
98
+ claims.release('a');
99
+
100
+ expect(claims.isHeld('a')).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,29 @@
1
+ import type {
2
+ ISessionCommands,
3
+ ISessionConversationRead,
4
+ ISessionEvents,
5
+ ISessionExecutionState,
6
+ ISessionIdentity,
7
+ ISessionPromptResolution,
8
+ ISessionTurnControl,
9
+ ISessionTurnSubmission,
10
+ } from '@robota-sdk/agent-interface-session';
11
+
12
+ /** The exact session capabilities consumed by the public HTTP transport. */
13
+ export interface IHttpTransportSession
14
+ extends
15
+ ISessionTurnSubmission,
16
+ ISessionEvents,
17
+ ISessionTurnControl,
18
+ ISessionIdentity,
19
+ ISessionPromptResolution,
20
+ ISessionCommands,
21
+ ISessionConversationRead,
22
+ ISessionExecutionState {
23
+ /**
24
+ * Resolves once the session can name itself, and rejects if it failed to start. A session that
25
+ * builds itself in the background (`InteractiveSession`) has no id before then, so `/submit` waits
26
+ * on this before claiming a turn. A session that is ready at construction can omit it.
27
+ */
28
+ whenInitialized?(): Promise<void>;
29
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ITransportAdapter implementation for HTTP transport.
3
+ *
4
+ * Wraps createAgentRoutes into the unified ITransportAdapter interface
5
+ * while exposing the underlying Hono app via getApp().
6
+ */
7
+
8
+ import { resolveAdmission } from '@robota-sdk/agent-transport/node';
9
+ import { Hono } from 'hono';
10
+
11
+ import { createAgentRoutes } from './routes.js';
12
+
13
+ import type { IHttpTransportSession } from './http-session.js';
14
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
15
+ import type {
16
+ ITransportAdapter,
17
+ ITransportAdmissionConfig,
18
+ ITransportLifecycleError,
19
+ } from '@robota-sdk/agent-interface-transport';
20
+
21
+ export interface IHttpTransportOptions {
22
+ /** Optional: base path prefix for routes. */
23
+ basePath?: string;
24
+ /**
25
+ * SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and
26
+ * `getAdmissionToken()` returns it for the host to hand to its client.
27
+ *
28
+ * This transport previously had no gate at all, so omitting the option used to mean "anyone may
29
+ * execute anything". Omission now means the safe thing, and running open takes saying so.
30
+ */
31
+ admission?: ITransportAdmissionConfig;
32
+ /**
33
+ * Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.
34
+ * Review found this seam missing: the option existed on the routes and not on the entry point
35
+ * README and the examples actually use, so "the host decides the destination" was a decision no
36
+ * host on this path could make. Same forwarding rule as `admission`, which sat beside it.
37
+ */
38
+ onStreamFailure?: TStreamFailureListener;
39
+ /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */
40
+ attribution?: TTurnAttribution;
41
+ }
42
+
43
+ export interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
44
+ getApp(): Hono;
45
+ getAdmissionToken(): string | null;
46
+ }
47
+
48
+ export function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {
49
+ let session: IHttpTransportSession | null = null;
50
+ let app: Hono | null = null;
51
+ // Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and
52
+ // a transport that cannot mint one must fail before anything is served.
53
+ const admission = resolveAdmission(options?.admission);
54
+ const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>
55
+ Object.assign(new Error(`HTTP transport ${code}.`), {
56
+ name: 'TransportLifecycleError' as const,
57
+ code,
58
+ transportName: 'http',
59
+ });
60
+
61
+ return {
62
+ name: 'http',
63
+ lifecycle: Object.freeze({ kind: 'service' }),
64
+ attach(s: IHttpTransportSession) {
65
+ session = s;
66
+ },
67
+ async start() {
68
+ if (!session) throw lifecycleError('not-attached');
69
+ if (app) throw lifecycleError('already-started');
70
+ const routes = createAgentRoutes({
71
+ sessionFactory: () => session!,
72
+ // The decision already made, passed through — not taken apart and rebuilt for the routes to
73
+ // resolve a second time.
74
+ admission,
75
+ onStreamFailure: options?.onStreamFailure,
76
+ attribution: options?.attribution,
77
+ });
78
+ // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes
79
+ // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.
80
+ app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;
81
+ },
82
+ async stop() {
83
+ app = null;
84
+ session = null;
85
+ },
86
+ getApp() {
87
+ if (!app) throw new Error('Transport not started. Call start() first.');
88
+ return app;
89
+ },
90
+ /** The credential this transport requires, or `null` when the host explicitly opened it. */
91
+ getAdmissionToken() {
92
+ return admission.token;
93
+ },
94
+ };
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createAgentRoutes } from './routes.js';
2
+ export type { IAgentRoutesOptions, TSessionFactory } from './routes.js';
3
+ export type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
4
+ export { createHttpTransport } from './http-transport.js';
5
+ export type { IHttpTransport, IHttpTransportOptions } from './http-transport.js';
6
+ export type { IHttpTransportSession } from './http-session.js';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The permission and ask prompts a `/submit` stream forwarded and that are still open, per session.
3
+ *
4
+ * The session emits a prompt once, when it is asked, so a client that comes later would never see
5
+ * it; `GET /prompts` answers from here, and `POST /prompts/:id` refuses an id that is not here.
6
+ *
7
+ * Keyed by the session's id, the same key as the turn claim, and not by object identity: the session
8
+ * factory may return a fresh wrapper per request, so the `agent-transport` registry (a WeakMap on the
9
+ * session object) would never find the prompt a different request recorded.
10
+ *
11
+ * It learns only through the prompt-receiving stream's own subscriptions and never listens to the
12
+ * session itself. A listener of its own would keep an unattended turn's prompt parked instead of
13
+ * letting the session fail it closed. A session's entries go when that stream's claim is released.
14
+ */
15
+
16
+ import type {
17
+ IAskRequestEvent,
18
+ IPermissionRequestEvent,
19
+ } from '@robota-sdk/agent-interface-session';
20
+
21
+ export type THttpPromptFrame =
22
+ | { readonly type: 'permission_request'; readonly event: IPermissionRequestEvent }
23
+ | { readonly type: 'ask_request'; readonly event: IAskRequestEvent };
24
+
25
+ export interface IHttpOpenPrompts {
26
+ record(sessionKey: string, frame: THttpPromptFrame): void;
27
+ forget(sessionKey: string, id: string): void;
28
+ /** The session's open prompts, oldest first. */
29
+ list(sessionKey: string): readonly THttpPromptFrame[];
30
+ find(sessionKey: string, id: string): THttpPromptFrame | undefined;
31
+ /** The stream that received the session's prompts has ended. */
32
+ clear(sessionKey: string): void;
33
+ }
34
+
35
+ export function createHttpOpenPrompts(): IHttpOpenPrompts {
36
+ const bySession = new Map<string, Map<string, THttpPromptFrame>>();
37
+
38
+ return {
39
+ record(sessionKey, frame) {
40
+ let open = bySession.get(sessionKey);
41
+ if (open === undefined) {
42
+ open = new Map();
43
+ bySession.set(sessionKey, open);
44
+ }
45
+ open.set(frame.event.id, frame);
46
+ },
47
+ forget(sessionKey, id) {
48
+ bySession.get(sessionKey)?.delete(id);
49
+ },
50
+ list: (sessionKey) => [...(bySession.get(sessionKey)?.values() ?? [])],
51
+ find: (sessionKey, id) => bySession.get(sessionKey)?.get(id),
52
+ clear(sessionKey) {
53
+ bySession.delete(sessionKey);
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `GET /prompts` and `POST /prompts/:id` — reading and answering the permission and ask prompts a
3
+ * prompt-receiving `/submit` stream forwarded.
4
+ *
5
+ * Split from `routes.ts` for the same reason `/submit` is: these routes carry a decision of their own
6
+ * (is this id open here, and is this answer the right kind for it), not just a mount.
7
+ */
8
+
9
+ import { decodeClientMessage } from '@robota-sdk/agent-transport';
10
+
11
+ import type { IHttpOpenPrompts } from './open-prompts.js';
12
+ import type { TSessionFactory } from './submit-route.js';
13
+ import type { TTurnAttribution } from './submit-stream.js';
14
+ import type { ITurnClaims } from './turn-claims.js';
15
+ import type { Context } from 'hono';
16
+
17
+ export interface IPromptRouteDeps {
18
+ readonly sessionFactory: TSessionFactory;
19
+ readonly claims: ITurnClaims;
20
+ readonly openPrompts: IHttpOpenPrompts;
21
+ readonly attribution?: TTurnAttribution;
22
+ }
23
+
24
+ /** The open prompts of the request's session, oldest first: what a client that came later reads. */
25
+ export function listPromptsHandler(deps: IPromptRouteDeps) {
26
+ return async (c: Context) => {
27
+ const session = await deps.sessionFactory(c);
28
+ const key = deps.claims.keyFor(session);
29
+ return c.json({ prompts: key === undefined ? [] : deps.openPrompts.list(key) });
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Answer one open prompt by id.
35
+ *
36
+ * The id must be one this session's prompt-receiving stream forwarded and that is still open. An id
37
+ * that never was and one already settled get the same 404: the client cannot act differently on the
38
+ * two, and the registry forgets a prompt once it settles.
39
+ *
40
+ * The answerer is the HOST's `attribution.driverId`, never the request, for the same reason `/submit`
41
+ * attribution is: a client must not record its answer as another surface's.
42
+ *
43
+ * Nothing awaits between the lookup and the resolve. The session settles synchronously and emits
44
+ * `prompt_resolved`, which the stream forwards and the registry forgets, so a second answer for the
45
+ * same id finds nothing open and is refused rather than reported as accepted.
46
+ *
47
+ * `200` means the prompt settled, not merely that the answer was handed over: the session ignores an
48
+ * answer it does not take (one in an outside party's name, say), and a prompt still open after the
49
+ * resolve is answered `409` so the client does not believe it decided something it did not.
50
+ */
51
+ export function answerPromptHandler(deps: IPromptRouteDeps) {
52
+ const { sessionFactory, claims, openPrompts, attribution } = deps;
53
+ return async (c: Context) => {
54
+ const session = await sessionFactory(c);
55
+ const id = c.req.param('id') ?? '';
56
+ let body: { result?: unknown; response?: unknown };
57
+ try {
58
+ body = await c.req.json();
59
+ } catch {
60
+ return c.json({ error: 'the body must be a JSON object' }, 400);
61
+ }
62
+
63
+ const key = claims.keyFor(session);
64
+ const frame = key === undefined ? undefined : openPrompts.find(key, id);
65
+ if (key === undefined || frame === undefined) {
66
+ return c.json({ error: 'no open prompt with this id' }, 404);
67
+ }
68
+
69
+ if (frame.type === 'permission_request') {
70
+ const decoded = decodeClientMessage({
71
+ type: 'permission-response',
72
+ id,
73
+ result: body?.result,
74
+ });
75
+ if (!decoded.ok || decoded.message.type !== 'permission-response') {
76
+ return c.json(
77
+ { error: 'result must be true, false, "allow-session" or "allow-project"' },
78
+ 400,
79
+ );
80
+ }
81
+ session.resolvePermission(id, decoded.message.result, attribution?.driverId);
82
+ } else {
83
+ const decoded = decodeClientMessage({ type: 'ask-response', id, response: body?.response });
84
+ if (!decoded.ok || decoded.message.type !== 'ask-response') {
85
+ return c.json(
86
+ {
87
+ error:
88
+ 'response must be { "type": "answer", "values": string[], "text"?: string } or ' +
89
+ '{ "type": "cancelled" }',
90
+ },
91
+ 400,
92
+ );
93
+ }
94
+ session.resolveAsk(id, decoded.message.response, attribution?.driverId);
95
+ }
96
+ if (openPrompts.find(key, id) !== undefined) {
97
+ return c.json({ error: 'the session did not accept this answer' }, 409);
98
+ }
99
+ return c.json({ ok: true });
100
+ };
101
+ }