@robota-sdk/agent-transport-http 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +332 -0
- package/LICENSE +661 -0
- package/README.md +123 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +127 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +127 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +78 -0
- package/src/__tests__/admission-loopback-scenario.test.ts +198 -0
- package/src/__tests__/http-base-path.test.ts +33 -0
- package/src/__tests__/http-transport.test.ts +174 -0
- package/src/__tests__/prompt-routes.test.ts +299 -0
- package/src/__tests__/routes-streamsse-throw.test.ts +49 -0
- package/src/__tests__/routes.test.ts +1063 -0
- package/src/__tests__/submit-stream.test.ts +126 -0
- package/src/__tests__/turn-claims.test.ts +102 -0
- package/src/http-session.ts +29 -0
- package/src/http-transport.ts +95 -0
- package/src/index.ts +6 -0
- package/src/open-prompts.ts +56 -0
- package/src/prompt-routes.ts +101 -0
- package/src/routes.ts +190 -0
- package/src/submit-route.ts +196 -0
- package/src/submit-stream.ts +289 -0
- package/src/turn-claims.ts +62 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catch around `streamSSE` itself — reached by making the boundary throw.
|
|
3
|
+
*
|
|
4
|
+
* The branch guards the claim when Hono throws BEFORE ever running the relay callback, and for two
|
|
5
|
+
* rounds it shipped as honestly-labelled unverified code: a throw from inside the callback lands in
|
|
6
|
+
* the inner `finally` (measured — a case built that way was green with this catch removed), and the
|
|
7
|
+
* real `streamSSE` has no synchronous throw path today. Review asked for the third option: a
|
|
8
|
+
* synthetic stub AT the module boundary. Mocking `hono/streaming` tests OUR handler's ordering —
|
|
9
|
+
* hold, then release on the boundary's throw — without asserting anything about Hono's internals,
|
|
10
|
+
* which is exactly what the branch is written against.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
16
|
+
|
|
17
|
+
vi.mock('hono/streaming', () => ({
|
|
18
|
+
streamSSE: () => {
|
|
19
|
+
throw new Error('the boundary threw before the callback ran');
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { createAgentRoutes } = await import('../routes.js');
|
|
24
|
+
|
|
25
|
+
describe('a streamSSE that throws synchronously', () => {
|
|
26
|
+
it('does not leave the session claimed forever', async () => {
|
|
27
|
+
const session = createTestInteractiveSession();
|
|
28
|
+
const app = createAgentRoutes({
|
|
29
|
+
sessionFactory: () => session,
|
|
30
|
+
// SEC-008: admission is not under test here, and the reason it is open says so.
|
|
31
|
+
admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
|
|
32
|
+
});
|
|
33
|
+
const post = () =>
|
|
34
|
+
app.request('/submit', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
body: JSON.stringify({ prompt: 'x' }),
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const first = await post();
|
|
41
|
+
const second = await post();
|
|
42
|
+
|
|
43
|
+
// Both requests fail on the thrown boundary — a 500, not this route's business. What IS this
|
|
44
|
+
// route's business: the second must not be 409. A leaked claim turns every later request into
|
|
45
|
+
// "session busy" forever, and that difference is the whole reason the catch exists.
|
|
46
|
+
expect(first.status).toBe(500);
|
|
47
|
+
expect(second.status, 'the first request took the claim with it').not.toBe(409);
|
|
48
|
+
});
|
|
49
|
+
});
|