@robota-sdk/agent-transport-http 3.0.0-beta.88 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @robota-sdk/agent-transport-http
2
2
 
3
+ ## 3.0.0-beta.90
4
+ ### Patch Changes
5
+
6
+ - @robota-sdk/agent-interface-session@3.0.0-beta.90
7
+ - @robota-sdk/agent-interface-transport@3.0.0-beta.90
8
+ - @robota-sdk/agent-transport@3.0.0-beta.90
9
+
10
+ ## 3.0.0-beta.89
11
+ ### Minor Changes
12
+
13
+ - ca44ac6: HTTP clients can now receive and answer permission and ask prompts. `POST /submit` with `receivePrompts: true` streams `permission_request`, `ask_request` and `prompt_resolved`. `GET /prompts` lists the prompts still open, so a client that attaches later can see them, and `POST /prompts/:id` answers one; an id that is not open returns 404, and an answer the session did not take returns 409. Clients that omit `receivePrompts` see no change. `IHttpTransportSession` now also requires `resolvePermission` and `resolveAsk`, which `InteractiveSession` already implements. The `--serve --http-port` API gains these routes as well.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [3427887]
18
+ - @robota-sdk/agent-transport@3.0.0-beta.89
19
+ - @robota-sdk/agent-interface-session@3.0.0-beta.89
20
+ - @robota-sdk/agent-interface-transport@3.0.0-beta.89
21
+
3
22
  ## 3.0.0-beta.88
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -72,16 +72,31 @@ check on `getSession().getSessionId()`, not on object identity.
72
72
 
73
73
  ## Routes
74
74
 
75
- | Method | Path | Purpose |
76
- | ------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
- | POST | `/submit` | Body `{ "prompt": string }`. Runs a turn and streams `text_delta`, `tool_start`, `tool_end`, `thinking`, then `complete`, `interrupted` or `error` as SSE. `409` while a turn is already running. |
78
- | POST | `/command` | Body `{ "name": string, "args"?: string }`. Runs a session command on behalf of a remote caller. |
79
- | POST | `/abort` | Aborts the running turn. |
80
- | POST | `/cancel-queue` | Drops the queued prompt. |
81
- | GET | `/messages` | The conversation history. |
82
- | GET | `/context` | The context-window state. |
83
- | GET | `/executing` | `{ "executing": boolean }`, using the same rule `/submit` uses to answer `409`. |
84
- | GET | `/pending` | `{ "pending": … }`, the queued prompt, if any. |
75
+ | Method | Path | Purpose |
76
+ | ------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
+ | POST | `/submit` | Body `{ "prompt": string, "receivePrompts"?: boolean }`. Runs a turn and streams `text_delta`, `tool_start`, `tool_end`, `thinking`, then `complete`, `interrupted` or `error` as SSE. With `receivePrompts: true` the stream also carries `permission_request`, `ask_request` and `prompt_resolved`. `409` while a turn is already running. |
78
+ | POST | `/command` | Body `{ "name": string, "args"?: string }`. Runs a session command on behalf of a remote caller. |
79
+ | POST | `/abort` | Aborts the running turn. |
80
+ | POST | `/cancel-queue` | Drops the queued prompt. |
81
+ | GET | `/messages` | The conversation history. |
82
+ | GET | `/context` | The context-window state. |
83
+ | GET | `/executing` | `{ "executing": boolean }`, using the same rule `/submit` uses to answer `409`. |
84
+ | GET | `/pending` | `{ "pending": … }`, the queued prompt, if any. |
85
+ | GET | `/prompts` | `{ "prompts": [{ "type", "event" }] }`, the prompts a prompt-receiving `/submit` stream forwarded that are still open, oldest first. |
86
+ | POST | `/prompts/:id` | Answers an open prompt: `{ "result": true \| false \| "allow-session" \| "allow-project" }` for a permission, `{ "response": { "type": "answer", "values": string[], "text"?: string } \| { "type": "cancelled" } }` for an ask. `404` when the id is not open, `400` for an answer of the wrong shape, `409` when the session did not take the answer. |
87
+
88
+ ### Permission and ask prompts
89
+
90
+ A turn can stop to ask whether a tool may run (`permission_request`) or to ask the user a question
91
+ (`ask_request`). Only a client that says it can answer receives them: send `receivePrompts: true` with
92
+ `/submit`, and answer each prompt by its `event.id` through `POST /prompts/:id`. `prompt_resolved`
93
+ follows when a prompt is settled, by this client or by another surface on the same session. A
94
+ client that comes later reads the open prompts from `GET /prompts` and answers the same way.
95
+
96
+ Without `receivePrompts`, nothing changes: a turn whose prompt no surface can answer is denied (or the
97
+ question cancelled) at once rather than left waiting. Closing the stream aborts the turn, which settles
98
+ its open prompts the same way. The answer is recorded as the host's `attribution.driverId`, never as
99
+ anything the request claims.
85
100
 
86
101
  Paths are relative to where the routes are mounted (`basePath`, or the prefix you pass to
87
102
  `app.route`).
