@robota-sdk/agent-transport-http 3.0.0-beta.87 → 3.0.0-beta.90

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,299 @@
1
+ /**
2
+ * Permission and ask prompts over the HTTP routes: forwarded on a prompt-receiving `/submit` stream,
3
+ * listed by `GET /prompts` for a client that came later, and answered by `POST /prompts/:id`.
4
+ */
5
+
6
+ import { describe, it, expect, vi } from 'vitest';
7
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
8
+
9
+ import { createAgentRoutes } from '../routes.js';
10
+
11
+ import type {
12
+ IInteractiveSession,
13
+ ITurnHandle,
14
+ TPermissionResultValue,
15
+ } from '@robota-sdk/agent-interface-session';
16
+
17
+ type TActionResponse = Parameters<IInteractiveSession['resolveAsk']>[1];
18
+
19
+ const DRIVER = 'remote:http';
20
+
21
+ function stubHandle(): ITurnHandle {
22
+ return {
23
+ turnId: 'turn-1',
24
+ completed: Promise.resolve({
25
+ response: '',
26
+ history: [],
27
+ toolSummaries: [],
28
+ contextState: { usedTokens: 0, maxTokens: 0, usedPercentage: 0, remainingPercentage: 100 },
29
+ }),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * A session whose turn asks once, the way the real prompt registry does: it parks the prompt only
35
+ * while someone listens for it and fails closed otherwise, settles the first answer for an id, and
36
+ * emits `prompt_resolved` synchronously as it settles.
37
+ */
38
+ function createAskingSession(
39
+ kind: 'permission' | 'ask',
40
+ options: { ignoreAnswers?: boolean; throwAfterAsk?: boolean } = {},
41
+ ) {
42
+ const listeners = new Map<string, Set<(data: unknown) => void>>();
43
+ const emit = (event: string, data: unknown): void => {
44
+ for (const h of [...(listeners.get(event) ?? [])]) h(data);
45
+ };
46
+ const parked = new Map<string, (value: TPermissionResultValue | TActionResponse) => void>();
47
+ const settle = (id: string, value: TPermissionResultValue | TActionResponse): void => {
48
+ const resolve = parked.get(id);
49
+ if (resolve === undefined) return;
50
+ parked.delete(id);
51
+ resolve(value);
52
+ emit('prompt_resolved', { id });
53
+ };
54
+ const gate = kind === 'permission' ? 'permission_request' : 'ask_request';
55
+ const ask = (): Promise<TPermissionResultValue | TActionResponse> => {
56
+ if ((listeners.get(gate)?.size ?? 0) === 0) {
57
+ return Promise.resolve(kind === 'permission' ? false : { type: 'cancelled' });
58
+ }
59
+ const id = kind === 'permission' ? 'p1' : 'a1';
60
+ return new Promise((resolve) => {
61
+ parked.set(id, resolve);
62
+ emit(
63
+ gate,
64
+ kind === 'permission'
65
+ ? { id, toolName: 'Bash', toolArgs: { command: 'ls' } }
66
+ : { id, request: { id: 'q', title: 'Pick one', options: [{ value: 'x', label: 'X' }] } },
67
+ );
68
+ });
69
+ };
70
+ const resolvePermission = vi.fn((id: string, result: TPermissionResultValue) => {
71
+ if (options.ignoreAnswers !== true) settle(id, result);
72
+ });
73
+ const resolveAsk = vi.fn((id: string, response: TActionResponse) => {
74
+ if (options.ignoreAnswers !== true) settle(id, response);
75
+ });
76
+ const failAllClosed = (): void => {
77
+ for (const id of [...parked.keys()]) {
78
+ settle(id, kind === 'permission' ? false : { type: 'cancelled' });
79
+ }
80
+ };
81
+ const session = createTestInteractiveSession({
82
+ // Like the real session: an aborted turn settles every prompt it left parked.
83
+ abort: failAllClosed,
84
+ submit: (async () => {
85
+ if (options.throwAfterAsk === true) {
86
+ void ask();
87
+ await new Promise((r) => setTimeout(r, 5));
88
+ throw new Error('the turn failed while its prompt was open');
89
+ }
90
+ const answer = await ask();
91
+ emit('complete', { success: true, content: JSON.stringify(answer) });
92
+ return stubHandle();
93
+ }) as IInteractiveSession['submit'],
94
+ resolvePermission: resolvePermission as IInteractiveSession['resolvePermission'],
95
+ resolveAsk: resolveAsk as IInteractiveSession['resolveAsk'],
96
+ on: ((event: string, handler: (data: unknown) => void) => {
97
+ if (!listeners.has(event)) listeners.set(event, new Set());
98
+ listeners.get(event)?.add(handler);
99
+ }) as IInteractiveSession['on'],
100
+ off: ((event: string, handler: (data: unknown) => void) => {
101
+ listeners.get(event)?.delete(handler);
102
+ // Like the real session: the last prompt listener leaving settles what it left parked.
103
+ if ((listeners.get(gate)?.size ?? 0) === 0) failAllClosed();
104
+ }) as IInteractiveSession['off'],
105
+ });
106
+ return {
107
+ session,
108
+ resolvePermission,
109
+ resolveAsk,
110
+ listenerCount: (e: string) => listeners.get(e)?.size ?? 0,
111
+ };
112
+ }
113
+
114
+ function routesFor(session: IInteractiveSession) {
115
+ return createAgentRoutes({
116
+ sessionFactory: () => ({ ...session }),
117
+ admission: { open: true, openReason: 'unit test' },
118
+ attribution: { driverId: DRIVER, surface: 'remote' },
119
+ });
120
+ }
121
+
122
+ const json = (body: unknown): RequestInit => ({
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify(body),
126
+ });
127
+
128
+ /** Read an SSE response until `until` appears in it; returns everything read so far. */
129
+ async function readUntil(
130
+ reader: ReadableStreamDefaultReader<Uint8Array>,
131
+ seen: { text: string },
132
+ until: string,
133
+ ): Promise<string> {
134
+ const decoder = new TextDecoder();
135
+ while (!seen.text.includes(until)) {
136
+ const { value, done } = await reader.read();
137
+ if (done) break;
138
+ seen.text += decoder.decode(value, { stream: true });
139
+ }
140
+ return seen.text;
141
+ }
142
+
143
+ async function openTurn(routes: ReturnType<typeof routesFor>, receivePrompts?: boolean) {
144
+ const res = await routes.request(
145
+ '/submit',
146
+ json({ prompt: 'go', ...(receivePrompts === undefined ? {} : { receivePrompts }) }),
147
+ );
148
+ expect(res.status).toBe(200);
149
+ const reader = res.body!.getReader();
150
+ return { reader, seen: { text: '' } };
151
+ }
152
+
153
+ describe('HTTP prompt routes', () => {
154
+ it('forwards a live permission prompt, lists it for a late client, and settles it on approve', async () => {
155
+ const { session, resolvePermission } = createAskingSession('permission');
156
+ const routes = routesFor(session);
157
+ const { reader, seen } = await openTurn(routes, true);
158
+
159
+ expect(await readUntil(reader, seen, 'event: permission_request')).toContain(
160
+ '"toolName":"Bash"',
161
+ );
162
+
163
+ const late = await routes.request('/prompts');
164
+ expect(await late.json()).toEqual({
165
+ prompts: [
166
+ {
167
+ type: 'permission_request',
168
+ event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } },
169
+ },
170
+ ],
171
+ });
172
+
173
+ const answer = await routes.request('/prompts/p1', json({ result: true }));
174
+ expect(answer.status).toBe(200);
175
+ expect(resolvePermission).toHaveBeenCalledWith('p1', true, DRIVER);
176
+
177
+ const rest = await readUntil(reader, seen, 'event: complete');
178
+ expect(rest).toContain('event: prompt_resolved');
179
+ expect(rest).toContain('"content":"true"');
180
+ expect(await (await routes.request('/prompts')).json()).toEqual({ prompts: [] });
181
+ });
182
+
183
+ it('settles the turn denied when the client answers false', async () => {
184
+ const { session } = createAskingSession('permission');
185
+ const routes = routesFor(session);
186
+ const { reader, seen } = await openTurn(routes, true);
187
+ await readUntil(reader, seen, 'event: permission_request');
188
+
189
+ expect((await routes.request('/prompts/p1', json({ result: false }))).status).toBe(200);
190
+ expect(await readUntil(reader, seen, 'event: complete')).toContain('"content":"false"');
191
+ });
192
+
193
+ it('answers an ask prompt', async () => {
194
+ const { session, resolveAsk } = createAskingSession('ask');
195
+ const routes = routesFor(session);
196
+ const { reader, seen } = await openTurn(routes, true);
197
+ await readUntil(reader, seen, 'event: ask_request');
198
+
199
+ const response = { type: 'answer', values: ['x'] };
200
+ expect((await routes.request('/prompts/a1', json({ response }))).status).toBe(200);
201
+ expect(resolveAsk).toHaveBeenCalledWith('a1', response, DRIVER);
202
+ await readUntil(reader, seen, 'event: complete');
203
+ });
204
+
205
+ it('refuses an unknown id and an already-settled id with 404', async () => {
206
+ const { session, resolvePermission } = createAskingSession('permission');
207
+ const routes = routesFor(session);
208
+ const { reader, seen } = await openTurn(routes, true);
209
+ await readUntil(reader, seen, 'event: permission_request');
210
+
211
+ expect((await routes.request('/prompts/nope', json({ result: true }))).status).toBe(404);
212
+ expect((await routes.request('/prompts/p1', json({ result: true }))).status).toBe(200);
213
+ const again = await routes.request('/prompts/p1', json({ result: false }));
214
+ expect(again.status).toBe(404);
215
+ expect(resolvePermission).toHaveBeenCalledTimes(1);
216
+ await readUntil(reader, seen, 'event: complete');
217
+ });
218
+
219
+ it('refuses a malformed answer and an answer of the wrong kind with 400', async () => {
220
+ const { session, resolvePermission } = createAskingSession('permission');
221
+ const routes = routesFor(session);
222
+ const { reader, seen } = await openTurn(routes, true);
223
+ await readUntil(reader, seen, 'event: permission_request');
224
+
225
+ expect((await routes.request('/prompts/p1', json({ result: 'yes' }))).status).toBe(400);
226
+ expect(
227
+ (await routes.request('/prompts/p1', json({ response: { type: 'cancelled' } }))).status,
228
+ ).toBe(400);
229
+ expect(
230
+ (
231
+ await routes.request('/prompts/p1', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: 'not json',
235
+ })
236
+ ).status,
237
+ ).toBe(400);
238
+ expect(resolvePermission).not.toHaveBeenCalled();
239
+
240
+ await routes.request('/prompts/p1', json({ result: true }));
241
+ await readUntil(reader, seen, 'event: complete');
242
+ });
243
+
244
+ it('leaves prompts unsubscribed without receivePrompts, so the turn fails closed as before', async () => {
245
+ const { session, listenerCount } = createAskingSession('permission');
246
+ const routes = routesFor(session);
247
+ const { reader, seen } = await openTurn(routes);
248
+
249
+ const all = await readUntil(reader, seen, 'event: complete');
250
+ expect(all).not.toContain('permission_request');
251
+ expect(all).toContain('"content":"false"');
252
+ expect(listenerCount('permission_request')).toBe(0);
253
+ expect(await (await routes.request('/prompts')).json()).toEqual({ prompts: [] });
254
+ });
255
+
256
+ it('refuses a receivePrompts that is not a boolean', async () => {
257
+ const { session } = createAskingSession('permission');
258
+ const res = await routesFor(session).request(
259
+ '/submit',
260
+ json({ prompt: 'go', receivePrompts: 'yes' }),
261
+ );
262
+ expect(res.status).toBe(400);
263
+ });
264
+
265
+ it('forgets the session prompts when the prompt-receiving stream ends', async () => {
266
+ const { session } = createAskingSession('permission');
267
+ const routes = routesFor(session);
268
+ const { reader, seen } = await openTurn(routes, true);
269
+ await readUntil(reader, seen, 'event: permission_request');
270
+
271
+ await reader.cancel();
272
+ await vi.waitFor(async () => {
273
+ expect(await (await routes.request('/prompts')).json()).toEqual({ prompts: [] });
274
+ });
275
+ expect((await routes.request('/prompts/p1', json({ result: true }))).status).toBe(404);
276
+ });
277
+
278
+ it('answers 409 when the session did not take the answer', async () => {
279
+ const { session } = createAskingSession('permission', { ignoreAnswers: true });
280
+ const routes = routesFor(session);
281
+ const { reader, seen } = await openTurn(routes, true);
282
+ await readUntil(reader, seen, 'event: permission_request');
283
+
284
+ expect((await routes.request('/prompts/p1', json({ result: true }))).status).toBe(409);
285
+ await reader.cancel();
286
+ });
287
+
288
+ it('stops listing a prompt another surface still holds once this stream ended', async () => {
289
+ const { session } = createAskingSession('permission', { throwAfterAsk: true });
290
+ // Another surface listening keeps the prompt parked after this stream lets go of it.
291
+ session.on('permission_request', () => {});
292
+ const routes = routesFor(session);
293
+ const { reader, seen } = await openTurn(routes, true);
294
+
295
+ await readUntil(reader, seen, 'event: error');
296
+ expect(await (await routes.request('/prompts')).json()).toEqual({ prompts: [] });
297
+ expect((await routes.request('/prompts/p1', json({ result: true }))).status).toBe(404);
298
+ });
299
+ });
@@ -137,6 +137,8 @@ describe('HTTP Transport Routes', () => {
137
137
  isExecuting,
138
138
  getPendingPrompt,
139
139
  getPendingCount,
140
+ resolvePermission,
141
+ resolveAsk,
140
142
  } = full;
141
143
  const port: IHttpTransportSession = {
142
144
  submit,
@@ -156,10 +158,13 @@ describe('HTTP Transport Routes', () => {
156
158
  isExecuting,
157
159
  getPendingPrompt,
158
160
  getPendingCount,
161
+ resolvePermission,
162
+ resolveAsk,
159
163
  };
160
164
  // #3282 §2 added `listModels` to `ISessionCommands` (15 + 1); #3288 §2 added
161
- // `getMessagesDisplay` to `ISessionConversationRead` (16 + 1).
162
- expect(Object.keys(port)).toHaveLength(17);
165
+ // `getMessagesDisplay` to `ISessionConversationRead` (16 + 1); #3449 added the two prompt
166
+ // answers of `ISessionPromptResolution` (17 + 2).
167
+ expect(Object.keys(port)).toHaveLength(19);
163
168
  const transport = createHttpTransport({
164
169
  admission: { open: true, openReason: 'least-authority HTTP port scenario' },
165
170
  });
@@ -366,6 +371,31 @@ describe('HTTP Transport Routes', () => {
366
371
  return res.text();
367
372
  }
368
373
 
374
+ it('POST /submit attributes the turn to the host-assigned driver and surface, never the client', async () => {
375
+ const session = createEmitterSession('complete', { ok: true });
376
+ const app = createAgentRoutes({
377
+ sessionFactory: () => session,
378
+ admission: { open: true, openReason: 'this case is about attribution, not admission' },
379
+ attribution: { driverId: 'remote:http', surface: 'remote' },
380
+ });
381
+ const res = await app.request('/submit', {
382
+ method: 'POST',
383
+ headers: { 'Content-Type': 'application/json' },
384
+ body: JSON.stringify({ prompt: 'hi', driverId: 'owner', surface: 'cli' }),
385
+ });
386
+ await res.text();
387
+ expect(session.submit).toHaveBeenCalledWith('hi', undefined, undefined, {
388
+ driverId: 'remote:http',
389
+ surface: 'remote',
390
+ });
391
+ });
392
+
393
+ it('POST /submit leaves attribution to the session when the host assigns none', async () => {
394
+ const session = createEmitterSession('complete', { ok: true });
395
+ await requestSubmit(session);
396
+ expect(session.submit).toHaveBeenCalledWith('hi');
397
+ });
398
+
369
399
  it('POST /submit receives the complete event without a trailing thinking(false)', async () => {
370
400
  const session = createEmitterSession('complete', { ok: true });
371
401
  const body = await requestSubmit(session);
@@ -4,6 +4,7 @@ import type {
4
4
  ISessionEvents,
5
5
  ISessionExecutionState,
6
6
  ISessionIdentity,
7
+ ISessionPromptResolution,
7
8
  ISessionTurnControl,
8
9
  ISessionTurnSubmission,
9
10
  } from '@robota-sdk/agent-interface-session';
@@ -15,6 +16,7 @@ export interface IHttpTransportSession
15
16
  ISessionEvents,
16
17
  ISessionTurnControl,
17
18
  ISessionIdentity,
19
+ ISessionPromptResolution,
18
20
  ISessionCommands,
19
21
  ISessionConversationRead,
20
22
  ISessionExecutionState {
@@ -11,7 +11,7 @@ import { Hono } from 'hono';
11
11
  import { createAgentRoutes } from './routes.js';
12
12
 
13
13
  import type { IHttpTransportSession } from './http-session.js';
14
- import type { TStreamFailureListener } from './submit-stream.js';
14
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
15
15
  import type {
16
16
  ITransportAdapter,
17
17
  ITransportAdmissionConfig,
@@ -36,6 +36,8 @@ export interface IHttpTransportOptions {
36
36
  * host on this path could make. Same forwarding rule as `admission`, which sat beside it.
37
37
  */
38
38
  onStreamFailure?: TStreamFailureListener;
39
+ /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */
40
+ attribution?: TTurnAttribution;
39
41
  }
40
42
 
41
43
  export interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
@@ -71,6 +73,7 @@ export function createHttpTransport(options?: IHttpTransportOptions): IHttpTrans
71
73
  // resolve a second time.
72
74
  admission,
73
75
  onStreamFailure: options?.onStreamFailure,
76
+ attribution: options?.attribution,
74
77
  });
75
78
  // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes
76
79
  // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createAgentRoutes } from './routes.js';
2
2
  export type { IAgentRoutesOptions, TSessionFactory } from './routes.js';
3
- export type { TStreamFailureListener } from './submit-stream.js';
3
+ export type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
4
4
  export { createHttpTransport } from './http-transport.js';
5
5
  export type { IHttpTransport, IHttpTransportOptions } from './http-transport.js';
6
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
+ }
package/src/routes.ts CHANGED
@@ -13,11 +13,13 @@ import {
13
13
  } from '@robota-sdk/agent-transport/node';
14
14
  import { Hono } from 'hono';
15
15
 
16
+ import { createHttpOpenPrompts } from './open-prompts.js';
17
+ import { answerPromptHandler, listPromptsHandler } from './prompt-routes.js';
16
18
  import { submitHandler } from './submit-route.js';
17
19
  import { createTurnClaims } from './turn-claims.js';
18
20
 
19
21
  import type { TSessionFactory } from './submit-route.js';
20
- import type { TStreamFailureListener } from './submit-stream.js';
22
+ import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
21
23
  import type {
22
24
  ITransportAdmission,
23
25
  ITransportAdmissionConfig,
@@ -44,6 +46,12 @@ export interface IAgentRoutesOptions {
44
46
  * for a host that has its own boundary in front. It just has to be written down.
45
47
  */
46
48
  admission: ITransportAdmissionConfig | ITransportAdmission;
49
+ /**
50
+ * Who `/submit` turns and prompt answers are attributed to — the driver and product surface the
51
+ * HOST assigns, as the WebSocket transport's `driverId`/`surface` do. Never taken from the request
52
+ * body. Absent leaves the session's own default.
53
+ */
54
+ attribution?: TTurnAttribution;
47
55
  }
48
56
 
49
57
  /**
@@ -57,12 +65,13 @@ export interface IAgentRoutesOptions {
57
65
  * ```
58
66
  */
59
67
  export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
60
- const { sessionFactory, onStreamFailure } = options;
68
+ const { sessionFactory, onStreamFailure, attribution } = options;
61
69
  const app = new Hono();
62
70
 
63
71
  // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it
64
72
  // is keyed by the session's declared ID rather than by object identity.
65
73
  const claims = createTurnClaims();
74
+ const openPrompts = createHttpOpenPrompts();
66
75
 
67
76
  // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to
68
77
  // build rather than serving without one. Resolving per request would also mint a new token per
@@ -98,7 +107,19 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
98
107
  });
99
108
 
100
109
  // POST /submit — execute prompt, stream events via SSE
101
- app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure));
110
+ app.post(
111
+ '/submit',
112
+ submitHandler({ sessionFactory, claims, openPrompts, onStreamFailure, attribution }),
113
+ );
114
+
115
+ // GET /prompts — the open permission/ask prompts a prompt-receiving /submit stream forwarded
116
+ app.get('/prompts', listPromptsHandler({ sessionFactory, claims, openPrompts, attribution }));
117
+
118
+ // POST /prompts/:id — answer one of them
119
+ app.post(
120
+ '/prompts/:id',
121
+ answerPromptHandler({ sessionFactory, claims, openPrompts, attribution }),
122
+ );
102
123
 
103
124
  // POST /command — execute system command
104
125
  app.post('/command', async (c) => {