@pikku/core 0.12.100 → 0.12.102
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 +51 -0
- package/dist/services/email-service.d.ts +13 -0
- package/dist/services/http-personas.d.ts +36 -6
- package/dist/services/http-personas.js +128 -9
- package/dist/services/index.d.ts +1 -1
- package/dist/services/local-email-service.js +9 -0
- package/dist/services/persona-sign-in.d.ts +0 -10
- package/dist/services/persona-sign-in.js +11 -9
- package/dist/wirings/cli/cli-runner.js +2 -11
- package/dist/wirings/cli/format-cli-error.d.ts +24 -0
- package/dist/wirings/cli/format-cli-error.js +68 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +0 -1
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +0 -4
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +5 -0
- package/src/no-root-barrel.test.ts +9 -1
- package/src/services/email-service.test.ts +100 -0
- package/src/services/email-service.ts +14 -0
- package/src/services/http-personas-converse.test.ts +29 -20
- package/src/services/http-personas.test.ts +66 -0
- package/src/services/http-personas.ts +138 -9
- package/src/services/index.ts +1 -0
- package/src/services/local-email-service.test.ts +74 -0
- package/src/services/local-email-service.ts +9 -0
- package/src/services/persona-sign-in.test.ts +22 -28
- package/src/services/persona-sign-in.ts +11 -19
- package/src/wirings/cli/cli-runner.test.ts +104 -0
- package/src/wirings/cli/cli-runner.ts +2 -11
- package/src/wirings/cli/format-cli-error.test.ts +91 -0
- package/src/wirings/cli/format-cli-error.ts +96 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +0 -5
- package/tsconfig.tsbuildinfo +1 -1
- package/tsconfig.type-tests.json +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
EmailAttachment,
|
|
6
|
+
EmailService,
|
|
7
|
+
SendEmailInput,
|
|
8
|
+
SendEmailResult,
|
|
9
|
+
SendHTMLEmailInput,
|
|
10
|
+
SendTemplateEmailInput,
|
|
11
|
+
SendTextEmailInput,
|
|
12
|
+
} from './email-service.js'
|
|
13
|
+
|
|
14
|
+
const receipt: EmailAttachment = {
|
|
15
|
+
filename: 'receipt.pdf',
|
|
16
|
+
content: new Uint8Array([1, 2, 3]),
|
|
17
|
+
contentType: 'application/pdf',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const logo: EmailAttachment = {
|
|
21
|
+
filename: 'logo.png',
|
|
22
|
+
content: 'aGVsbG8=',
|
|
23
|
+
contentType: 'image/png',
|
|
24
|
+
contentId: 'logo',
|
|
25
|
+
disposition: 'inline',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('a text email carries attachments', () => {
|
|
29
|
+
const input: SendTextEmailInput = {
|
|
30
|
+
to: 'user@example.com',
|
|
31
|
+
text: 'Your receipt is attached',
|
|
32
|
+
attachments: [receipt],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(input.attachments, [receipt])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('an html email carries attachments', () => {
|
|
39
|
+
const input: SendHTMLEmailInput = {
|
|
40
|
+
to: 'user@example.com',
|
|
41
|
+
html: '<img src="cid:logo" />',
|
|
42
|
+
attachments: [logo],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
assert.equal(input.attachments?.[0]?.disposition, 'inline')
|
|
46
|
+
assert.equal(input.attachments?.[0]?.contentId, 'logo')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a template email carries attachments', () => {
|
|
50
|
+
const input: SendTemplateEmailInput = {
|
|
51
|
+
to: 'user@example.com',
|
|
52
|
+
template: { name: 'receipt', locale: 'en', data: { total: 10 } },
|
|
53
|
+
attachments: [receipt, logo],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assert.equal(input.attachments?.length, 2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('attachments are optional on every variant', () => {
|
|
60
|
+
const inputs: SendEmailInput[] = [
|
|
61
|
+
{ to: 'user@example.com', text: 'hello' },
|
|
62
|
+
{ to: 'user@example.com', html: '<p>hello</p>' },
|
|
63
|
+
{ to: 'user@example.com', template: { name: 'welcome' } },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
for (const input of inputs) {
|
|
67
|
+
assert.equal(input.attachments, undefined)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a wrapping service forwards attachments to its delegate', async () => {
|
|
72
|
+
const sent: SendEmailInput[] = []
|
|
73
|
+
const delegate: EmailService = {
|
|
74
|
+
async send(input): Promise<SendEmailResult> {
|
|
75
|
+
sent.push(input as SendEmailInput)
|
|
76
|
+
return { messageId: 'delegated' }
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const wrapper: EmailService = {
|
|
81
|
+
async send(input): Promise<SendEmailResult> {
|
|
82
|
+
const { template, ...rest } = input as SendTemplateEmailInput
|
|
83
|
+
return delegate.send({
|
|
84
|
+
...rest,
|
|
85
|
+
subject: `rendered:${template.name}`,
|
|
86
|
+
html: '<p>rendered</p>',
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result = await wrapper.send({
|
|
92
|
+
to: 'user@example.com',
|
|
93
|
+
template: { name: 'receipt' },
|
|
94
|
+
attachments: [receipt],
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
assert.equal(result.messageId, 'delegated')
|
|
98
|
+
assert.equal(sent.length, 1)
|
|
99
|
+
assert.deepEqual(sent[0]!.attachments, [receipt])
|
|
100
|
+
})
|
|
@@ -6,6 +6,19 @@ export interface EmailTemplateReference {
|
|
|
6
6
|
data?: Record<string, unknown>
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export interface EmailAttachment {
|
|
10
|
+
filename: string
|
|
11
|
+
/**
|
|
12
|
+
* Raw bytes, or the content already base64-encoded. A `string` is always
|
|
13
|
+
* read as base64 — never as a plain-text body — so text attachments must be
|
|
14
|
+
* encoded by the caller.
|
|
15
|
+
*/
|
|
16
|
+
content: Uint8Array | string
|
|
17
|
+
contentType?: string
|
|
18
|
+
contentId?: string
|
|
19
|
+
disposition?: 'attachment' | 'inline'
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
export interface BaseSendEmailInput {
|
|
10
23
|
to: string | string[]
|
|
11
24
|
from?: string
|
|
@@ -14,6 +27,7 @@ export interface BaseSendEmailInput {
|
|
|
14
27
|
replyTo?: string | string[]
|
|
15
28
|
headers?: Record<string, string>
|
|
16
29
|
subject?: string
|
|
30
|
+
attachments?: EmailAttachment[]
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
export interface SendTextEmailInput extends BaseSendEmailInput {
|
|
@@ -57,28 +57,37 @@ const startAgentTarget = async () => {
|
|
|
57
57
|
json({ runId: body.runId, text: 'Created it.', status: 'completed' })
|
|
58
58
|
return
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// The SSE route, because that is the one a persona's turn goes to — the
|
|
61
|
+
// plain route buffers the whole run and dies on undici's headers timeout.
|
|
62
|
+
if (req.url === '/api/rpc/agent/todoBot/stream') {
|
|
61
63
|
agentRuns++
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
64
|
+
const runId = `run-${agentRuns}`
|
|
65
|
+
const events: unknown[] =
|
|
66
|
+
agentRuns === 1
|
|
67
|
+
? [
|
|
68
|
+
{ type: 'RUN_STARTED', runId },
|
|
69
|
+
{ type: 'TEXT_MESSAGE_CONTENT', delta: 'Let me ' },
|
|
70
|
+
{ type: 'TEXT_MESSAGE_CONTENT', delta: 'do that.' },
|
|
71
|
+
{
|
|
72
|
+
type: 'approval-request',
|
|
73
|
+
runId,
|
|
74
|
+
toolCallId: 'tc1',
|
|
75
|
+
toolName: 'createTodo',
|
|
76
|
+
args: { title: 'x' },
|
|
77
|
+
},
|
|
78
|
+
{ type: 'done' },
|
|
79
|
+
]
|
|
80
|
+
: [
|
|
81
|
+
{ type: 'RUN_STARTED', runId },
|
|
82
|
+
{ type: 'TEXT_MESSAGE_CONTENT', delta: 'All set.' },
|
|
83
|
+
{ type: 'RUN_FINISHED', runId },
|
|
84
|
+
{ type: 'done' },
|
|
85
|
+
]
|
|
86
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
|
76
89
|
}
|
|
77
|
-
|
|
78
|
-
runId: `run-${agentRuns}`,
|
|
79
|
-
text: 'All set.',
|
|
80
|
-
status: 'completed',
|
|
81
|
-
})
|
|
90
|
+
res.end()
|
|
82
91
|
return
|
|
83
92
|
}
|
|
84
93
|
res.writeHead(404).end()
|
|
@@ -66,6 +66,16 @@ const startTarget = async () => {
|
|
|
66
66
|
return
|
|
67
67
|
}
|
|
68
68
|
const rpcName = req.url.slice('/api/rpc/'.length)
|
|
69
|
+
if (rpcName === 'getMyScopes') {
|
|
70
|
+
res
|
|
71
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
72
|
+
.end(JSON.stringify({ scopes: ['stays:read'], roles: ['guest'] }))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (rpcName === 'noSuchRpc') {
|
|
76
|
+
res.writeHead(404).end()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
69
79
|
if (rpcName === 'html-error') {
|
|
70
80
|
res
|
|
71
81
|
.writeHead(500, { 'content-type': 'text/html' })
|
|
@@ -261,12 +271,68 @@ describe('HttpPersona', async () => {
|
|
|
261
271
|
// mount in `apiUrl`, so it moves `signInPath` instead. The session read has
|
|
262
272
|
// to follow it: a 404 there reads as 'this stage does not report roles' and
|
|
263
273
|
// silently turns the role check off.
|
|
274
|
+
//
|
|
275
|
+
// `rolesRpc: false` because this is about the better-auth path specifically —
|
|
276
|
+
// leaving the RPC in would answer first and the mount would go untested.
|
|
264
277
|
test('reads the session from the mount the sign-in path names', async () => {
|
|
265
278
|
const actors = createHttpPersonas({
|
|
266
279
|
apiUrl: target.origin,
|
|
267
280
|
secret: ROOT,
|
|
268
281
|
signInPath: '/api/auth/sign-in/actor',
|
|
269
282
|
rpcPath: '/api/rpc',
|
|
283
|
+
rolesRpc: false,
|
|
284
|
+
personas: {
|
|
285
|
+
manager: {
|
|
286
|
+
id: 'manager',
|
|
287
|
+
name: 'Manager',
|
|
288
|
+
email: 'manager@personas.invalid',
|
|
289
|
+
roles: ['admin'],
|
|
290
|
+
goals: [],
|
|
291
|
+
tags: [],
|
|
292
|
+
runnable: true,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(await actors.manager!.sessionRoles(), ['admin', 'support'])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Scopes are the model in an app that authorizes on them; better-auth's
|
|
301
|
+
// `user.role` is a projection kept in step for its own admin endpoints, and
|
|
302
|
+
// is absent entirely from an app that declares no such field. Reading the
|
|
303
|
+
// projection is how a guest holding exactly what it should got refused for
|
|
304
|
+
// "roles drifted".
|
|
305
|
+
test('reads roles from the app RPC in preference to better-auth', async () => {
|
|
306
|
+
const actors = createHttpPersonas({
|
|
307
|
+
apiUrl: target.origin,
|
|
308
|
+
secret: ROOT,
|
|
309
|
+
signInPath: '/api/auth/sign-in/actor',
|
|
310
|
+
rpcPath: '/api/rpc',
|
|
311
|
+
personas: {
|
|
312
|
+
manager: {
|
|
313
|
+
id: 'manager',
|
|
314
|
+
name: 'Manager',
|
|
315
|
+
email: 'manager@personas.invalid',
|
|
316
|
+
roles: ['guest'],
|
|
317
|
+
goals: [],
|
|
318
|
+
tags: [],
|
|
319
|
+
runnable: true,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
assert.deepEqual(await actors.manager!.sessionRoles(), ['guest'])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// An app without the RPC is not an app reporting "no roles" — going quiet
|
|
328
|
+
// there would turn every persona into a mismatch against an empty list.
|
|
329
|
+
test('falls back to better-auth when the roles RPC is absent', async () => {
|
|
330
|
+
const actors = createHttpPersonas({
|
|
331
|
+
apiUrl: target.origin,
|
|
332
|
+
secret: ROOT,
|
|
333
|
+
signInPath: '/api/auth/sign-in/actor',
|
|
334
|
+
rpcPath: '/api/rpc',
|
|
335
|
+
rolesRpc: 'noSuchRpc',
|
|
270
336
|
personas: {
|
|
271
337
|
manager: {
|
|
272
338
|
id: 'manager',
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ConverseOptions,
|
|
11
11
|
ActorFlowVerdict,
|
|
12
12
|
TargetAgentReply,
|
|
13
|
+
TargetPendingApproval,
|
|
13
14
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
14
15
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js'
|
|
15
16
|
import {
|
|
@@ -75,6 +76,17 @@ export interface HttpPersonasConfig {
|
|
|
75
76
|
sessionPath?: string
|
|
76
77
|
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
77
78
|
rpcPath?: string
|
|
79
|
+
/**
|
|
80
|
+
* Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
|
|
81
|
+
* Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
|
|
82
|
+
*
|
|
83
|
+
* Asked before better-auth's `user.role`, because in an app that authorizes
|
|
84
|
+
* on scopes that column is a projection rather than the model — it exists so
|
|
85
|
+
* better-auth's own admin endpoints have something to read, is written by
|
|
86
|
+
* whatever keeps it in step, and is absent entirely from an app that declares
|
|
87
|
+
* no such field. A persona verified against it is verified against a copy.
|
|
88
|
+
*/
|
|
89
|
+
rolesRpc?: string | false
|
|
78
90
|
/**
|
|
79
91
|
* Default model a persona thinks with when `converse(...)` is called without
|
|
80
92
|
* an explicit `model`. Its own turns/approvals/evaluation run in-process via
|
|
@@ -207,16 +219,46 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
207
219
|
/**
|
|
208
220
|
* The roles the stage says this session holds.
|
|
209
221
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* comma-separated list. A target that answers
|
|
213
|
-
*
|
|
214
|
-
*
|
|
222
|
+
* {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
|
|
223
|
+
* `get-session` — where its admin plugin puts `role` on the user, as a
|
|
224
|
+
* comma-separated list. A target that answers neither returns `null` rather
|
|
225
|
+
* than an empty list, because "this stage does not report roles" and "this
|
|
226
|
+
* person has none" call for opposite responses from the caller.
|
|
215
227
|
*/
|
|
216
228
|
async sessionRoles(): Promise<string[] | null> {
|
|
217
229
|
if (!this.signedIn) {
|
|
218
230
|
await this.login()
|
|
219
231
|
}
|
|
232
|
+
const fromRpc = await this.rolesFromRpc()
|
|
233
|
+
if (fromRpc) return fromRpc
|
|
234
|
+
return await this.rolesFromSession()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The caller's own roles, from the app's own RPC. `null` for every answer
|
|
239
|
+
* that is not a role list — an app without the RPC 404s here, which is a
|
|
240
|
+
* reason to go on and ask better-auth, not a reason to report "none".
|
|
241
|
+
*/
|
|
242
|
+
private async rolesFromRpc(): Promise<string[] | null> {
|
|
243
|
+
const rpcName = this.config.rolesRpc ?? 'getMyScopes'
|
|
244
|
+
if (rpcName === false) return null
|
|
245
|
+
let res: Response
|
|
246
|
+
try {
|
|
247
|
+
res = await this.postRpc(rpcName, {})
|
|
248
|
+
} catch {
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
if (!res.ok) return null
|
|
252
|
+
const { body } = await readScenarioHttpResponse<{
|
|
253
|
+
roles?: unknown
|
|
254
|
+
data?: { roles?: unknown }
|
|
255
|
+
}>(res)
|
|
256
|
+
const roles = body?.roles ?? body?.data?.roles
|
|
257
|
+
if (!Array.isArray(roles)) return null
|
|
258
|
+
return roles.filter((name): name is string => typeof name === 'string')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async rolesFromSession(): Promise<string[] | null> {
|
|
220
262
|
const mount = authMount(
|
|
221
263
|
this.config.operator?.signInPath ?? this.config.signInPath
|
|
222
264
|
)
|
|
@@ -254,19 +296,30 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
254
296
|
return user ? [] : null
|
|
255
297
|
}
|
|
256
298
|
|
|
257
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* Start/continue the target agent's run over HTTP as this persona.
|
|
301
|
+
*
|
|
302
|
+
* The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
|
|
303
|
+
* run before it sends a single byte, so a run longer than the client's
|
|
304
|
+
* headers timeout — 300s in undici, which is what Node and Bun both use —
|
|
305
|
+
* fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
|
|
306
|
+
* stage that is down. An agent that talks for several minutes, which is the
|
|
307
|
+
* normal case for anything conversational, cannot be driven that way at all.
|
|
308
|
+
* The stream sends its first event immediately and the run's length stops
|
|
309
|
+
* mattering.
|
|
310
|
+
*/
|
|
258
311
|
private async agentRun(
|
|
259
312
|
agentName: string,
|
|
260
313
|
message: string,
|
|
261
314
|
threadId: string,
|
|
262
315
|
resourceId: string
|
|
263
316
|
): Promise<TargetAgentReply> {
|
|
264
|
-
const
|
|
317
|
+
const res = await this.sendAgent(`agent/${agentName}/stream`, {
|
|
265
318
|
message,
|
|
266
319
|
threadId,
|
|
267
320
|
resourceId,
|
|
268
321
|
})
|
|
269
|
-
return
|
|
322
|
+
return await collectAgentStream(res)
|
|
270
323
|
}
|
|
271
324
|
|
|
272
325
|
/** Answer the target agent's pending approvals over HTTP and continue. */
|
|
@@ -283,7 +336,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
283
336
|
}
|
|
284
337
|
|
|
285
338
|
// knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
|
|
286
|
-
private async
|
|
339
|
+
private async sendAgent(subPath: string, body: unknown): Promise<Response> {
|
|
287
340
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
288
341
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
|
|
289
342
|
const send = () =>
|
|
@@ -308,6 +361,11 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
308
361
|
`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
|
|
309
362
|
)
|
|
310
363
|
}
|
|
364
|
+
return res
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async postAgent(subPath: string, body: unknown): Promise<unknown> {
|
|
368
|
+
const res = await this.sendAgent(subPath, body)
|
|
311
369
|
if (res.status === 204) return undefined
|
|
312
370
|
const text = await res.text()
|
|
313
371
|
return text ? JSON.parse(text) : undefined
|
|
@@ -342,6 +400,77 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
342
400
|
}
|
|
343
401
|
}
|
|
344
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Reduce the agent's SSE run into the same reply shape the plain route returns.
|
|
405
|
+
*
|
|
406
|
+
* `RUN_ERROR` is raised rather than returned: the plain route answers a failed
|
|
407
|
+
* run with a non-2xx, and a scenario that read an error as an empty transcript
|
|
408
|
+
* would score the agent on silence it never produced.
|
|
409
|
+
*/
|
|
410
|
+
async function collectAgentStream(res: Response): Promise<TargetAgentReply> {
|
|
411
|
+
const body = res.body
|
|
412
|
+
if (!body) {
|
|
413
|
+
throw new Error('[scenario] the agent stream carried no body')
|
|
414
|
+
}
|
|
415
|
+
const decoder = new TextDecoder()
|
|
416
|
+
const reader = body.getReader()
|
|
417
|
+
let buffer = ''
|
|
418
|
+
let text = ''
|
|
419
|
+
let runId = ''
|
|
420
|
+
const pendingApprovals: TargetPendingApproval[] = []
|
|
421
|
+
|
|
422
|
+
const consume = (line: string) => {
|
|
423
|
+
if (!line.startsWith('data:')) return
|
|
424
|
+
const payload = line.slice(5).trim()
|
|
425
|
+
if (!payload) return
|
|
426
|
+
let event: Record<string, unknown>
|
|
427
|
+
try {
|
|
428
|
+
event = JSON.parse(payload)
|
|
429
|
+
} catch {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (typeof event.runId === 'string' && event.runId) runId = event.runId
|
|
433
|
+
if (
|
|
434
|
+
event.type === 'TEXT_MESSAGE_CONTENT' &&
|
|
435
|
+
typeof event.delta === 'string'
|
|
436
|
+
) {
|
|
437
|
+
text += event.delta
|
|
438
|
+
} else if (event.type === 'approval-request') {
|
|
439
|
+
pendingApprovals.push({
|
|
440
|
+
toolCallId: String(event.toolCallId),
|
|
441
|
+
toolName: String(event.toolName),
|
|
442
|
+
args: event.args,
|
|
443
|
+
reason: typeof event.reason === 'string' ? event.reason : undefined,
|
|
444
|
+
})
|
|
445
|
+
} else if (event.type === 'RUN_ERROR' || event.type === 'error') {
|
|
446
|
+
const message = event.message ?? event.errorText ?? 'the agent run failed'
|
|
447
|
+
throw new Error(`[scenario] agent run failed: ${String(message)}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
for (;;) {
|
|
453
|
+
const { done, value } = await reader.read()
|
|
454
|
+
if (done) break
|
|
455
|
+
buffer += decoder.decode(value, { stream: true })
|
|
456
|
+
const lines = buffer.split('\n')
|
|
457
|
+
buffer = lines.pop() ?? ''
|
|
458
|
+
for (const line of lines) consume(line)
|
|
459
|
+
}
|
|
460
|
+
if (buffer) consume(buffer)
|
|
461
|
+
} finally {
|
|
462
|
+
await reader.cancel().catch(() => {})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
text,
|
|
467
|
+
runId,
|
|
468
|
+
status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
|
|
469
|
+
pendingApprovals:
|
|
470
|
+
pendingApprovals.length > 0 ? pendingApprovals : undefined,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
345
474
|
/** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
|
|
346
475
|
function normalizeAgentReply(raw: unknown): TargetAgentReply {
|
|
347
476
|
const r = (raw ?? {}) as Record<string, unknown>
|
package/src/services/index.ts
CHANGED
|
@@ -106,3 +106,77 @@ test('LocalEmailService logs null textLength for HTML email without plain text',
|
|
|
106
106
|
assert.equal(payload.htmlLength, '<p>Hello</p>'.length)
|
|
107
107
|
assert.equal(payload.textLength, null)
|
|
108
108
|
})
|
|
109
|
+
|
|
110
|
+
test('LocalEmailService logs attachment metadata without the content', async () => {
|
|
111
|
+
const service = new LocalEmailService()
|
|
112
|
+
const writes: string[] = []
|
|
113
|
+
const originalInfo = console.info
|
|
114
|
+
console.info = (value?: unknown) => {
|
|
115
|
+
writes.push(String(value))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await service.send({
|
|
120
|
+
to: 'user@example.com',
|
|
121
|
+
html: '<img src="cid:logo" />',
|
|
122
|
+
attachments: [
|
|
123
|
+
{
|
|
124
|
+
filename: 'receipt.pdf',
|
|
125
|
+
content: new Uint8Array([1, 2, 3, 4]),
|
|
126
|
+
contentType: 'application/pdf',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
filename: 'logo.png',
|
|
130
|
+
content: 'aGVsbG8=',
|
|
131
|
+
contentType: 'image/png',
|
|
132
|
+
contentId: 'logo',
|
|
133
|
+
disposition: 'inline',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
})
|
|
137
|
+
} finally {
|
|
138
|
+
console.info = originalInfo
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const payload = JSON.parse(writes[0])
|
|
142
|
+
assert.deepEqual(payload.attachments, [
|
|
143
|
+
{
|
|
144
|
+
filename: 'receipt.pdf',
|
|
145
|
+
contentType: 'application/pdf',
|
|
146
|
+
contentId: null,
|
|
147
|
+
disposition: 'attachment',
|
|
148
|
+
contentLength: 4,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
filename: 'logo.png',
|
|
152
|
+
contentType: 'image/png',
|
|
153
|
+
contentId: 'logo',
|
|
154
|
+
disposition: 'inline',
|
|
155
|
+
contentLength: 8,
|
|
156
|
+
},
|
|
157
|
+
])
|
|
158
|
+
assert.equal(writes[0].includes('aGVsbG8='), false)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('LocalEmailService omits attachments when there are none', async () => {
|
|
162
|
+
const service = new LocalEmailService()
|
|
163
|
+
const writes: string[] = []
|
|
164
|
+
const originalInfo = console.info
|
|
165
|
+
console.info = (value?: unknown) => {
|
|
166
|
+
writes.push(String(value))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await service.send({ to: 'user@example.com', text: 'no files' })
|
|
171
|
+
await service.send({
|
|
172
|
+
to: 'user@example.com',
|
|
173
|
+
text: 'no files',
|
|
174
|
+
attachments: [],
|
|
175
|
+
})
|
|
176
|
+
} finally {
|
|
177
|
+
console.info = originalInfo
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
assert.equal(JSON.parse(writes[0]).attachments, undefined)
|
|
181
|
+
assert.equal(JSON.parse(writes[1]).attachments, undefined)
|
|
182
|
+
})
|
|
@@ -27,6 +27,15 @@ export class LocalEmailService implements EmailService {
|
|
|
27
27
|
if ('text' in input && typeof input.text === 'string') {
|
|
28
28
|
payload.textLength = input.text.length
|
|
29
29
|
}
|
|
30
|
+
if (input.attachments?.length) {
|
|
31
|
+
payload.attachments = input.attachments.map((attachment) => ({
|
|
32
|
+
filename: attachment.filename,
|
|
33
|
+
contentType: attachment.contentType ?? null,
|
|
34
|
+
contentId: attachment.contentId ?? null,
|
|
35
|
+
disposition: attachment.disposition ?? 'attachment',
|
|
36
|
+
contentLength: attachment.content.length,
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
30
39
|
|
|
31
40
|
console.info(JSON.stringify(payload))
|
|
32
41
|
return {}
|
|
@@ -14,7 +14,7 @@ const OPERATOR_TOKEN = 'operator.jwt.token'
|
|
|
14
14
|
*/
|
|
15
15
|
const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
16
16
|
const users = [...seeded]
|
|
17
|
-
|
|
17
|
+
const sentBodies: unknown[] = []
|
|
18
18
|
const server: Server = createServer((req, res) => {
|
|
19
19
|
const chunks: Buffer[] = []
|
|
20
20
|
req.on('data', (c) => chunks.push(c))
|
|
@@ -31,21 +31,15 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
31
31
|
}
|
|
32
32
|
let actAs: { userId: string } | undefined
|
|
33
33
|
if (body.actAs) {
|
|
34
|
-
|
|
34
|
+
sentBodies.push(body.actAs)
|
|
35
|
+
const user = users.find((u) => u.email === body.actAs.email)
|
|
35
36
|
if (!user) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
)
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
created++
|
|
47
|
-
user = { id: `made-${created}`, email: body.actAs.email }
|
|
48
|
-
users.push(user)
|
|
37
|
+
res.writeHead(404).end(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
message: `No account on this stage for ${body.actAs.email}`,
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
return
|
|
49
43
|
}
|
|
50
44
|
actAs = { userId: user.id }
|
|
51
45
|
}
|
|
@@ -74,8 +68,8 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
74
68
|
return {
|
|
75
69
|
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
76
70
|
server,
|
|
77
|
-
get
|
|
78
|
-
return
|
|
71
|
+
get sentBodies() {
|
|
72
|
+
return sentBodies
|
|
79
73
|
},
|
|
80
74
|
}
|
|
81
75
|
}
|
|
@@ -112,7 +106,6 @@ describe('operator persona sign-in', () => {
|
|
|
112
106
|
}
|
|
113
107
|
assert.equal(result.actingAs, 'user-7')
|
|
114
108
|
assert.match(result.cookie, /session=operator/)
|
|
115
|
-
assert.equal(stage.createdCount, 0)
|
|
116
109
|
})
|
|
117
110
|
|
|
118
111
|
// An app that mounts auth somewhere other than the root moves both sign-in
|
|
@@ -151,24 +144,25 @@ describe('operator persona sign-in', () => {
|
|
|
151
144
|
() => personas.customer!.invoke('whoami', {}),
|
|
152
145
|
/operator sign-in failed for 'customer' \(404\)/
|
|
153
146
|
)
|
|
154
|
-
assert.equal(stage.createdCount, 0)
|
|
155
147
|
})
|
|
156
148
|
|
|
157
|
-
|
|
158
|
-
|
|
149
|
+
// Provisioning moved into the stage, so the handshake carries an address and
|
|
150
|
+
// nothing else. A `create` flag or a role list here would be the caller
|
|
151
|
+
// deciding what the stage holds, which is the arrangement this replaced.
|
|
152
|
+
test('asks only to act as an address', async () => {
|
|
153
|
+
const stage = await startStage([
|
|
154
|
+
{ id: 'user-3', email: 'customer@personas.invalid' },
|
|
155
|
+
])
|
|
159
156
|
servers.push(stage.server)
|
|
160
157
|
|
|
161
158
|
const personas = createHttpPersonas({
|
|
162
159
|
apiUrl: stage.apiUrl,
|
|
163
|
-
operator: { token: OPERATOR_TOKEN
|
|
164
|
-
personas: { customer: persona('
|
|
160
|
+
operator: { token: OPERATOR_TOKEN },
|
|
161
|
+
personas: { customer: persona('customer@personas.invalid') },
|
|
165
162
|
})
|
|
166
163
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
assert.equal(result.actingAs, 'made-1')
|
|
171
|
-
assert.equal(stage.createdCount, 1)
|
|
164
|
+
await personas.customer!.invoke('whoami', {})
|
|
165
|
+
assert.deepEqual(stage.sentBodies, [{ email: 'customer@personas.invalid' }])
|
|
172
166
|
})
|
|
173
167
|
|
|
174
168
|
test('mints the operator session from a token factory', async () => {
|