@@ -97,12 +112,12 @@ Paths are relative to where the routes are mounted (`basePath`, or the prefix yo
97
112
  | `IAgentRoutesOptions` | interface | `{ sessionFactory, admission, onStreamFailure?, attribution? }` |
98
113
  | `TSessionFactory` | type | `(c: Context) => IHttpTransportSession \| Promise<IHttpTransportSession>` |
99
114
  | `IHttpTransportSession` | interface | The session capabilities the routes use; a full interactive session satisfies it |
100
- | `TTurnAttribution` | type | Host-assigned `driverId`/`surface` for `/submit` turns; never read from the request |
115
+ | `TTurnAttribution` | type | Host-assigned `driverId`/`surface` for turns and prompt answers; never read from the request |
101
116
  | `TStreamFailureListener` | type | Receives the details of a stream that fails after headers were sent (the client sees a generic error) |
102
117
 
103
118
  ## Related
104
119
 
105
- - [`@robota-sdk/agent-transport`](../agent-transport/README.md) — the admission helpers these routes use.
120
+ - [`@robota-sdk/agent-transport`](../agent-transport/README.md) — the admission helpers and the prompt-answer decoder these routes use.
106
121
  - [`@robota-sdk/agent-transport-ws`](../agent-transport-ws/README.md) — the WebSocket transport, which
107
122
  carries the full session protocol (this HTTP surface covers a subset).
108
123
  - [docs/SPEC.md](./docs/SPEC.md) — package contract and invariants.
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport/node"),t=require("hono"),n=require("hono/streaming");function r(e,t,n,r){let a=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},o=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return a(`text_delta`,e=>void o(`text_delta`,{delta:e})),a(`tool_start`,e=>void o(`tool_start`,e)),a(`tool_end`,e=>void o(`tool_end`,e)),a(`thinking`,e=>void o(`thinking`,{isThinking:e})),a(`complete`,async e=>{await o(`complete`,e),s()}),a(`interrupted`,async e=>{await o(`interrupted`,e),s()}),a(`error`,async e=>{await o(`error`,{message:e.message}),s()}),t.onAbort(()=>i(e,r,s)),c}function i(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function a(e,t){for(let t of e)try{t()}catch{}t()}function o(e,t,n,i,o){return async c=>{try{let s=[];try{let n=r(e,c,s,i);await(o===void 0?e.submit(t):e.submit(t,void 0,void 0,{...o})),await n}finally{a(s,n)}}catch(e){await s(e instanceof Error?e:Error(String(e)),c,i)}}}async function s(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function c(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function l(e,t,r,i){return async a=>{let s=await e(a),l=await a.req.json();if(!l.prompt||typeof l.prompt!=`string`)return a.json({error:`prompt is required`},400);if(s.whenInitialized!==void 0)try{await s.whenInitialized()}catch{return a.json({error:`the session could not be started`},500)}let u=c(a,s,t);if(u instanceof Response)return u;let{claim:d}=u,f=!1,p=()=>{f||(f=!0,t.release(d))};try{return await(0,n.streamSSE)(a,o(s,l.prompt,p,r,i))}catch(e){throw p(),e}}}function u(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function d(n){let{sessionFactory:r,onStreamFailure:i,attribution:a}=n,o=new t.Hono,s=u(),c=(0,e.resolveAdmission)(n.admission);return o.use(`*`,async(t,n)=>c.token===null||(0,e.credentialMatches)(c.token,(0,e.bearerCredential)(t.req.header(`authorization`)))?n():t.json({error:`unauthorized`},401)),o.post(`/submit`,l(r,s,i,a)),o.post(`/command`,async e=>{let t=await r(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let i=await t.executeCommand(n.name,n.args??``,`remote`);return i?e.json(i):e.json({error:`Unknown command: ${n.name}`},404)}),o.post(`/abort`,async e=>((await r(e)).abort(),e.json({ok:!0}))),o.post(`/cancel-queue`,async e=>((await r(e)).cancelQueue(),e.json({ok:!0}))),o.get(`/messages`,async e=>{let t=await r(e);return e.json(t.getMessages())}),o.get(`/context`,async e=>{let t=await r(e);return e.json(t.getContextState())}),o.get(`/executing`,async e=>{let t=await r(e),n=s.keyFor(t),i=n!==void 0&&s.isHeld(n);return e.json({executing:i||t.isExecuting()})}),o.get(`/pending`,async e=>{let t=await r(e);return e.json({pending:t.getPendingPrompt()})}),o}function f(n){let r=null,i=null,a=(0,e.resolveAdmission)(n?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){r=e},async start(){if(!r)throw o(`not-attached`);if(i)throw o(`already-started`);let e=d({sessionFactory:()=>r,admission:a,onStreamFailure:n?.onStreamFailure,attribution:n?.attribution});i=n?.basePath?new t.Hono().route(n.basePath,e):e},async stop(){i=null,r=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}exports.createAgentRoutes=d,exports.createHttpTransport=f;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport/node"),t=require("hono"),n=require("@robota-sdk/agent-transport"),r=require("hono/streaming");function i(){let e=new Map;return{record(t,n){let r=e.get(t);r===void 0&&(r=new Map,e.set(t,r)),r.set(n.event.id,n)},forget(t,n){e.get(t)?.delete(n)},list:t=>[...e.get(t)?.values()??[]],find:(t,n)=>e.get(t)?.get(n),clear(t){e.delete(t)}}}function a(e){return async t=>{let n=await e.sessionFactory(t),r=e.claims.keyFor(n);return t.json({prompts:r===void 0?[]:e.openPrompts.list(r)})}}function o(e){let{sessionFactory:t,claims:r,openPrompts:i,attribution:a}=e;return async e=>{let o=await t(e),s=e.req.param(`id`)??``,c;try{c=await e.req.json()}catch{return e.json({error:`the body must be a JSON object`},400)}let l=r.keyFor(o),u=l===void 0?void 0:i.find(l,s);if(l===void 0||u===void 0)return e.json({error:`no open prompt with this id`},404);if(u.type===`permission_request`){let t=(0,n.decodeClientMessage)({type:`permission-response`,id:s,result:c?.result});if(!t.ok||t.message.type!==`permission-response`)return e.json({error:`result must be true, false, "allow-session" or "allow-project"`},400);o.resolvePermission(s,t.message.result,a?.driverId)}else{let t=(0,n.decodeClientMessage)({type:`ask-response`,id:s,response:c?.response});if(!t.ok||t.message.type!==`ask-response`)return e.json({error:`response must be { "type": "answer", "values": string[], "text"?: string } or { "type": "cancelled" }`},400);o.resolveAsk(s,t.message.response,a?.driverId)}return i.find(l,s)===void 0?e.json({ok:!0}):e.json({error:`the session did not accept this answer`},409)}}function s(e,t,n,r,i){let a=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},o=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,u=new Promise(e=>{s=e});return a(`text_delta`,e=>void o(`text_delta`,{delta:e})),a(`tool_start`,e=>void o(`tool_start`,e)),a(`tool_end`,e=>void o(`tool_end`,e)),a(`thinking`,e=>void o(`thinking`,{isThinking:e})),i!==void 0&&c(a,o,i),a(`complete`,async e=>{await o(`complete`,e),s()}),a(`interrupted`,async e=>{await o(`interrupted`,e),s()}),a(`error`,async e=>{await o(`error`,{message:e.message}),s()}),t.onAbort(()=>l(e,r,s)),u}function c(e,t,n){e(`permission_request`,e=>{n.record({type:`permission_request`,event:e}),t(`permission_request`,e)}),e(`ask_request`,e=>{n.record({type:`ask_request`,event:e}),t(`ask_request`,e)}),e(`prompt_resolved`,e=>{n.forget(e.id),t(`prompt_resolved`,e)})}function l(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function u(e,t){for(let t of e)try{t()}catch{}t()}function d(e,t,n,r,i,a){return async o=>{try{let c=[];try{let n=s(e,o,c,r,a);await(i===void 0?e.submit(t):e.submit(t,void 0,void 0,{...i})),await n}finally{u(c,n)}}catch(e){await f(e instanceof Error?e:Error(String(e)),o,r)}}}async function f(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function p(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function m(e){let{sessionFactory:t,claims:n,openPrompts:i,onStreamFailure:a,attribution:o}=e;return async e=>{let s=await t(e),c=await e.req.json();if(!c.prompt||typeof c.prompt!=`string`)return e.json({error:`prompt is required`},400);if(c.receivePrompts!==void 0&&typeof c.receivePrompts!=`boolean`)return e.json({error:`receivePrompts must be a boolean`},400);if(s.whenInitialized!==void 0)try{await s.whenInitialized()}catch{return e.json({error:`the session could not be started`},500)}let l=p(e,s,n);if(l instanceof Response)return l;let{claim:u}=l,f=!1,m=()=>{f||(f=!0,i.clear(u),n.release(u))},h=c.receivePrompts===!0?{record:e=>i.record(u,e),forget:e=>i.forget(u,e)}:void 0;try{return await(0,r.streamSSE)(e,d(s,c.prompt,m,a,o,h))}catch(e){throw m(),e}}}function h(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function g(n){let{sessionFactory:r,onStreamFailure:s,attribution:c}=n,l=new t.Hono,u=h(),d=i(),f=(0,e.resolveAdmission)(n.admission);return l.use(`*`,async(t,n)=>f.token===null||(0,e.credentialMatches)(f.token,(0,e.bearerCredential)(t.req.header(`authorization`)))?n():t.json({error:`unauthorized`},401)),l.post(`/submit`,m({sessionFactory:r,claims:u,openPrompts:d,onStreamFailure:s,attribution:c})),l.get(`/prompts`,a({sessionFactory:r,claims:u,openPrompts:d,attribution:c})),l.post(`/prompts/:id`,o({sessionFactory:r,claims:u,openPrompts:d,attribution:c})),l.post(`/command`,async e=>{let t=await r(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let i=await t.executeCommand(n.name,n.args??``,`remote`);return i?e.json(i):e.json({error:`Unknown command: ${n.name}`},404)}),l.post(`/abort`,async e=>((await r(e)).abort(),e.json({ok:!0}))),l.post(`/cancel-queue`,async e=>((await r(e)).cancelQueue(),e.json({ok:!0}))),l.get(`/messages`,async e=>{let t=await r(e);return e.json(t.getMessages())}),l.get(`/context`,async e=>{let t=await r(e);return e.json(t.getContextState())}),l.get(`/executing`,async e=>{let t=await r(e),n=u.keyFor(t),i=n!==void 0&&u.isHeld(n);return e.json({executing:i||t.isExecuting()})}),l.get(`/pending`,async e=>{let t=await r(e);return e.json({pending:t.getPendingPrompt()})}),l}function _(n){let r=null,i=null,a=(0,e.resolveAdmission)(n?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){r=e},async start(){if(!r)throw o(`not-attached`);if(i)throw o(`already-started`);let e=g({sessionFactory:()=>r,admission:a,onStreamFailure:n?.onStreamFailure,attribution:n?.attribution});i=n?.basePath?new t.Hono().route(n.basePath,e):e},async stop(){i=null,r=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}exports.createAgentRoutes=g,exports.createHttpTransport=_;
@@ -1,10 +1,10 @@
1
1
  import { Context, Hono } from "hono";
2
- import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionTurnControl, ISessionTurnSubmission, ISubmitOptions } from "@robota-sdk/agent-interface-session";
2
+ import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionPromptResolution, ISessionTurnControl, ISessionTurnSubmission, ISubmitOptions } from "@robota-sdk/agent-interface-session";
3
3
  import "hono/streaming";
4
4
  import { ITransportAdapter, ITransportAdmission, ITransportAdmissionConfig } from "@robota-sdk/agent-interface-transport";
5
5
  //#region src/http-session.d.ts
6
6
  /** The exact session capabilities consumed by the public HTTP transport. */
7
- interface IHttpTransportSession extends ISessionTurnSubmission, ISessionEvents, ISessionTurnControl, ISessionIdentity, ISessionCommands, ISessionConversationRead, ISessionExecutionState {
7
+ interface IHttpTransportSession extends ISessionTurnSubmission, ISessionEvents, ISessionTurnControl, ISessionIdentity, ISessionPromptResolution, ISessionCommands, ISessionConversationRead, ISessionExecutionState {
8
8
  /**
9
9
  * Resolves once the session can name itself, and rejects if it failed to start. A session that
10
10
  * builds itself in the background (`InteractiveSession`) has no id before then, so `/submit` waits
@@ -77,9 +77,9 @@ interface IAgentRoutesOptions {
77
77
  */
78
78
  admission: ITransportAdmissionConfig | ITransportAdmission;
79
79
  /**
80
- * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the
81
- * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves
82
- * the session's own default.
80
+ * Who `/submit` turns and prompt answers are attributed to — the driver and product surface the
81
+ * HOST assigns, as the WebSocket transport's `driverId`/`surface` do. Never taken from the request
82
+ * body. Absent leaves the session's own default.
83
83
  */
84
84
  attribution?: TTurnAttribution;
85
85
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAWiB,8BAEb,wBACA,gBACA,qBACA,kBACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCCV,0BAA0B,OAAO;;;;;;KAOjC,mBAAmB,KAAK;;;;;;;;;;;;;;;;;;;;;;;KCIxB,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCVpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;EAMvC,cAAc;;;;;;;;;;;;iBAaA,kBAAkB,SAAS,sBAAsB;;;UC5ChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;EAElB,cAAc;;UAGC,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAYiB,8BAEb,wBACA,gBACA,qBACA,kBACA,0BACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCKV,0BAA0B,OAAO;;;;;;KAOjC,mBAAmB,KAAK;;;;;;;;;;;;;;;;;;;;;;;KCDxB,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCTpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;EAMvC,cAAc;;;;;;;;;;;;iBAaA,kBAAkB,SAAS,sBAAsB;;;UC9ChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;EAElB,cAAc;;UAGC,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
@@ -1,10 +1,10 @@
1
1
  import { Context, Hono } from "hono";
2
2
  import "hono/streaming";
3
- import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionTurnControl, ISessionTurnSubmission, ISubmitOptions } from "@robota-sdk/agent-interface-session";
3
+ import { ISessionCommands, ISessionConversationRead, ISessionEvents, ISessionExecutionState, ISessionIdentity, ISessionPromptResolution, ISessionTurnControl, ISessionTurnSubmission, ISubmitOptions } from "@robota-sdk/agent-interface-session";
4
4
  import { ITransportAdapter, ITransportAdmission, ITransportAdmissionConfig } from "@robota-sdk/agent-interface-transport";
5
5
  //#region src/http-session.d.ts
6
6
  /** The exact session capabilities consumed by the public HTTP transport. */
7
- interface IHttpTransportSession extends ISessionTurnSubmission, ISessionEvents, ISessionTurnControl, ISessionIdentity, ISessionCommands, ISessionConversationRead, ISessionExecutionState {
7
+ interface IHttpTransportSession extends ISessionTurnSubmission, ISessionEvents, ISessionTurnControl, ISessionIdentity, ISessionPromptResolution, ISessionCommands, ISessionConversationRead, ISessionExecutionState {
8
8
  /**
9
9
  * Resolves once the session can name itself, and rejects if it failed to start. A session that
10
10
  * builds itself in the background (`InteractiveSession`) has no id before then, so `/submit` waits
@@ -77,9 +77,9 @@ interface IAgentRoutesOptions {
77
77
  */
78
78
  admission: ITransportAdmissionConfig | ITransportAdmission;
79
79
  /**
80
- * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the
81
- * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves
82
- * the session's own default.
80
+ * Who `/submit` turns and prompt answers are attributed to — the driver and product surface the
81
+ * HOST assigns, as the WebSocket transport's `driverId`/`surface` do. Never taken from the request
82
+ * body. Absent leaves the session's own default.
83
83
  */
84
84
  attribution?: TTurnAttribution;
85
85
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAWiB,8BAEb,wBACA,gBACA,qBACA,kBACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCCV,0BAA0B,OAAO;;;;;;KAOjC,mBAAmB,KAAK;;;;;;;;;;;;;;;;;;;;;;;KCIxB,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCVpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;EAMvC,cAAc;;;;;;;;;;;;iBAaA,kBAAkB,SAAS,sBAAsB;;;UC5ChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;EAElB,cAAc;;UAGC,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/http-session.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/routes.ts","../../src/http-transport.ts"],"mappings":";;;;;;UAYiB,8BAEb,wBACA,gBACA,qBACA,kBACA,0BACA,kBACA,0BACA;;;;;;EAMF,oBAAoB;;;;;;;;;;;;;;;;;KCKV,0BAA0B,OAAO;;;;;;KAOjC,mBAAmB,KAAK;;;;;;;;;;;;;;;;;;;;;;;KCDxB,mBACV,GAAG,YACA,wBAAwB,QAAQ;;;UCTpB;;EAEf,gBAAgB;;;;;;EAMhB,kBAAkB;;;;;;;;EAQlB,WAAW,4BAA4B;;;;;;EAMvC,cAAc;;;;;;;;;;;;iBAaA,kBAAkB,SAAS,sBAAsB;;;UC9ChD;;EAEf;;;;;;;;EAQA,YAAY;;;;;;;EAOZ,kBAAkB;;EAElB,cAAc;;UAGC,uBAAuB,kBAAkB;EACxD,UAAU;EACV;;iBAGc,oBAAoB,UAAU,wBAAwB"}
@@ -1,2 +1,2 @@
1
- import{bearerCredential as e,credentialMatches as t,resolveAdmission as n}from"@robota-sdk/agent-transport/node";import{Hono as r}from"hono";import{streamSSE as i}from"hono/streaming";function a(e,t,n,r){let i=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},a=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return i(`text_delta`,e=>void a(`text_delta`,{delta:e})),i(`tool_start`,e=>void a(`tool_start`,e)),i(`tool_end`,e=>void a(`tool_end`,e)),i(`thinking`,e=>void a(`thinking`,{isThinking:e})),i(`complete`,async e=>{await a(`complete`,e),s()}),i(`interrupted`,async e=>{await a(`interrupted`,e),s()}),i(`error`,async e=>{await a(`error`,{message:e.message}),s()}),t.onAbort(()=>o(e,r,s)),c}function o(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function s(e,t){for(let t of e)try{t()}catch{}t()}function c(e,t,n,r,i){return async o=>{try{let c=[];try{let n=a(e,o,c,r);await(i===void 0?e.submit(t):e.submit(t,void 0,void 0,{...i})),await n}finally{s(c,n)}}catch(e){await l(e instanceof Error?e:Error(String(e)),o,r)}}}async function l(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function u(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function d(e,t,n,r){return async a=>{let o=await e(a),s=await a.req.json();if(!s.prompt||typeof s.prompt!=`string`)return a.json({error:`prompt is required`},400);if(o.whenInitialized!==void 0)try{await o.whenInitialized()}catch{return a.json({error:`the session could not be started`},500)}let l=u(a,o,t);if(l instanceof Response)return l;let{claim:d}=l,f=!1,p=()=>{f||(f=!0,t.release(d))};try{return await i(a,c(o,s.prompt,p,n,r))}catch(e){throw p(),e}}}function f(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function p(i){let{sessionFactory:a,onStreamFailure:o,attribution:s}=i,c=new r,l=f(),u=n(i.admission);return c.use(`*`,async(n,r)=>u.token===null||t(u.token,e(n.req.header(`authorization`)))?r():n.json({error:`unauthorized`},401)),c.post(`/submit`,d(a,l,o,s)),c.post(`/command`,async e=>{let t=await a(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let r=await t.executeCommand(n.name,n.args??``,`remote`);return r?e.json(r):e.json({error:`Unknown command: ${n.name}`},404)}),c.post(`/abort`,async e=>((await a(e)).abort(),e.json({ok:!0}))),c.post(`/cancel-queue`,async e=>((await a(e)).cancelQueue(),e.json({ok:!0}))),c.get(`/messages`,async e=>{let t=await a(e);return e.json(t.getMessages())}),c.get(`/context`,async e=>{let t=await a(e);return e.json(t.getContextState())}),c.get(`/executing`,async e=>{let t=await a(e),n=l.keyFor(t),r=n!==void 0&&l.isHeld(n);return e.json({executing:r||t.isExecuting()})}),c.get(`/pending`,async e=>{let t=await a(e);return e.json({pending:t.getPendingPrompt()})}),c}function m(e){let t=null,i=null,a=n(e?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){t=e},async start(){if(!t)throw o(`not-attached`);if(i)throw o(`already-started`);let n=p({sessionFactory:()=>t,admission:a,onStreamFailure:e?.onStreamFailure,attribution:e?.attribution});i=e?.basePath?new r().route(e.basePath,n):n},async stop(){i=null,t=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}export{p as createAgentRoutes,m as createHttpTransport};
1
+ import{bearerCredential as e,credentialMatches as t,resolveAdmission as n}from"@robota-sdk/agent-transport/node";import{Hono as r}from"hono";import{decodeClientMessage as i}from"@robota-sdk/agent-transport";import{streamSSE as a}from"hono/streaming";function o(){let e=new Map;return{record(t,n){let r=e.get(t);r===void 0&&(r=new Map,e.set(t,r)),r.set(n.event.id,n)},forget(t,n){e.get(t)?.delete(n)},list:t=>[...e.get(t)?.values()??[]],find:(t,n)=>e.get(t)?.get(n),clear(t){e.delete(t)}}}function s(e){return async t=>{let n=await e.sessionFactory(t),r=e.claims.keyFor(n);return t.json({prompts:r===void 0?[]:e.openPrompts.list(r)})}}function c(e){let{sessionFactory:t,claims:n,openPrompts:r,attribution:a}=e;return async e=>{let o=await t(e),s=e.req.param(`id`)??``,c;try{c=await e.req.json()}catch{return e.json({error:`the body must be a JSON object`},400)}let l=n.keyFor(o),u=l===void 0?void 0:r.find(l,s);if(l===void 0||u===void 0)return e.json({error:`no open prompt with this id`},404);if(u.type===`permission_request`){let t=i({type:`permission-response`,id:s,result:c?.result});if(!t.ok||t.message.type!==`permission-response`)return e.json({error:`result must be true, false, "allow-session" or "allow-project"`},400);o.resolvePermission(s,t.message.result,a?.driverId)}else{let t=i({type:`ask-response`,id:s,response:c?.response});if(!t.ok||t.message.type!==`ask-response`)return e.json({error:`response must be { "type": "answer", "values": string[], "text"?: string } or { "type": "cancelled" }`},400);o.resolveAsk(s,t.message.response,a?.driverId)}return r.find(l,s)===void 0?e.json({ok:!0}):e.json({error:`the session did not accept this answer`},409)}}function l(e,t,n,r,i){let a=(t,r)=>{e.on(t,r),n.push(()=>e.off(t,r))},o=(e,n)=>t.writeSSE({event:e,data:JSON.stringify(n)}).catch(()=>{}),s,c=new Promise(e=>{s=e});return a(`text_delta`,e=>void o(`text_delta`,{delta:e})),a(`tool_start`,e=>void o(`tool_start`,e)),a(`tool_end`,e=>void o(`tool_end`,e)),a(`thinking`,e=>void o(`thinking`,{isThinking:e})),i!==void 0&&u(a,o,i),a(`complete`,async e=>{await o(`complete`,e),s()}),a(`interrupted`,async e=>{await o(`interrupted`,e),s()}),a(`error`,async e=>{await o(`error`,{message:e.message}),s()}),t.onAbort(()=>d(e,r,s)),c}function u(e,t,n){e(`permission_request`,e=>{n.record({type:`permission_request`,event:e}),t(`permission_request`,e)}),e(`ask_request`,e=>{n.record({type:`ask_request`,event:e}),t(`ask_request`,e)}),e(`prompt_resolved`,e=>{n.forget(e.id),t(`prompt_resolved`,e)})}function d(e,t,n){try{e.abort()}catch(e){try{t?.(e instanceof Error?e:Error(String(e)))}catch{}}finally{n()}}function f(e,t){for(let t of e)try{t()}catch{}t()}function p(e,t,n,r,i,a){return async o=>{try{let s=[];try{let n=l(e,o,s,r,a);await(i===void 0?e.submit(t):e.submit(t,void 0,void 0,{...i})),await n}finally{f(s,n)}}catch(e){await m(e instanceof Error?e:Error(String(e)),o,r)}}}async function m(e,t,n){try{n?.(e)}catch{}await t.writeSSE({event:`error`,data:JSON.stringify({message:`the stream failed on the server after it opened — the turn may not have run`})}).catch(()=>{})}function h(e,t,n){let r=n.keyFor(t);return r===void 0?e.json({error:`concurrent-turn tracking is unavailable for this session, so this request cannot be served safely. Refusing rather than starting a turn that cannot be guaranteed to be yours.`},500):n.isHeld(r)||t.isExecuting()?e.json({error:`session busy — a turn is already in flight`},409):(n.hold(r),{claim:r})}function g(e){let{sessionFactory:t,claims:n,openPrompts:r,onStreamFailure:i,attribution:o}=e;return async e=>{let s=await t(e),c=await e.req.json();if(!c.prompt||typeof c.prompt!=`string`)return e.json({error:`prompt is required`},400);if(c.receivePrompts!==void 0&&typeof c.receivePrompts!=`boolean`)return e.json({error:`receivePrompts must be a boolean`},400);if(s.whenInitialized!==void 0)try{await s.whenInitialized()}catch{return e.json({error:`the session could not be started`},500)}let l=h(e,s,n);if(l instanceof Response)return l;let{claim:u}=l,d=!1,f=()=>{d||(d=!0,r.clear(u),n.release(u))},m=c.receivePrompts===!0?{record:e=>r.record(u,e),forget:e=>r.forget(u,e)}:void 0;try{return await a(e,p(s,c.prompt,f,i,o,m))}catch(e){throw f(),e}}}function _(){let e=new Set;return{keyFor(e){try{let t=e.getSession().getSessionId();return typeof t==`string`&&t!==``?t:void 0}catch{return}},isHeld:t=>e.has(t),hold:t=>void e.add(t),release:t=>void e.delete(t)}}function v(i){let{sessionFactory:a,onStreamFailure:l,attribution:u}=i,d=new r,f=_(),p=o(),m=n(i.admission);return d.use(`*`,async(n,r)=>m.token===null||t(m.token,e(n.req.header(`authorization`)))?r():n.json({error:`unauthorized`},401)),d.post(`/submit`,g({sessionFactory:a,claims:f,openPrompts:p,onStreamFailure:l,attribution:u})),d.get(`/prompts`,s({sessionFactory:a,claims:f,openPrompts:p,attribution:u})),d.post(`/prompts/:id`,c({sessionFactory:a,claims:f,openPrompts:p,attribution:u})),d.post(`/command`,async e=>{let t=await a(e),n=await e.req.json();if(!n.name||typeof n.name!=`string`)return e.json({error:`name is required`},400);let r=await t.executeCommand(n.name,n.args??``,`remote`);return r?e.json(r):e.json({error:`Unknown command: ${n.name}`},404)}),d.post(`/abort`,async e=>((await a(e)).abort(),e.json({ok:!0}))),d.post(`/cancel-queue`,async e=>((await a(e)).cancelQueue(),e.json({ok:!0}))),d.get(`/messages`,async e=>{let t=await a(e);return e.json(t.getMessages())}),d.get(`/context`,async e=>{let t=await a(e);return e.json(t.getContextState())}),d.get(`/executing`,async e=>{let t=await a(e),n=f.keyFor(t),r=n!==void 0&&f.isHeld(n);return e.json({executing:r||t.isExecuting()})}),d.get(`/pending`,async e=>{let t=await a(e);return e.json({pending:t.getPendingPrompt()})}),d}function y(e){let t=null,i=null,a=n(e?.admission),o=e=>Object.assign(Error(`HTTP transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`http`});return{name:`http`,lifecycle:Object.freeze({kind:`service`}),attach(e){t=e},async start(){if(!t)throw o(`not-attached`);if(i)throw o(`already-started`);let n=v({sessionFactory:()=>t,admission:a,onStreamFailure:e?.onStreamFailure,attribution:e?.attribution});i=e?.basePath?new r().route(e.basePath,n):n},async stop(){i=null,t=null},getApp(){if(!i)throw Error(`Transport not started. Call start() first.`);return i},getAdmissionToken(){return a.token}}}export{v as createAgentRoutes,y as createHttpTransport};
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/submit-stream.ts","../../src/submit-route.ts","../../src/turn-claims.ts","../../src/routes.ts","../../src/http-transport.ts"],"sourcesContent":["/**\n * RUNTIME-14 / RUNTIME-38 — relaying ONE turn to an SSE client, and letting go of it afterwards.\n *\n * Split out of `routes.ts` under the file-size ceiling. The route's job is to decide whether this\n * request may start a turn; relaying the turn's events, tearing the subscriptions down, and telling\n * a client whose stream broke after the headers went out is a different job with its own failure\n * modes, and it was four fifths of the handler.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { SSEStreamingApi } from 'hono/streaming';\nimport type { ISubmitOptions } from '@robota-sdk/agent-interface-session';\n\n/**\n * Where the DETAIL of a post-headers stream failure goes — injected, never imported.\n *\n * The first version imported `createLogger` from `agent-core`, and review held it against two\n * authorities at once: `project-structure.md` declares this package contract-pure (transports\n * depend on the contracts and the protocol, nothing else), and its side-concern rule says a logging\n * destination \"must be injected, not imported directly\". The host that mounts this router decides\n * where diagnostics go; a module-level singleton decided it for every host, with no override.\n *\n * Absent, the detail is dropped — by the HOST's explicit default, which is a different thing from\n * this module deleting it: the client still learns the stream failed, and the host was offered the\n * detail at the boundary where it mounts the route.\n */\nexport type TStreamFailureListener = (error: Error) => void;\n\n/**\n * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read\n * from the request, because a client could otherwise claim to be the local operator or another\n * surface in the usage record. Absent means the session's own default.\n */\nexport type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;\n\n/**\n * Wire every relay subscription and the abort path; answer with the promise that settles when the\n * turn is over (or refused one).\n *\n * The subscriptions are wired OUTSIDE the promise executor. Inside it, a throw from `session.on` —\n * a bad handler, an EventEmitter listener cap — is caught by the Promise constructor and becomes an\n * already-rejected `done` instead of propagating. Execution then reaches `await session.submit`, so\n * a REAL TURN is consumed with only some of its listeners attached and nothing to relay it, and the\n * rejection surfaces afterwards at `await done` — by which point the turn is gone.\n */\nfunction wireRelay(\n session: IHttpTransportSession,\n stream: SSEStreamingApi,\n cleanup: Array<() => void>,\n onFailure: TStreamFailureListener | undefined,\n): Promise<void> {\n const subscribe = <T>(event: string, handler: (data: T) => void): void => {\n session.on(event as 'text_delta', handler as () => void);\n cleanup.push(() => session.off(event as 'text_delta', handler as () => void));\n };\n\n // RUNTIME-14: await + catch every SSE write so a write to a client-closed stream is a blessed\n // no-op, not an unhandled rejection (post-headers errors bypass Hono's onError).\n const write = (event: string, data: unknown): Promise<void> =>\n stream.writeSSE({ event, data: JSON.stringify(data) }).catch(() => {\n // allow-fallback: client closed the stream mid-write — nothing to deliver; the finally teardown\n // (RUNTIME-14) removes the listeners, so this write has nothing left to do.\n });\n\n let settle!: () => void;\n const done = new Promise<void>((resolve) => {\n settle = resolve;\n });\n\n subscribe('text_delta', (delta: string) => void write('text_delta', { delta }));\n subscribe('tool_start', (state) => void write('tool_start', state));\n subscribe('tool_end', (state) => void write('tool_end', state));\n subscribe('thinking', (isThinking: boolean) => void write('thinking', { isThinking }));\n\n // Flush the terminal event before resolving, so the resolve → cleanup → stream-close\n // continuation cannot race ahead of the write.\n subscribe('complete', async (result) => {\n await write('complete', result);\n settle();\n });\n subscribe('interrupted', async (result) => {\n await write('interrupted', result);\n settle();\n });\n subscribe('error', async (error: Error) => {\n await write('error', { message: error.message });\n settle();\n });\n\n // RUNTIME-14: on client disconnect, CANCEL the underlying run (not merely stop writing) and\n // unblock `done` so the finally teardown runs — otherwise `done` would never resolve and the\n // listeners leak.\n stream.onAbort(() => onClientAbort(session, onFailure, settle));\n\n return done;\n}\n\n/**\n * The abort path, alone, because every clause of it is load-bearing and review shaped each one.\n *\n * `settle` in a `finally`: an `abort()` that throws used to cost a listener leak, and with the\n * claim registry it costs the SESSION — `done` never resolves, the teardown never runs, the claim\n * is held forever, and every future /submit is 409.\n *\n * The throw is REPORTED, not rethrown: this handler runs inside Hono's abort dispatch, OUTSIDE the\n * relay's try/catch, so a rethrow is an unhandled rejection rather than a reported failure. The\n * detail goes where every other stream failure's detail goes.\n */\nfunction onClientAbort(\n session: IHttpTransportSession,\n onFailure: TStreamFailureListener | undefined,\n settle: () => void,\n): void {\n try {\n session.abort();\n } catch (error) {\n // allow-fallback: an abort that throws is the HOST's news, not the runner's\n try {\n onFailure?.(error instanceof Error ? error : new Error(String(error)));\n } catch {\n // allow-fallback: a listener that throws must not replace the abort path's settle\n // Same rule as reportStreamFailure: the host's listener failing is the host's to see.\n }\n } finally {\n settle();\n }\n}\n\n/**\n * Teardown that cannot take the claim with it.\n *\n * Each entry individually, and review is why: these are `session.off` calls, and one that throws\n * inside a bare loop would end the caller's `finally` before `release()` ran — the same\n * claim-held-forever the abort path closes, reintroduced two lines down. A failed unsubscribe\n * forfeits one listener; taking the release with it forfeits the session.\n */\nfunction teardown(cleanup: Array<() => void>, release: () => void): void {\n for (const fn of cleanup) {\n try {\n fn();\n } catch {\n // allow-fallback: a failed unsubscribe must not take the claim release with it\n // The listener stays attached — the lesser loss, and the one the next turn can survive.\n }\n }\n // RUNTIME-38: released here for the same reason — a turn that throws must not wedge the session\n // it claimed.\n release();\n}\n\n/**\n * The `streamSSE` callback for one submitted prompt.\n *\n * `release` is called in the teardown, ALWAYS — on completion, on error, and on client disconnect.\n * It is passed in rather than taken here because the claim is made before the stream opens: a claim\n * whose release lives only inside the stream is a lock that outlives the request that took it.\n * (This block sat on `wireRelay` for one round after the split stranded it — the stale-doc shape\n * this file keeps writing about, caught doing it.)\n */\nexport function relayTurn(\n session: IHttpTransportSession,\n prompt: string,\n release: () => void,\n onFailure?: TStreamFailureListener,\n attribution?: TTurnAttribution,\n): (stream: SSEStreamingApi) => Promise<void> {\n return async (stream) => {\n // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's\n // `streamSSE` runner calls its `onError` argument and then UNCONDITIONALLY writes\n // `event: error, data: e.message` to the stream — so an escaped exception reaches the client\n // verbatim no matter what the handler withheld. The probe that found it saw TWO error events:\n // this module's generic line, then Hono's raw one. The only place the trust boundary can be\n // held is here, before the throw reaches the runner at all.\n try {\n // The inner `try` opens BEFORE the wiring: a throw from `session.on` inside `wireRelay`\n // leaves the session claimed, and the `finally` is what releases it — opened later, every\n // later request gets 409 forever.\n const cleanup: Array<() => void> = [];\n try {\n const done = wireRelay(session, stream, cleanup, onFailure);\n await (attribution === undefined\n ? session.submit(prompt)\n : session.submit(prompt, undefined, undefined, { ...attribution }));\n await done;\n } finally {\n // RUNTIME-14: teardown ALWAYS runs — on completion, error, OR client disconnect — so the\n // session event listeners can never leak, and the claim always comes back.\n teardown(cleanup, release);\n }\n } catch (error) {\n await reportStreamFailure(\n error instanceof Error ? error : new Error(String(error)),\n stream,\n onFailure,\n );\n }\n };\n}\n\n/**\n * What to tell a client whose stream failed AFTER the response headers were sent.\n *\n * A throw at that point cannot become an error status — the client already has a 200 and an open\n * stream. Without this the throw is an UNHANDLED rejection: it left the process silently in a\n * browser and turned the `quality` job red under vitest, which is how it was found.\n *\n * By the time this runs, `relayTurn`'s teardown has removed the listeners and released the claim.\n * This handler owes only the telling.\n *\n * The CLIENT gets a generic line and the HOST gets the detail, and review moved this three times.\n * The first version sent `error.message` verbatim; the second sent it to a package logger imported\n * from `agent-core`, which the layering rule refuses (see `TStreamFailureListener`). What survives\n * both rounds is the boundary itself: an exception escaping the stream callback is not a message\n * anything composed for a client — it can carry provider internals, paths, or stack fragments,\n * exactly what the 500 branch in `routes.ts` withholds at the same boundary.\n *\n * The session's own `error` EVENT is different and stays verbatim in `relayTurn` above: that is the\n * session's client-facing error channel, worded by the session (`humanizeApiError`) and relayed\n * identically by the WS transport. One is a message meant for the client; this one never was.\n */\nexport async function reportStreamFailure(\n error: Error,\n stream: SSEStreamingApi,\n onFailure?: TStreamFailureListener,\n): Promise<void> {\n try {\n onFailure?.(error);\n } catch {\n // allow-fallback: a listener that throws must not take the client's only notification with it\n // The listener is the host's code; its failure is the host's to see, and the write below is the\n // one thing this function owes the client either way.\n }\n await stream\n .writeSSE({\n event: 'error',\n data: JSON.stringify({\n message: 'the stream failed on the server after it opened — the turn may not have run',\n }),\n })\n .catch(() => {\n // allow-fallback: the stream is already gone, which is the one case where there is nobody\n // left to tell. Rethrowing here would restore the unhandled rejection this exists to remove.\n });\n}\n","/**\n * `POST /submit` — the admission decision and the handler that hands an admitted turn to the relay.\n *\n * Split from `routes.ts` under the file-size ceiling, along the same seam the review rounds carved:\n * the route factory is a LIST of routes, and this one carries the concurrency-admission decision\n * plus the claim lifecycle, which is a different job from mounting endpoints.\n */\n\nimport { streamSSE } from 'hono/streaming';\n\nimport { relayTurn } from './submit-stream.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type { ITurnClaims } from './turn-claims.js';\nimport type { Context } from 'hono';\n\n/**\n * Callback that resolves an HTTP session port from the request context.\n *\n * It need NOT return the same object twice for the same logical session. It briefly did: `/submit`\n * keyed its concurrent-turn claim on object identity, so a factory building a fresh wrapper per\n * call — a proxy, an adapter, a `{...session}` copy — defeated the guard in silence. The first\n * answer was to document that as a requirement callers had to keep, and a requirement neither the\n * type system nor a test can check is not a contract, it is a hope.\n *\n * What the session already promises is enough. `getSession(): { getSessionId(): string }` names the\n * session, the claim is keyed by that name, and the requirement is gone rather than written down.\n *\n * The session must therefore BE nameable. `/submit` refuses with 500 when `getSessionId()` returns\n * nothing, because without an id the concurrent-turn guarantee does not exist and serving anyway\n * would hand back the bug to whoever could least tell. The contract types that method as returning\n * a `string`, so a conformant session never meets it.\n *\n * (Review moved this block here: it sat on the RE-EXPORT in `routes.ts` after the split, where the\n * person editing the definition would never see it.)\n */\nexport type TSessionFactory = (\n c: Context,\n) => IHttpTransportSession | Promise<IHttpTransportSession>;\n\n/**\n * Admit this request to ONE turn on its session, or answer why not.\n *\n * Returns the claim key it HOLDS on success — the caller owes its release — or the refusal\n * Response. A named unit, and review is why: the admission decision (nameable? busy? claim) is what\n * this route decides, independently testable from the stream wiring it hands the turn to.\n */\nfunction admitTurn(\n c: Context,\n session: IHttpTransportSession,\n claims: ITurnClaims,\n): Response | { claim: string } {\n // RUNTIME-38: the session is single-threaded (one turn at a time) and shared across requests, so a\n // concurrent /submit would cross-subscribe to the same emitter and interleave two clients' events.\n //\n // The route CLAIMS the turn rather than asking whether one is running, and that difference is the\n // whole fix. `session.isExecuting()` is set inside `submit()` past an `await ensureInitialized()`\n // — an unconditional suspension point — so two requests both read `false`, both call `submit`, and\n // the loser coalesces into the pending queue while its HTTP response streams back the WINNER'S\n // events. One turn delivered to two clients as if each had asked for it, which is worse than the\n // race the earlier comment described and then wrongly declared absent.\n //\n // A synchronous flag on the route closes it because there is no suspension point between reading\n // and setting it. It is released in the stream's `finally`, so a turn that throws does not leave\n // the route wedged.\n //\n // BOTH are asked because neither subsumes the other. The claim is what this ROUTE knows;\n // `isExecuting()` is what the SESSION knows, including a turn started by another surface — the\n // TUI, a WS client, a previous process — which the claim cannot see.\n // A session that cannot name itself is REFUSED, not served racily. The busy check is a guarantee\n // only while the claim is keyed by a real id: with no id it falls back to `isExecuting()` alone,\n // which the comment above documents as the racy read this route exists to stop relying on.\n // Serving anyway would degrade the fix back into the bug for exactly the callers who could not\n // be told they were affected.\n //\n // `getSession(): { getSessionId(): string }` is required by the contract, so this is unreachable\n // for a conformant session. It is a 500 rather than a 503: the session is not temporarily\n // unavailable, it does not meet the contract this route is built on, which is a defect on the\n // server side of the boundary and not a condition that clears on retry.\n const claim = claims.keyFor(session);\n if (claim === undefined) {\n return c.json(\n {\n // No internal method signature: this route can be mounted outside a trust boundary, and\n // the caller cannot act on `getSession().getSessionId()` anyway — it names a contract\n // they do not implement. The HOST needs the detail and gets it from the comment above\n // this branch; the client needs to know its request was refused and why in its own terms.\n error:\n 'concurrent-turn tracking is unavailable for this session, so this request cannot be ' +\n 'served safely. Refusing rather than starting a turn that cannot be guaranteed to be ' +\n 'yours.',\n },\n 500,\n );\n }\n if (claims.isHeld(claim) || session.isExecuting()) {\n return c.json({ error: 'session busy — a turn is already in flight' }, 409);\n }\n claims.hold(claim);\n return { claim };\n}\n\nexport function submitHandler(\n sessionFactory: TSessionFactory,\n claims: ITurnClaims,\n onStreamFailure?: TStreamFailureListener,\n attribution?: TTurnAttribution,\n) {\n return async (c: Context) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ prompt: string }>();\n\n if (!body.prompt || typeof body.prompt !== 'string') {\n return c.json({ error: 'prompt is required' }, 400);\n }\n\n // A session still building itself cannot name itself yet, and the claim needs its name. The\n // claim itself is still taken synchronously after this, so two requests that both waited here\n // start one turn between them.\n if (session.whenInitialized !== undefined) {\n try {\n await session.whenInitialized();\n } catch {\n // The reason can name host paths or settings; the client only needs to know it failed.\n return c.json({ error: 'the session could not be started' }, 500);\n }\n }\n\n const admitted = admitTurn(c, session, claims);\n if (admitted instanceof Response) {\n return admitted;\n }\n const { claim } = admitted;\n // SINGLE-SHOT, and review traced the double-release it prevents: the relay's teardown releases\n // this claim on the normal path, and if a future Hono ever rejected the outer promise AFTER the\n // callback completed, the catch below would release the SAME KEY a second time — by which point\n // another request may hold it, and the second release would free that request's claim, which is\n // the cross-talk this registry exists to prevent. A release that has happened is not a release\n // that can happen again.\n let released = false;\n const releaseOnce = (): void => {\n if (!released) {\n released = true;\n claims.release(claim);\n }\n };\n\n // The claim is taken OUTSIDE the callback, so its release cannot live only in the callback's\n // `finally` — a claim whose release is not in the same protected region is a lock, not a claim,\n // which this file states one level in. If `streamSSE` throws before it ever runs the callback,\n // this releases and rethrows; the callback's own `finally` covers everything after it starts.\n //\n // REPRODUCED at the module boundary: `routes-streamsse-throw.test.ts` mocks `hono/streaming`\n // to throw before the callback ever runs and asserts the second request is not 409 — a leaked\n // claim is what this catch exists to prevent. Two rounds shipped it as honestly-labelled\n // unverified code (a throw from `session.on` lands INSIDE the callback, where the inner\n // `finally` releases — measured, a case built that way is green with this catch removed), and\n // review supplied the third option: stub the boundary, not Hono's internals.\n try {\n // NO third argument, deliberately: Hono's runner follows any `onError` by writing the raw\n // `e.message` to the stream, so the only leak-proof shape is a callback nothing escapes —\n // `relayTurn` catches its own failures and reports them generically. An onError here would be\n // dead code that turns into a leak the day it stops being dead.\n // `await`, so the catch below covers a REJECTION as well as a synchronous throw. Today's\n // Hono returns the Response synchronously and only the sync path is real — but review is\n // right that a version returning a rejecting promise would sail past a bare `return`, and\n // the cost of closing that class is one keyword.\n return await streamSSE(\n c,\n relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution),\n );\n } catch (error) {\n releaseOnce();\n throw error;\n }\n };\n}\n","/**\n * RUNTIME-38 — who currently holds the one turn a session can run.\n *\n * Split out of `routes.ts`, which had grown past its size ceiling: routing is one job and deciding\n * whether a second turn may start on a session is another, and the second is the only part of that\n * file with state of its own.\n *\n * PER SESSION, not one flag for the router. `sessionFactory` resolves a session per request (the\n * documented multi-tenant shape), so a single flag made one tenant's turn refuse every other\n * tenant's — a busy neighbour is not a reason to refuse you, and that is a worse defect than the\n * race it closed.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\n\nexport interface ITurnClaims {\n /** The key this session is claimed under, or `undefined` when it will not name itself. */\n keyFor(session: IHttpTransportSession): string | undefined;\n isHeld(key: string): boolean;\n hold(key: string): void;\n release(key: string): void;\n}\n\n/**\n * A claim registry keyed by the session's declared ID, not by object identity.\n *\n * Keying on the OBJECT makes identity-stability a requirement of every caller that nothing can\n * check: a `sessionFactory` returning a fresh wrapper per call for the same logical session — a\n * proxy, an adapter, a spread copy — defeats the guard in silence, and every request looks\n * unclaimed. `getSession(): { getSessionId(): string }` already names the session, so asking for the\n * id turns an unenforceable requirement into no requirement at all.\n *\n * A claim is released in the caller's `finally`, so this does not grow with the number of sessions\n * the host has ever served — which is what weak keying would otherwise buy.\n */\nexport function createTurnClaims(): ITurnClaims {\n const held = new Set<string>();\n\n return {\n keyFor(session) {\n try {\n // No `?.`: `getSession(): { getSessionId(): string }` is non-nullable, and optional chaining\n // here would suggest the contract allows an absent session when it does not. A session that\n // breaks the contract anyway is caught below.\n const id = session.getSession().getSessionId();\n return typeof id === 'string' && id !== '' ? id : undefined;\n } catch {\n // allow-fallback: an unnameable session must not be guessed at\n // A session that throws while naming itself has not told us who it is, and guessing would\n // claim the wrong turn. The CALLER refuses on `undefined`, so nothing is swallowed here.\n //\n // `undefined` rather than a placeholder — a shared fallback key would collide every\n // unnameable session into one claim and refuse them all as each other's neighbours. There is\n // no `isExecuting()` fallback on the submit path, and an earlier comment said there was.\n return undefined;\n }\n },\n isHeld: (key) => held.has(key),\n hold: (key) => void held.add(key),\n release: (key) => void held.delete(key),\n };\n}\n","/**\n * HTTP transport adapter — exposes the declared session capabilities over REST API.\n *\n * Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.\n * Exposes the core session methods (a subset; background-task, job-group, and\n * execution-workspace methods are WS-only).\n */\n\nimport {\n bearerCredential,\n credentialMatches,\n resolveAdmission,\n} from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { submitHandler } from './submit-route.js';\nimport { createTurnClaims } from './turn-claims.js';\n\nimport type { TSessionFactory } from './submit-route.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type {\n ITransportAdmission,\n ITransportAdmissionConfig,\n} from '@robota-sdk/agent-interface-transport';\n\n// Re-exported so existing importers keep their path; the definition and its rationale live with\n// the code that enforces them, in `submit-route.ts`.\nexport type { TSessionFactory } from './submit-route.js';\n\nexport interface IAgentRoutesOptions {\n /** Resolve an HTTP session port per request (e.g., by auth token, session ID). */\n sessionFactory: TSessionFactory;\n /**\n * Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —\n * see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,\n * and absent means the host chose to drop it.\n */\n onStreamFailure?: TStreamFailureListener;\n /**\n * SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this\n * option that means \"I did not think about it\", which is the state these routes shipped in.\n *\n * `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer\n * for a host that has its own boundary in front. It just has to be written down.\n */\n admission: ITransportAdmissionConfig | ITransportAdmission;\n /**\n * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the\n * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves\n * the session's own default.\n */\n attribution?: TTurnAttribution;\n}\n\n/**\n * Create a Hono router with all agent HTTP endpoints.\n *\n * Usage:\n * ```typescript\n * const routes = createAgentRoutes({ sessionFactory });\n * app.route('/agent', routes); // mount on existing app\n * export default routes; // or use standalone (CF Workers)\n * ```\n */\nexport function createAgentRoutes(options: IAgentRoutesOptions): Hono {\n const { sessionFactory, onStreamFailure, attribution } = options;\n const app = new Hono();\n\n // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it\n // is keyed by the session's declared ID rather than by object identity.\n const claims = createTurnClaims();\n\n // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to\n // build rather than serving without one. Resolving per request would also mint a new token per\n // request, which no peer could ever present.\n // Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes\n // back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take\n // its resolved admission apart and rebuild a config for this to resolve again — two resolutions\n // of one decision, and a mint on the second if the first had opened without a reason (review).\n //\n // This used to pick between the two with `'token' in options.admission`, and review showed that\n // cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of\n // `{ token: '' }` — documented as \"mint a fresh one\" — was read as pre-resolved and installed the\n // EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The\n // discriminator is gone rather than repaired; there is nothing here left to get wrong.\n const admission = resolveAdmission(options.admission);\n\n /**\n * The trust boundary, installed BEFORE every route rather than checked inside each one.\n *\n * Before this, `POST /submit` reached `session.submit` and `POST /command` reached\n * `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and\n * an unauthenticated request looked exactly like an authorised one in both directions.\n *\n * A missing credential and a wrong one get the same answer, deliberately: telling them apart tells\n * a caller which half they got right.\n */\n app.use('*', async (c, next) => {\n if (admission.token === null) return next();\n if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {\n return next();\n }\n return c.json({ error: 'unauthorized' }, 401);\n });\n\n // POST /submit — execute prompt, stream events via SSE\n app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure, attribution));\n\n // POST /command — execute system command\n app.post('/command', async (c) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ name: string; args?: string }>();\n\n if (!body.name || typeof body.name !== 'string') {\n return c.json({ error: 'name is required' }, 400);\n }\n\n // SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,\n // and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy\n // seam that exists to treat the two differently. Admission decided WHO may reach the session; it\n // does not say who they are. (The MCP adapter had the same defect; this is its sibling.)\n const result = await session.executeCommand(body.name, body.args ?? '', 'remote');\n if (!result) {\n return c.json({ error: `Unknown command: ${body.name}` }, 404);\n }\n return c.json(result);\n });\n\n // POST /abort — abort current execution\n app.post('/abort', async (c) => {\n const session = await sessionFactory(c);\n session.abort();\n return c.json({ ok: true });\n });\n\n // POST /cancel-queue — cancel queued prompt\n app.post('/cancel-queue', async (c) => {\n const session = await sessionFactory(c);\n session.cancelQueue();\n return c.json({ ok: true });\n });\n\n // GET /messages — get message history\n app.get('/messages', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getMessages());\n });\n\n // GET /context — get context window state\n app.get('/context', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getContextState());\n });\n\n // GET /executing — check if currently executing\n //\n // Reports the SAME \"busy\" that `/submit` refuses on: the route's own claim OR the session's\n // `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and\n // still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer\n // than either of them alone.\n app.get('/executing', async (c) => {\n const session = await sessionFactory(c);\n const claim = claims.keyFor(session);\n const claimed = claim !== undefined && claims.isHeld(claim);\n return c.json({ executing: claimed || session.isExecuting() });\n });\n\n // GET /pending — get pending queued prompt\n app.get('/pending', async (c) => {\n const session = await sessionFactory(c);\n return c.json({ pending: session.getPendingPrompt() });\n });\n\n return app;\n}\n","/**\n * ITransportAdapter implementation for HTTP transport.\n *\n * Wraps createAgentRoutes into the unified ITransportAdapter interface\n * while exposing the underlying Hono app via getApp().\n */\n\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { createAgentRoutes } from './routes.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type {\n ITransportAdapter,\n ITransportAdmissionConfig,\n ITransportLifecycleError,\n} from '@robota-sdk/agent-interface-transport';\n\nexport interface IHttpTransportOptions {\n /** Optional: base path prefix for routes. */\n basePath?: string;\n /**\n * SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and\n * `getAdmissionToken()` returns it for the host to hand to its client.\n *\n * This transport previously had no gate at all, so omitting the option used to mean \"anyone may\n * execute anything\". Omission now means the safe thing, and running open takes saying so.\n */\n admission?: ITransportAdmissionConfig;\n /**\n * Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.\n * Review found this seam missing: the option existed on the routes and not on the entry point\n * README and the examples actually use, so \"the host decides the destination\" was a decision no\n * host on this path could make. Same forwarding rule as `admission`, which sat beside it.\n */\n onStreamFailure?: TStreamFailureListener;\n /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */\n attribution?: TTurnAttribution;\n}\n\nexport interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {\n getApp(): Hono;\n getAdmissionToken(): string | null;\n}\n\nexport function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {\n let session: IHttpTransportSession | null = null;\n let app: Hono | null = null;\n // Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and\n // a transport that cannot mint one must fail before anything is served.\n const admission = resolveAdmission(options?.admission);\n const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>\n Object.assign(new Error(`HTTP transport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'http',\n });\n\n return {\n name: 'http',\n lifecycle: Object.freeze({ kind: 'service' }),\n attach(s: IHttpTransportSession) {\n session = s;\n },\n async start() {\n if (!session) throw lifecycleError('not-attached');\n if (app) throw lifecycleError('already-started');\n const routes = createAgentRoutes({\n sessionFactory: () => session!,\n // The decision already made, passed through — not taken apart and rebuilt for the routes to\n // resolve a second time.\n admission,\n onStreamFailure: options?.onStreamFailure,\n attribution: options?.attribution,\n });\n // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes\n // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.\n app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;\n },\n async stop() {\n app = null;\n session = null;\n },\n getApp() {\n if (!app) throw new Error('Transport not started. Call start() first.');\n return app;\n },\n /** The credential this transport requires, or `null` when the host explicitly opened it. */\n getAdmissionToken() {\n return admission.token;\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/open-prompts.ts","../../src/prompt-routes.ts","../../src/submit-stream.ts","../../src/submit-route.ts","../../src/turn-claims.ts","../../src/routes.ts","../../src/http-transport.ts"],"sourcesContent":["/**\n * The permission and ask prompts a `/submit` stream forwarded and that are still open, per session.\n *\n * The session emits a prompt once, when it is asked, so a client that comes later would never see\n * it; `GET /prompts` answers from here, and `POST /prompts/:id` refuses an id that is not here.\n *\n * Keyed by the session's id, the same key as the turn claim, and not by object identity: the session\n * factory may return a fresh wrapper per request, so the `agent-transport` registry (a WeakMap on the\n * session object) would never find the prompt a different request recorded.\n *\n * It learns only through the prompt-receiving stream's own subscriptions and never listens to the\n * session itself. A listener of its own would keep an unattended turn's prompt parked instead of\n * letting the session fail it closed. A session's entries go when that stream's claim is released.\n */\n\nimport type {\n IAskRequestEvent,\n IPermissionRequestEvent,\n} from '@robota-sdk/agent-interface-session';\n\nexport type THttpPromptFrame =\n | { readonly type: 'permission_request'; readonly event: IPermissionRequestEvent }\n | { readonly type: 'ask_request'; readonly event: IAskRequestEvent };\n\nexport interface IHttpOpenPrompts {\n record(sessionKey: string, frame: THttpPromptFrame): void;\n forget(sessionKey: string, id: string): void;\n /** The session's open prompts, oldest first. */\n list(sessionKey: string): readonly THttpPromptFrame[];\n find(sessionKey: string, id: string): THttpPromptFrame | undefined;\n /** The stream that received the session's prompts has ended. */\n clear(sessionKey: string): void;\n}\n\nexport function createHttpOpenPrompts(): IHttpOpenPrompts {\n const bySession = new Map<string, Map<string, THttpPromptFrame>>();\n\n return {\n record(sessionKey, frame) {\n let open = bySession.get(sessionKey);\n if (open === undefined) {\n open = new Map();\n bySession.set(sessionKey, open);\n }\n open.set(frame.event.id, frame);\n },\n forget(sessionKey, id) {\n bySession.get(sessionKey)?.delete(id);\n },\n list: (sessionKey) => [...(bySession.get(sessionKey)?.values() ?? [])],\n find: (sessionKey, id) => bySession.get(sessionKey)?.get(id),\n clear(sessionKey) {\n bySession.delete(sessionKey);\n },\n };\n}\n","/**\n * `GET /prompts` and `POST /prompts/:id` — reading and answering the permission and ask prompts a\n * prompt-receiving `/submit` stream forwarded.\n *\n * Split from `routes.ts` for the same reason `/submit` is: these routes carry a decision of their own\n * (is this id open here, and is this answer the right kind for it), not just a mount.\n */\n\nimport { decodeClientMessage } from '@robota-sdk/agent-transport';\n\nimport type { IHttpOpenPrompts } from './open-prompts.js';\nimport type { TSessionFactory } from './submit-route.js';\nimport type { TTurnAttribution } from './submit-stream.js';\nimport type { ITurnClaims } from './turn-claims.js';\nimport type { Context } from 'hono';\n\nexport interface IPromptRouteDeps {\n readonly sessionFactory: TSessionFactory;\n readonly claims: ITurnClaims;\n readonly openPrompts: IHttpOpenPrompts;\n readonly attribution?: TTurnAttribution;\n}\n\n/** The open prompts of the request's session, oldest first: what a client that came later reads. */\nexport function listPromptsHandler(deps: IPromptRouteDeps) {\n return async (c: Context) => {\n const session = await deps.sessionFactory(c);\n const key = deps.claims.keyFor(session);\n return c.json({ prompts: key === undefined ? [] : deps.openPrompts.list(key) });\n };\n}\n\n/**\n * Answer one open prompt by id.\n *\n * The id must be one this session's prompt-receiving stream forwarded and that is still open. An id\n * that never was and one already settled get the same 404: the client cannot act differently on the\n * two, and the registry forgets a prompt once it settles.\n *\n * The answerer is the HOST's `attribution.driverId`, never the request, for the same reason `/submit`\n * attribution is: a client must not record its answer as another surface's.\n *\n * Nothing awaits between the lookup and the resolve. The session settles synchronously and emits\n * `prompt_resolved`, which the stream forwards and the registry forgets, so a second answer for the\n * same id finds nothing open and is refused rather than reported as accepted.\n *\n * `200` means the prompt settled, not merely that the answer was handed over: the session ignores an\n * answer it does not take (one in an outside party's name, say), and a prompt still open after the\n * resolve is answered `409` so the client does not believe it decided something it did not.\n */\nexport function answerPromptHandler(deps: IPromptRouteDeps) {\n const { sessionFactory, claims, openPrompts, attribution } = deps;\n return async (c: Context) => {\n const session = await sessionFactory(c);\n const id = c.req.param('id') ?? '';\n let body: { result?: unknown; response?: unknown };\n try {\n body = await c.req.json();\n } catch {\n return c.json({ error: 'the body must be a JSON object' }, 400);\n }\n\n const key = claims.keyFor(session);\n const frame = key === undefined ? undefined : openPrompts.find(key, id);\n if (key === undefined || frame === undefined) {\n return c.json({ error: 'no open prompt with this id' }, 404);\n }\n\n if (frame.type === 'permission_request') {\n const decoded = decodeClientMessage({\n type: 'permission-response',\n id,\n result: body?.result,\n });\n if (!decoded.ok || decoded.message.type !== 'permission-response') {\n return c.json(\n { error: 'result must be true, false, \"allow-session\" or \"allow-project\"' },\n 400,\n );\n }\n session.resolvePermission(id, decoded.message.result, attribution?.driverId);\n } else {\n const decoded = decodeClientMessage({ type: 'ask-response', id, response: body?.response });\n if (!decoded.ok || decoded.message.type !== 'ask-response') {\n return c.json(\n {\n error:\n 'response must be { \"type\": \"answer\", \"values\": string[], \"text\"?: string } or ' +\n '{ \"type\": \"cancelled\" }',\n },\n 400,\n );\n }\n session.resolveAsk(id, decoded.message.response, attribution?.driverId);\n }\n if (openPrompts.find(key, id) !== undefined) {\n return c.json({ error: 'the session did not accept this answer' }, 409);\n }\n return c.json({ ok: true });\n };\n}\n","/**\n * RUNTIME-14 / RUNTIME-38 — relaying ONE turn to an SSE client, and letting go of it afterwards.\n *\n * Split out of `routes.ts` under the file-size ceiling. The route's job is to decide whether this\n * request may start a turn; relaying the turn's events, tearing the subscriptions down, and telling\n * a client whose stream broke after the headers went out is a different job with its own failure\n * modes, and it was four fifths of the handler.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { THttpPromptFrame } from './open-prompts.js';\nimport type { SSEStreamingApi } from 'hono/streaming';\nimport type {\n IAskRequestEvent,\n IPermissionRequestEvent,\n IPromptResolvedEvent,\n ISubmitOptions,\n} from '@robota-sdk/agent-interface-session';\n\n/**\n * Where the DETAIL of a post-headers stream failure goes — injected, never imported.\n *\n * The first version imported `createLogger` from `agent-core`, and review held it against two\n * authorities at once: `project-structure.md` declares this package contract-pure (transports\n * depend on the contracts and the protocol, nothing else), and its side-concern rule says a logging\n * destination \"must be injected, not imported directly\". The host that mounts this router decides\n * where diagnostics go; a module-level singleton decided it for every host, with no override.\n *\n * Absent, the detail is dropped — by the HOST's explicit default, which is a different thing from\n * this module deleting it: the client still learns the stream failed, and the host was offered the\n * detail at the boundary where it mounts the route.\n */\nexport type TStreamFailureListener = (error: Error) => void;\n\n/**\n * Who a submitted turn is attributed to, assigned by the HOST that composed the routes — never read\n * from the request, because a client could otherwise claim to be the local operator or another\n * surface in the usage record. Absent means the session's own default.\n */\nexport type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;\n\n/**\n * Where a prompt-receiving stream reports the prompts it forwarded, so a later request can list or\n * answer them. Present only when the client asked to receive prompts.\n */\nexport interface IRelayPrompts {\n record(frame: THttpPromptFrame): void;\n forget(id: string): void;\n}\n\n/**\n * Wire every relay subscription and the abort path; answer with the promise that settles when the\n * turn is over (or refused one).\n *\n * The subscriptions are wired OUTSIDE the promise executor. Inside it, a throw from `session.on` —\n * a bad handler, an EventEmitter listener cap — is caught by the Promise constructor and becomes an\n * already-rejected `done` instead of propagating. Execution then reaches `await session.submit`, so\n * a REAL TURN is consumed with only some of its listeners attached and nothing to relay it, and the\n * rejection surfaces afterwards at `await done` — by which point the turn is gone.\n */\nfunction wireRelay(\n session: IHttpTransportSession,\n stream: SSEStreamingApi,\n cleanup: Array<() => void>,\n onFailure: TStreamFailureListener | undefined,\n prompts: IRelayPrompts | undefined,\n): Promise<void> {\n const subscribe = <T>(event: string, handler: (data: T) => void): void => {\n session.on(event as 'text_delta', handler as () => void);\n cleanup.push(() => session.off(event as 'text_delta', handler as () => void));\n };\n\n // RUNTIME-14: await + catch every SSE write so a write to a client-closed stream is a blessed\n // no-op, not an unhandled rejection (post-headers errors bypass Hono's onError).\n const write = (event: string, data: unknown): Promise<void> =>\n stream.writeSSE({ event, data: JSON.stringify(data) }).catch(() => {\n // allow-fallback: client closed the stream mid-write — nothing to deliver; the finally teardown\n // (RUNTIME-14) removes the listeners, so this write has nothing left to do.\n });\n\n let settle!: () => void;\n const done = new Promise<void>((resolve) => {\n settle = resolve;\n });\n\n subscribe('text_delta', (delta: string) => void write('text_delta', { delta }));\n subscribe('tool_start', (state) => void write('tool_start', state));\n subscribe('tool_end', (state) => void write('tool_end', state));\n subscribe('thinking', (isThinking: boolean) => void write('thinking', { isThinking }));\n if (prompts !== undefined) subscribePrompts(subscribe, write, prompts);\n\n // Flush the terminal event before resolving, so the resolve → cleanup → stream-close\n // continuation cannot race ahead of the write.\n subscribe('complete', async (result) => {\n await write('complete', result);\n settle();\n });\n subscribe('interrupted', async (result) => {\n await write('interrupted', result);\n settle();\n });\n subscribe('error', async (error: Error) => {\n await write('error', { message: error.message });\n settle();\n });\n\n // RUNTIME-14: on client disconnect, CANCEL the underlying run (not merely stop writing) and\n // unblock `done` so the finally teardown runs — otherwise `done` would never resolve and the\n // listeners leak.\n stream.onAbort(() => onClientAbort(session, onFailure, settle));\n\n return done;\n}\n\n/**\n * Forward the turn's permission and ask prompts, and their settlement, to a client that said it can\n * answer them.\n *\n * Only such a client subscribes: the session parks a prompt only while someone listens for it, so a\n * client that cannot answer must not listen, or the turn would wait on a prompt nobody settles\n * instead of failing it closed at once.\n */\nfunction subscribePrompts(\n subscribe: <T>(event: string, handler: (data: T) => void) => void,\n write: (event: string, data: unknown) => Promise<void>,\n prompts: IRelayPrompts,\n): void {\n subscribe('permission_request', (event: IPermissionRequestEvent) => {\n prompts.record({ type: 'permission_request', event });\n void write('permission_request', event);\n });\n subscribe('ask_request', (event: IAskRequestEvent) => {\n prompts.record({ type: 'ask_request', event });\n void write('ask_request', event);\n });\n subscribe('prompt_resolved', (event: IPromptResolvedEvent) => {\n prompts.forget(event.id);\n void write('prompt_resolved', event);\n });\n}\n\n/**\n * The abort path, alone, because every clause of it is load-bearing and review shaped each one.\n *\n * `settle` in a `finally`: an `abort()` that throws used to cost a listener leak, and with the\n * claim registry it costs the SESSION — `done` never resolves, the teardown never runs, the claim\n * is held forever, and every future /submit is 409.\n *\n * The throw is REPORTED, not rethrown: this handler runs inside Hono's abort dispatch, OUTSIDE the\n * relay's try/catch, so a rethrow is an unhandled rejection rather than a reported failure. The\n * detail goes where every other stream failure's detail goes.\n */\nfunction onClientAbort(\n session: IHttpTransportSession,\n onFailure: TStreamFailureListener | undefined,\n settle: () => void,\n): void {\n try {\n session.abort();\n } catch (error) {\n // allow-fallback: an abort that throws is the HOST's news, not the runner's\n try {\n onFailure?.(error instanceof Error ? error : new Error(String(error)));\n } catch {\n // allow-fallback: a listener that throws must not replace the abort path's settle\n // Same rule as reportStreamFailure: the host's listener failing is the host's to see.\n }\n } finally {\n settle();\n }\n}\n\n/**\n * Teardown that cannot take the claim with it.\n *\n * Each entry individually, and review is why: these are `session.off` calls, and one that throws\n * inside a bare loop would end the caller's `finally` before `release()` ran — the same\n * claim-held-forever the abort path closes, reintroduced two lines down. A failed unsubscribe\n * forfeits one listener; taking the release with it forfeits the session.\n */\nfunction teardown(cleanup: Array<() => void>, release: () => void): void {\n for (const fn of cleanup) {\n try {\n fn();\n } catch {\n // allow-fallback: a failed unsubscribe must not take the claim release with it\n // The listener stays attached — the lesser loss, and the one the next turn can survive.\n }\n }\n // RUNTIME-38: released here for the same reason — a turn that throws must not wedge the session\n // it claimed.\n release();\n}\n\n/**\n * The `streamSSE` callback for one submitted prompt.\n *\n * `release` is called in the teardown, ALWAYS — on completion, on error, and on client disconnect.\n * It is passed in rather than taken here because the claim is made before the stream opens: a claim\n * whose release lives only inside the stream is a lock that outlives the request that took it.\n * (This block sat on `wireRelay` for one round after the split stranded it — the stale-doc shape\n * this file keeps writing about, caught doing it.)\n */\nexport function relayTurn(\n session: IHttpTransportSession,\n prompt: string,\n release: () => void,\n onFailure?: TStreamFailureListener,\n attribution?: TTurnAttribution,\n prompts?: IRelayPrompts,\n): (stream: SSEStreamingApi) => Promise<void> {\n return async (stream) => {\n // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's\n // `streamSSE` runner calls its `onError` argument and then UNCONDITIONALLY writes\n // `event: error, data: e.message` to the stream — so an escaped exception reaches the client\n // verbatim no matter what the handler withheld. The probe that found it saw TWO error events:\n // this module's generic line, then Hono's raw one. The only place the trust boundary can be\n // held is here, before the throw reaches the runner at all.\n try {\n // The inner `try` opens BEFORE the wiring: a throw from `session.on` inside `wireRelay`\n // leaves the session claimed, and the `finally` is what releases it — opened later, every\n // later request gets 409 forever.\n const cleanup: Array<() => void> = [];\n try {\n const done = wireRelay(session, stream, cleanup, onFailure, prompts);\n await (attribution === undefined\n ? session.submit(prompt)\n : session.submit(prompt, undefined, undefined, { ...attribution }));\n await done;\n } finally {\n // RUNTIME-14: teardown ALWAYS runs — on completion, error, OR client disconnect — so the\n // session event listeners can never leak, and the claim always comes back.\n teardown(cleanup, release);\n }\n } catch (error) {\n await reportStreamFailure(\n error instanceof Error ? error : new Error(String(error)),\n stream,\n onFailure,\n );\n }\n };\n}\n\n/**\n * What to tell a client whose stream failed AFTER the response headers were sent.\n *\n * A throw at that point cannot become an error status — the client already has a 200 and an open\n * stream. Without this the throw is an UNHANDLED rejection: it left the process silently in a\n * browser and turned the `quality` job red under vitest, which is how it was found.\n *\n * By the time this runs, `relayTurn`'s teardown has removed the listeners and released the claim.\n * This handler owes only the telling.\n *\n * The CLIENT gets a generic line and the HOST gets the detail, and review moved this three times.\n * The first version sent `error.message` verbatim; the second sent it to a package logger imported\n * from `agent-core`, which the layering rule refuses (see `TStreamFailureListener`). What survives\n * both rounds is the boundary itself: an exception escaping the stream callback is not a message\n * anything composed for a client — it can carry provider internals, paths, or stack fragments,\n * exactly what the 500 branch in `routes.ts` withholds at the same boundary.\n *\n * The session's own `error` EVENT is different and stays verbatim in `relayTurn` above: that is the\n * session's client-facing error channel, worded by the session (`humanizeApiError`) and relayed\n * identically by the WS transport. One is a message meant for the client; this one never was.\n */\nexport async function reportStreamFailure(\n error: Error,\n stream: SSEStreamingApi,\n onFailure?: TStreamFailureListener,\n): Promise<void> {\n try {\n onFailure?.(error);\n } catch {\n // allow-fallback: a listener that throws must not take the client's only notification with it\n // The listener is the host's code; its failure is the host's to see, and the write below is the\n // one thing this function owes the client either way.\n }\n await stream\n .writeSSE({\n event: 'error',\n data: JSON.stringify({\n message: 'the stream failed on the server after it opened — the turn may not have run',\n }),\n })\n .catch(() => {\n // allow-fallback: the stream is already gone, which is the one case where there is nobody\n // left to tell. Rethrowing here would restore the unhandled rejection this exists to remove.\n });\n}\n","/**\n * `POST /submit` — the admission decision and the handler that hands an admitted turn to the relay.\n *\n * Split from `routes.ts` under the file-size ceiling, along the same seam the review rounds carved:\n * the route factory is a LIST of routes, and this one carries the concurrency-admission decision\n * plus the claim lifecycle, which is a different job from mounting endpoints.\n */\n\nimport { streamSSE } from 'hono/streaming';\n\nimport { relayTurn } from './submit-stream.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { IHttpOpenPrompts } from './open-prompts.js';\nimport type { IRelayPrompts, TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type { ITurnClaims } from './turn-claims.js';\nimport type { Context } from 'hono';\n\n/**\n * Callback that resolves an HTTP session port from the request context.\n *\n * It need NOT return the same object twice for the same logical session. It briefly did: `/submit`\n * keyed its concurrent-turn claim on object identity, so a factory building a fresh wrapper per\n * call — a proxy, an adapter, a `{...session}` copy — defeated the guard in silence. The first\n * answer was to document that as a requirement callers had to keep, and a requirement neither the\n * type system nor a test can check is not a contract, it is a hope.\n *\n * What the session already promises is enough. `getSession(): { getSessionId(): string }` names the\n * session, the claim is keyed by that name, and the requirement is gone rather than written down.\n *\n * The session must therefore BE nameable. `/submit` refuses with 500 when `getSessionId()` returns\n * nothing, because without an id the concurrent-turn guarantee does not exist and serving anyway\n * would hand back the bug to whoever could least tell. The contract types that method as returning\n * a `string`, so a conformant session never meets it.\n *\n * (Review moved this block here: it sat on the RE-EXPORT in `routes.ts` after the split, where the\n * person editing the definition would never see it.)\n */\nexport type TSessionFactory = (\n c: Context,\n) => IHttpTransportSession | Promise<IHttpTransportSession>;\n\n/**\n * Admit this request to ONE turn on its session, or answer why not.\n *\n * Returns the claim key it HOLDS on success — the caller owes its release — or the refusal\n * Response. A named unit, and review is why: the admission decision (nameable? busy? claim) is what\n * this route decides, independently testable from the stream wiring it hands the turn to.\n */\nfunction admitTurn(\n c: Context,\n session: IHttpTransportSession,\n claims: ITurnClaims,\n): Response | { claim: string } {\n // RUNTIME-38: the session is single-threaded (one turn at a time) and shared across requests, so a\n // concurrent /submit would cross-subscribe to the same emitter and interleave two clients' events.\n //\n // The route CLAIMS the turn rather than asking whether one is running, and that difference is the\n // whole fix. `session.isExecuting()` is set inside `submit()` past an `await ensureInitialized()`\n // — an unconditional suspension point — so two requests both read `false`, both call `submit`, and\n // the loser coalesces into the pending queue while its HTTP response streams back the WINNER'S\n // events. One turn delivered to two clients as if each had asked for it, which is worse than the\n // race the earlier comment described and then wrongly declared absent.\n //\n // A synchronous flag on the route closes it because there is no suspension point between reading\n // and setting it. It is released in the stream's `finally`, so a turn that throws does not leave\n // the route wedged.\n //\n // BOTH are asked because neither subsumes the other. The claim is what this ROUTE knows;\n // `isExecuting()` is what the SESSION knows, including a turn started by another surface — the\n // TUI, a WS client, a previous process — which the claim cannot see.\n // A session that cannot name itself is REFUSED, not served racily. The busy check is a guarantee\n // only while the claim is keyed by a real id: with no id it falls back to `isExecuting()` alone,\n // which the comment above documents as the racy read this route exists to stop relying on.\n // Serving anyway would degrade the fix back into the bug for exactly the callers who could not\n // be told they were affected.\n //\n // `getSession(): { getSessionId(): string }` is required by the contract, so this is unreachable\n // for a conformant session. It is a 500 rather than a 503: the session is not temporarily\n // unavailable, it does not meet the contract this route is built on, which is a defect on the\n // server side of the boundary and not a condition that clears on retry.\n const claim = claims.keyFor(session);\n if (claim === undefined) {\n return c.json(\n {\n // No internal method signature: this route can be mounted outside a trust boundary, and\n // the caller cannot act on `getSession().getSessionId()` anyway — it names a contract\n // they do not implement. The HOST needs the detail and gets it from the comment above\n // this branch; the client needs to know its request was refused and why in its own terms.\n error:\n 'concurrent-turn tracking is unavailable for this session, so this request cannot be ' +\n 'served safely. Refusing rather than starting a turn that cannot be guaranteed to be ' +\n 'yours.',\n },\n 500,\n );\n }\n if (claims.isHeld(claim) || session.isExecuting()) {\n return c.json({ error: 'session busy — a turn is already in flight' }, 409);\n }\n claims.hold(claim);\n return { claim };\n}\n\nexport interface ISubmitRouteDeps {\n readonly sessionFactory: TSessionFactory;\n readonly claims: ITurnClaims;\n readonly openPrompts: IHttpOpenPrompts;\n readonly onStreamFailure?: TStreamFailureListener;\n readonly attribution?: TTurnAttribution;\n}\n\nexport function submitHandler(deps: ISubmitRouteDeps) {\n const { sessionFactory, claims, openPrompts, onStreamFailure, attribution } = deps;\n return async (c: Context) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ prompt: string; receivePrompts?: unknown }>();\n\n if (!body.prompt || typeof body.prompt !== 'string') {\n return c.json({ error: 'prompt is required' }, 400);\n }\n if (body.receivePrompts !== undefined && typeof body.receivePrompts !== 'boolean') {\n return c.json({ error: 'receivePrompts must be a boolean' }, 400);\n }\n\n // A session still building itself cannot name itself yet, and the claim needs its name. The\n // claim itself is still taken synchronously after this, so two requests that both waited here\n // start one turn between them.\n if (session.whenInitialized !== undefined) {\n try {\n await session.whenInitialized();\n } catch {\n // The reason can name host paths or settings; the client only needs to know it failed.\n return c.json({ error: 'the session could not be started' }, 500);\n }\n }\n\n const admitted = admitTurn(c, session, claims);\n if (admitted instanceof Response) {\n return admitted;\n }\n const { claim } = admitted;\n // SINGLE-SHOT, and review traced the double-release it prevents: the relay's teardown releases\n // this claim on the normal path, and if a future Hono ever rejected the outer promise AFTER the\n // callback completed, the catch below would release the SAME KEY a second time — by which point\n // another request may hold it, and the second release would free that request's claim, which is\n // the cross-talk this registry exists to prevent. A release that has happened is not a release\n // that can happen again.\n let released = false;\n const releaseOnce = (): void => {\n if (!released) {\n released = true;\n // The stream that held this session's prompts is gone; what is still open is the session's\n // to settle, and no longer this route's to list or answer.\n openPrompts.clear(claim);\n claims.release(claim);\n }\n };\n const prompts: IRelayPrompts | undefined =\n body.receivePrompts === true\n ? {\n record: (frame) => openPrompts.record(claim, frame),\n forget: (id) => openPrompts.forget(claim, id),\n }\n : undefined;\n\n // The claim is taken OUTSIDE the callback, so its release cannot live only in the callback's\n // `finally` — a claim whose release is not in the same protected region is a lock, not a claim,\n // which this file states one level in. If `streamSSE` throws before it ever runs the callback,\n // this releases and rethrows; the callback's own `finally` covers everything after it starts.\n //\n // REPRODUCED at the module boundary: `routes-streamsse-throw.test.ts` mocks `hono/streaming`\n // to throw before the callback ever runs and asserts the second request is not 409 — a leaked\n // claim is what this catch exists to prevent. Two rounds shipped it as honestly-labelled\n // unverified code (a throw from `session.on` lands INSIDE the callback, where the inner\n // `finally` releases — measured, a case built that way is green with this catch removed), and\n // review supplied the third option: stub the boundary, not Hono's internals.\n try {\n // NO third argument, deliberately: Hono's runner follows any `onError` by writing the raw\n // `e.message` to the stream, so the only leak-proof shape is a callback nothing escapes —\n // `relayTurn` catches its own failures and reports them generically. An onError here would be\n // dead code that turns into a leak the day it stops being dead.\n // `await`, so the catch below covers a REJECTION as well as a synchronous throw. Today's\n // Hono returns the Response synchronously and only the sync path is real — but review is\n // right that a version returning a rejecting promise would sail past a bare `return`, and\n // the cost of closing that class is one keyword.\n return await streamSSE(\n c,\n relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution, prompts),\n );\n } catch (error) {\n releaseOnce();\n throw error;\n }\n };\n}\n","/**\n * RUNTIME-38 — who currently holds the one turn a session can run.\n *\n * Split out of `routes.ts`, which had grown past its size ceiling: routing is one job and deciding\n * whether a second turn may start on a session is another, and the second is the only part of that\n * file with state of its own.\n *\n * PER SESSION, not one flag for the router. `sessionFactory` resolves a session per request (the\n * documented multi-tenant shape), so a single flag made one tenant's turn refuse every other\n * tenant's — a busy neighbour is not a reason to refuse you, and that is a worse defect than the\n * race it closed.\n */\n\nimport type { IHttpTransportSession } from './http-session.js';\n\nexport interface ITurnClaims {\n /** The key this session is claimed under, or `undefined` when it will not name itself. */\n keyFor(session: IHttpTransportSession): string | undefined;\n isHeld(key: string): boolean;\n hold(key: string): void;\n release(key: string): void;\n}\n\n/**\n * A claim registry keyed by the session's declared ID, not by object identity.\n *\n * Keying on the OBJECT makes identity-stability a requirement of every caller that nothing can\n * check: a `sessionFactory` returning a fresh wrapper per call for the same logical session — a\n * proxy, an adapter, a spread copy — defeats the guard in silence, and every request looks\n * unclaimed. `getSession(): { getSessionId(): string }` already names the session, so asking for the\n * id turns an unenforceable requirement into no requirement at all.\n *\n * A claim is released in the caller's `finally`, so this does not grow with the number of sessions\n * the host has ever served — which is what weak keying would otherwise buy.\n */\nexport function createTurnClaims(): ITurnClaims {\n const held = new Set<string>();\n\n return {\n keyFor(session) {\n try {\n // No `?.`: `getSession(): { getSessionId(): string }` is non-nullable, and optional chaining\n // here would suggest the contract allows an absent session when it does not. A session that\n // breaks the contract anyway is caught below.\n const id = session.getSession().getSessionId();\n return typeof id === 'string' && id !== '' ? id : undefined;\n } catch {\n // allow-fallback: an unnameable session must not be guessed at\n // A session that throws while naming itself has not told us who it is, and guessing would\n // claim the wrong turn. The CALLER refuses on `undefined`, so nothing is swallowed here.\n //\n // `undefined` rather than a placeholder — a shared fallback key would collide every\n // unnameable session into one claim and refuse them all as each other's neighbours. There is\n // no `isExecuting()` fallback on the submit path, and an earlier comment said there was.\n return undefined;\n }\n },\n isHeld: (key) => held.has(key),\n hold: (key) => void held.add(key),\n release: (key) => void held.delete(key),\n };\n}\n","/**\n * HTTP transport adapter — exposes the declared session capabilities over REST API.\n *\n * Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.\n * Exposes the core session methods (a subset; background-task, job-group, and\n * execution-workspace methods are WS-only).\n */\n\nimport {\n bearerCredential,\n credentialMatches,\n resolveAdmission,\n} from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { createHttpOpenPrompts } from './open-prompts.js';\nimport { answerPromptHandler, listPromptsHandler } from './prompt-routes.js';\nimport { submitHandler } from './submit-route.js';\nimport { createTurnClaims } from './turn-claims.js';\n\nimport type { TSessionFactory } from './submit-route.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type {\n ITransportAdmission,\n ITransportAdmissionConfig,\n} from '@robota-sdk/agent-interface-transport';\n\n// Re-exported so existing importers keep their path; the definition and its rationale live with\n// the code that enforces them, in `submit-route.ts`.\nexport type { TSessionFactory } from './submit-route.js';\n\nexport interface IAgentRoutesOptions {\n /** Resolve an HTTP session port per request (e.g., by auth token, session ID). */\n sessionFactory: TSessionFactory;\n /**\n * Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —\n * see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,\n * and absent means the host chose to drop it.\n */\n onStreamFailure?: TStreamFailureListener;\n /**\n * SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this\n * option that means \"I did not think about it\", which is the state these routes shipped in.\n *\n * `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer\n * for a host that has its own boundary in front. It just has to be written down.\n */\n admission: ITransportAdmissionConfig | ITransportAdmission;\n /**\n * Who `/submit` turns and prompt answers are attributed to — the driver and product surface the\n * HOST assigns, as the WebSocket transport's `driverId`/`surface` do. Never taken from the request\n * body. Absent leaves the session's own default.\n */\n attribution?: TTurnAttribution;\n}\n\n/**\n * Create a Hono router with all agent HTTP endpoints.\n *\n * Usage:\n * ```typescript\n * const routes = createAgentRoutes({ sessionFactory });\n * app.route('/agent', routes); // mount on existing app\n * export default routes; // or use standalone (CF Workers)\n * ```\n */\nexport function createAgentRoutes(options: IAgentRoutesOptions): Hono {\n const { sessionFactory, onStreamFailure, attribution } = options;\n const app = new Hono();\n\n // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it\n // is keyed by the session's declared ID rather than by object identity.\n const claims = createTurnClaims();\n const openPrompts = createHttpOpenPrompts();\n\n // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to\n // build rather than serving without one. Resolving per request would also mint a new token per\n // request, which no peer could ever present.\n // Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes\n // back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take\n // its resolved admission apart and rebuild a config for this to resolve again — two resolutions\n // of one decision, and a mint on the second if the first had opened without a reason (review).\n //\n // This used to pick between the two with `'token' in options.admission`, and review showed that\n // cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of\n // `{ token: '' }` — documented as \"mint a fresh one\" — was read as pre-resolved and installed the\n // EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The\n // discriminator is gone rather than repaired; there is nothing here left to get wrong.\n const admission = resolveAdmission(options.admission);\n\n /**\n * The trust boundary, installed BEFORE every route rather than checked inside each one.\n *\n * Before this, `POST /submit` reached `session.submit` and `POST /command` reached\n * `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and\n * an unauthenticated request looked exactly like an authorised one in both directions.\n *\n * A missing credential and a wrong one get the same answer, deliberately: telling them apart tells\n * a caller which half they got right.\n */\n app.use('*', async (c, next) => {\n if (admission.token === null) return next();\n if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {\n return next();\n }\n return c.json({ error: 'unauthorized' }, 401);\n });\n\n // POST /submit — execute prompt, stream events via SSE\n app.post(\n '/submit',\n submitHandler({ sessionFactory, claims, openPrompts, onStreamFailure, attribution }),\n );\n\n // GET /prompts — the open permission/ask prompts a prompt-receiving /submit stream forwarded\n app.get('/prompts', listPromptsHandler({ sessionFactory, claims, openPrompts, attribution }));\n\n // POST /prompts/:id — answer one of them\n app.post(\n '/prompts/:id',\n answerPromptHandler({ sessionFactory, claims, openPrompts, attribution }),\n );\n\n // POST /command — execute system command\n app.post('/command', async (c) => {\n const session = await sessionFactory(c);\n const body = await c.req.json<{ name: string; args?: string }>();\n\n if (!body.name || typeof body.name !== 'string') {\n return c.json({ error: 'name is required' }, 400);\n }\n\n // SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,\n // and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy\n // seam that exists to treat the two differently. Admission decided WHO may reach the session; it\n // does not say who they are. (The MCP adapter had the same defect; this is its sibling.)\n const result = await session.executeCommand(body.name, body.args ?? '', 'remote');\n if (!result) {\n return c.json({ error: `Unknown command: ${body.name}` }, 404);\n }\n return c.json(result);\n });\n\n // POST /abort — abort current execution\n app.post('/abort', async (c) => {\n const session = await sessionFactory(c);\n session.abort();\n return c.json({ ok: true });\n });\n\n // POST /cancel-queue — cancel queued prompt\n app.post('/cancel-queue', async (c) => {\n const session = await sessionFactory(c);\n session.cancelQueue();\n return c.json({ ok: true });\n });\n\n // GET /messages — get message history\n app.get('/messages', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getMessages());\n });\n\n // GET /context — get context window state\n app.get('/context', async (c) => {\n const session = await sessionFactory(c);\n return c.json(session.getContextState());\n });\n\n // GET /executing — check if currently executing\n //\n // Reports the SAME \"busy\" that `/submit` refuses on: the route's own claim OR the session's\n // `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and\n // still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer\n // than either of them alone.\n app.get('/executing', async (c) => {\n const session = await sessionFactory(c);\n const claim = claims.keyFor(session);\n const claimed = claim !== undefined && claims.isHeld(claim);\n return c.json({ executing: claimed || session.isExecuting() });\n });\n\n // GET /pending — get pending queued prompt\n app.get('/pending', async (c) => {\n const session = await sessionFactory(c);\n return c.json({ pending: session.getPendingPrompt() });\n });\n\n return app;\n}\n","/**\n * ITransportAdapter implementation for HTTP transport.\n *\n * Wraps createAgentRoutes into the unified ITransportAdapter interface\n * while exposing the underlying Hono app via getApp().\n */\n\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\nimport { Hono } from 'hono';\n\nimport { createAgentRoutes } from './routes.js';\n\nimport type { IHttpTransportSession } from './http-session.js';\nimport type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';\nimport type {\n ITransportAdapter,\n ITransportAdmissionConfig,\n ITransportLifecycleError,\n} from '@robota-sdk/agent-interface-transport';\n\nexport interface IHttpTransportOptions {\n /** Optional: base path prefix for routes. */\n basePath?: string;\n /**\n * SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and\n * `getAdmissionToken()` returns it for the host to hand to its client.\n *\n * This transport previously had no gate at all, so omitting the option used to mean \"anyone may\n * execute anything\". Omission now means the safe thing, and running open takes saying so.\n */\n admission?: ITransportAdmissionConfig;\n /**\n * Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.\n * Review found this seam missing: the option existed on the routes and not on the entry point\n * README and the examples actually use, so \"the host decides the destination\" was a decision no\n * host on this path could make. Same forwarding rule as `admission`, which sat beside it.\n */\n onStreamFailure?: TStreamFailureListener;\n /** Who `/submit` turns are attributed to — forwarded to `createAgentRoutes`, like `admission`. */\n attribution?: TTurnAttribution;\n}\n\nexport interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {\n getApp(): Hono;\n getAdmissionToken(): string | null;\n}\n\nexport function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {\n let session: IHttpTransportSession | null = null;\n let app: Hono | null = null;\n // Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and\n // a transport that cannot mint one must fail before anything is served.\n const admission = resolveAdmission(options?.admission);\n const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>\n Object.assign(new Error(`HTTP transport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'http',\n });\n\n return {\n name: 'http',\n lifecycle: Object.freeze({ kind: 'service' }),\n attach(s: IHttpTransportSession) {\n session = s;\n },\n async start() {\n if (!session) throw lifecycleError('not-attached');\n if (app) throw lifecycleError('already-started');\n const routes = createAgentRoutes({\n sessionFactory: () => session!,\n // The decision already made, passed through — not taken apart and rebuilt for the routes to\n // resolve a second time.\n admission,\n onStreamFailure: options?.onStreamFailure,\n attribution: options?.attribution,\n });\n // TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes\n // always mounted at root. It is honored here — or absent, in which case the routes ARE the app.\n app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;\n },\n async stop() {\n app = null;\n session = null;\n },\n getApp() {\n if (!app) throw new Error('Transport not started. Call start() first.');\n return app;\n },\n /** The credential this transport requires, or `null` when the host explicitly opened it. */\n getAdmissionToken() {\n return admission.token;\n },\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-transport-http",
3
- "version": "3.0.0-beta.88",
3
+ "version": "3.0.0-beta.90",
4
4
  "description": "HTTP (Hono) transport for the Robota SDK",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -41,9 +41,9 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "hono": "^4.12.32",
44
- "@robota-sdk/agent-transport": "3.0.0-beta.88",
45
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.88",
46
- "@robota-sdk/agent-interface-session": "3.0.0-beta.88"
44
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.90",
45
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.90",
46
+ "@robota-sdk/agent-transport": "3.0.0-beta.90"
47
47
  },
48
48
  "devDependencies": {
49
49
  "rimraf": "^5.0.10",
@@ -27,6 +27,8 @@ function createMockSession(): IHttpTransportSession {
27
27
  isExecuting,
28
28
  getPendingPrompt,
29
29
  getPendingCount,
30
+ resolvePermission,
31
+ resolveAsk,
30
32
  } = full;
31
33
  return {
32
34
  submit,
@@ -46,6 +48,8 @@ function createMockSession(): IHttpTransportSession {
46
48
  isExecuting,
47
49
  getPendingPrompt,
48
50
  getPendingCount,
51
+ resolvePermission,
52
+ resolveAsk,
49
53
  };
50
54
  }
51
55
 
@@ -58,8 +62,8 @@ describe('createHttpTransport', () => {
58
62
  >();
59
63
  expectTypeOf<IHttpTransportSession>().not.toHaveProperty('listRuntimeTools');
60
64
  // #3282 §2 added `listModels` to `ISessionCommands` (15 + 1); #3288 §2 added `getMessagesDisplay`
61
- // to `ISessionConversationRead` (16 + 1).
62
- expect(Object.keys(createMockSession())).toHaveLength(17);
65
+ // to `ISessionConversationRead` (16 + 1); #3449 added `ISessionPromptResolution` (17 + 2).
66
+ expect(Object.keys(createMockSession())).toHaveLength(19);
63
67
  });
64
68
 
65
69
  it('returns an adapter with name "http"', () => {
@@ -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
  });
@@ -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 {
@@ -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,6 +13,8 @@ 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
 
@@ -45,9 +47,9 @@ export interface IAgentRoutesOptions {
45
47
  */
46
48
  admission: ITransportAdmissionConfig | ITransportAdmission;
47
49
  /**
48
- * Who `/submit` turns are attributed to — the driver and product surface the HOST assigns, as the
49
- * WebSocket transport's `driverId`/`surface` do. Never taken from the request body. Absent leaves
50
- * the session's own default.
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.
51
53
  */
52
54
  attribution?: TTurnAttribution;
53
55
  }
@@ -69,6 +71,7 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
69
71
  // RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it
70
72
  // is keyed by the session's declared ID rather than by object identity.
71
73
  const claims = createTurnClaims();
74
+ const openPrompts = createHttpOpenPrompts();
72
75
 
73
76
  // SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to
74
77
  // build rather than serving without one. Resolving per request would also mint a new token per
@@ -104,7 +107,19 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
104
107
  });
105
108
 
106
109
  // POST /submit — execute prompt, stream events via SSE
107
- app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure, attribution));
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
+ );
108
123
 
109
124
  // POST /command — execute system command
110
125
  app.post('/command', async (c) => {
@@ -11,7 +11,8 @@ import { streamSSE } from 'hono/streaming';
11
11
  import { relayTurn } from './submit-stream.js';
12
12
 
13
13
  import type { IHttpTransportSession } from './http-session.js';
14
- import type { TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
14
+ import type { IHttpOpenPrompts } from './open-prompts.js';
15
+ import type { IRelayPrompts, TStreamFailureListener, TTurnAttribution } from './submit-stream.js';
15
16
  import type { ITurnClaims } from './turn-claims.js';
16
17
  import type { Context } from 'hono';
17
18
 
@@ -101,19 +102,26 @@ function admitTurn(
101
102
  return { claim };
102
103
  }
103
104
 
104
- export function submitHandler(
105
- sessionFactory: TSessionFactory,
106
- claims: ITurnClaims,
107
- onStreamFailure?: TStreamFailureListener,
108
- attribution?: TTurnAttribution,
109
- ) {
105
+ export interface ISubmitRouteDeps {
106
+ readonly sessionFactory: TSessionFactory;
107
+ readonly claims: ITurnClaims;
108
+ readonly openPrompts: IHttpOpenPrompts;
109
+ readonly onStreamFailure?: TStreamFailureListener;
110
+ readonly attribution?: TTurnAttribution;
111
+ }
112
+
113
+ export function submitHandler(deps: ISubmitRouteDeps) {
114
+ const { sessionFactory, claims, openPrompts, onStreamFailure, attribution } = deps;
110
115
  return async (c: Context) => {
111
116
  const session = await sessionFactory(c);
112
- const body = await c.req.json<{ prompt: string }>();
117
+ const body = await c.req.json<{ prompt: string; receivePrompts?: unknown }>();
113
118
 
114
119
  if (!body.prompt || typeof body.prompt !== 'string') {
115
120
  return c.json({ error: 'prompt is required' }, 400);
116
121
  }
122
+ if (body.receivePrompts !== undefined && typeof body.receivePrompts !== 'boolean') {
123
+ return c.json({ error: 'receivePrompts must be a boolean' }, 400);
124
+ }
117
125
 
118
126
  // A session still building itself cannot name itself yet, and the claim needs its name. The
119
127
  // claim itself is still taken synchronously after this, so two requests that both waited here
@@ -142,9 +150,19 @@ export function submitHandler(
142
150
  const releaseOnce = (): void => {
143
151
  if (!released) {
144
152
  released = true;
153
+ // The stream that held this session's prompts is gone; what is still open is the session's
154
+ // to settle, and no longer this route's to list or answer.
155
+ openPrompts.clear(claim);
145
156
  claims.release(claim);
146
157
  }
147
158
  };
159
+ const prompts: IRelayPrompts | undefined =
160
+ body.receivePrompts === true
161
+ ? {
162
+ record: (frame) => openPrompts.record(claim, frame),
163
+ forget: (id) => openPrompts.forget(claim, id),
164
+ }
165
+ : undefined;
148
166
 
149
167
  // The claim is taken OUTSIDE the callback, so its release cannot live only in the callback's
150
168
  // `finally` — a claim whose release is not in the same protected region is a lock, not a claim,
@@ -168,7 +186,7 @@ export function submitHandler(
168
186
  // the cost of closing that class is one keyword.
169
187
  return await streamSSE(
170
188
  c,
171
- relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution),
189
+ relayTurn(session, body.prompt, releaseOnce, onStreamFailure, attribution, prompts),
172
190
  );
173
191
  } catch (error) {
174
192
  releaseOnce();
@@ -8,8 +8,14 @@
8
8
  */
9
9
 
10
10
  import type { IHttpTransportSession } from './http-session.js';
11
+ import type { THttpPromptFrame } from './open-prompts.js';
11
12
  import type { SSEStreamingApi } from 'hono/streaming';
12
- import type { ISubmitOptions } from '@robota-sdk/agent-interface-session';
13
+ import type {
14
+ IAskRequestEvent,
15
+ IPermissionRequestEvent,
16
+ IPromptResolvedEvent,
17
+ ISubmitOptions,
18
+ } from '@robota-sdk/agent-interface-session';
13
19
 
14
20
  /**
15
21
  * Where the DETAIL of a post-headers stream failure goes — injected, never imported.
@@ -33,6 +39,15 @@ export type TStreamFailureListener = (error: Error) => void;
33
39
  */
34
40
  export type TTurnAttribution = Pick<ISubmitOptions, 'driverId' | 'surface'>;
35
41
 
42
+ /**
43
+ * Where a prompt-receiving stream reports the prompts it forwarded, so a later request can list or
44
+ * answer them. Present only when the client asked to receive prompts.
45
+ */
46
+ export interface IRelayPrompts {
47
+ record(frame: THttpPromptFrame): void;
48
+ forget(id: string): void;
49
+ }
50
+
36
51
  /**
37
52
  * Wire every relay subscription and the abort path; answer with the promise that settles when the
38
53
  * turn is over (or refused one).
@@ -48,6 +63,7 @@ function wireRelay(
48
63
  stream: SSEStreamingApi,
49
64
  cleanup: Array<() => void>,
50
65
  onFailure: TStreamFailureListener | undefined,
66
+ prompts: IRelayPrompts | undefined,
51
67
  ): Promise<void> {
52
68
  const subscribe = <T>(event: string, handler: (data: T) => void): void => {
53
69
  session.on(event as 'text_delta', handler as () => void);
@@ -71,6 +87,7 @@ function wireRelay(
71
87
  subscribe('tool_start', (state) => void write('tool_start', state));
72
88
  subscribe('tool_end', (state) => void write('tool_end', state));
73
89
  subscribe('thinking', (isThinking: boolean) => void write('thinking', { isThinking }));
90
+ if (prompts !== undefined) subscribePrompts(subscribe, write, prompts);
74
91
 
75
92
  // Flush the terminal event before resolving, so the resolve → cleanup → stream-close
76
93
  // continuation cannot race ahead of the write.
@@ -95,6 +112,33 @@ function wireRelay(
95
112
  return done;
96
113
  }
97
114
 
115
+ /**
116
+ * Forward the turn's permission and ask prompts, and their settlement, to a client that said it can
117
+ * answer them.
118
+ *
119
+ * Only such a client subscribes: the session parks a prompt only while someone listens for it, so a
120
+ * client that cannot answer must not listen, or the turn would wait on a prompt nobody settles
121
+ * instead of failing it closed at once.
122
+ */
123
+ function subscribePrompts(
124
+ subscribe: <T>(event: string, handler: (data: T) => void) => void,
125
+ write: (event: string, data: unknown) => Promise<void>,
126
+ prompts: IRelayPrompts,
127
+ ): void {
128
+ subscribe('permission_request', (event: IPermissionRequestEvent) => {
129
+ prompts.record({ type: 'permission_request', event });
130
+ void write('permission_request', event);
131
+ });
132
+ subscribe('ask_request', (event: IAskRequestEvent) => {
133
+ prompts.record({ type: 'ask_request', event });
134
+ void write('ask_request', event);
135
+ });
136
+ subscribe('prompt_resolved', (event: IPromptResolvedEvent) => {
137
+ prompts.forget(event.id);
138
+ void write('prompt_resolved', event);
139
+ });
140
+ }
141
+
98
142
  /**
99
143
  * The abort path, alone, because every clause of it is load-bearing and review shaped each one.
100
144
  *
@@ -163,6 +207,7 @@ export function relayTurn(
163
207
  release: () => void,
164
208
  onFailure?: TStreamFailureListener,
165
209
  attribution?: TTurnAttribution,
210
+ prompts?: IRelayPrompts,
166
211
  ): (stream: SSEStreamingApi) => Promise<void> {
167
212
  return async (stream) => {
168
213
  // NOTHING may escape this callback, and the reason is measured rather than stylistic. Hono's
@@ -177,7 +222,7 @@ export function relayTurn(
177
222
  // later request gets 409 forever.
178
223
  const cleanup: Array<() => void> = [];
179
224
  try {
180
- const done = wireRelay(session, stream, cleanup, onFailure);
225
+ const done = wireRelay(session, stream, cleanup, onFailure, prompts);
181
226
  await (attribution === undefined
182
227
  ? session.submit(prompt)
183
228
  : session.submit(prompt, undefined, undefined, { ...attribution }));