@siduri-x/api 1.0.1 → 1.0.4
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/dist/index.js +18 -9
- package/package.json +16 -12
- package/src/app.ts +294 -95
- package/src/auth.test.ts +56 -23
- package/src/auth.ts +74 -22
- package/src/context-mapper.test.ts +42 -147
- package/src/context-mapper.ts +44 -179
- package/src/index.test.ts +61 -32
- package/src/index.ts +30 -21
- package/src/runtime.test.ts +57 -5
- package/src/t5-experience.test.ts +0 -1
- package/src/t6-security.test.ts +0 -1
- package/dist/app.d.ts +0 -9
- package/dist/app.js +0 -480
- package/dist/auth.d.ts +0 -8
- package/dist/auth.js +0 -40
- package/dist/auth.test.d.ts +0 -1
- package/dist/auth.test.js +0 -48
- package/dist/b0-b6.test.d.ts +0 -1
- package/dist/b0-b6.test.js +0 -121
- package/dist/context-mapper.d.ts +0 -15
- package/dist/context-mapper.js +0 -287
- package/dist/context-mapper.test.d.ts +0 -1
- package/dist/context-mapper.test.js +0 -233
- package/dist/cors.d.ts +0 -3
- package/dist/cors.js +0 -42
- package/dist/index.d.ts +0 -6
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -115
- package/dist/runtime.d.ts +0 -1
- package/dist/runtime.js +0 -17
- package/dist/runtime.test.d.ts +0 -1
- package/dist/runtime.test.js +0 -240
- package/dist/smoke.test.d.ts +0 -0
- package/dist/smoke.test.js +0 -6
- package/dist/t4-gating.test.d.ts +0 -1
- package/dist/t4-gating.test.js +0 -193
- package/dist/t5-experience.test.d.ts +0 -1
- package/dist/t5-experience.test.js +0 -156
- package/dist/t6-security.test.d.ts +0 -1
- package/dist/t6-security.test.js +0 -424
- package/dist/t7-release.test.d.ts +0 -1
- package/dist/t7-release.test.js +0 -119
package/src/context-mapper.ts
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RequestContext,
|
|
3
|
-
AuthorizationRole,
|
|
4
|
-
Channel,
|
|
5
3
|
DiagnosticCode,
|
|
6
4
|
ContextError,
|
|
7
5
|
validateRequestContext,
|
|
8
6
|
} from '@siduri-x/core';
|
|
9
7
|
|
|
10
8
|
export interface ContextMapperOptions {
|
|
11
|
-
endpointPolicy?: 'public' | 'private' | 'operator' | 'direct';
|
|
12
|
-
defaultPublicAudience?: string;
|
|
13
|
-
defaultPrivateAudience?: string;
|
|
14
|
-
defaultOperatorAudience?: string;
|
|
9
|
+
endpointPolicy?: 'public' | 'private' | 'operator' | 'direct' | string;
|
|
15
10
|
allowAnonymousPublicChat?: boolean;
|
|
16
11
|
}
|
|
17
12
|
|
|
@@ -22,13 +17,17 @@ export interface MapRequestContextResult {
|
|
|
22
17
|
error?: ContextError;
|
|
23
18
|
}
|
|
24
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Maps incoming HTTP requests to a canonical RequestContext.
|
|
22
|
+
* In a single-owner, single-machine model:
|
|
23
|
+
* - Security is enforced at the external boundary, not internally between roles.
|
|
24
|
+
* - No internal audience or viewer/operator/owner role hierarchies.
|
|
25
|
+
*/
|
|
25
26
|
export function mapRequestContext(
|
|
26
27
|
input: any,
|
|
27
28
|
options: ContextMapperOptions = {}
|
|
28
29
|
): MapRequestContextResult {
|
|
29
30
|
const diagnostics: DiagnosticCode[] = [];
|
|
30
|
-
const endpointPolicy = options.endpointPolicy || 'public';
|
|
31
|
-
const defaultPublicAudience = options.defaultPublicAudience || 'audience-public';
|
|
32
31
|
|
|
33
32
|
if (!input || typeof input !== 'object') {
|
|
34
33
|
return {
|
|
@@ -40,27 +39,7 @@ export function mapRequestContext(
|
|
|
40
39
|
};
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
//
|
|
44
|
-
const rawAudience =
|
|
45
|
-
input?.context?.conversation?.audienceId ??
|
|
46
|
-
input?.conversation?.audienceId ??
|
|
47
|
-
input?.audienceId ??
|
|
48
|
-
input?.audience;
|
|
49
|
-
|
|
50
|
-
if (rawAudience === 'MASTER_PRIVATE' || input?.scope === 'MASTER_PRIVATE') {
|
|
51
|
-
if (endpointPolicy === 'public' || input?.channel === 'public' || input?.context?.conversation?.channel === 'public') {
|
|
52
|
-
return {
|
|
53
|
-
accepted: false,
|
|
54
|
-
error: {
|
|
55
|
-
code: 'LEGACY_PERSONAL_AUDIENCE',
|
|
56
|
-
field: 'audienceId',
|
|
57
|
-
correlationId: input?.context?.conversation?.correlationId || input?.correlationId,
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// 1. If incoming input already has a full neutral context structure
|
|
42
|
+
// 1. If incoming input already has a context structure
|
|
64
43
|
if (input.context && typeof input.context === 'object') {
|
|
65
44
|
const rawCtx = input.context;
|
|
66
45
|
const companionId = input.companionId || rawCtx.companionId || input.id;
|
|
@@ -77,48 +56,6 @@ export function mapRequestContext(
|
|
|
77
56
|
};
|
|
78
57
|
}
|
|
79
58
|
|
|
80
|
-
// Role cannot select audience or subject
|
|
81
|
-
const rawRole = input.role || rawCtx.actor?.authorizationRole;
|
|
82
|
-
if (input.role && !rawCtx.conversation?.channel && !rawCtx.conversation?.audienceId) {
|
|
83
|
-
return {
|
|
84
|
-
accepted: false,
|
|
85
|
-
error: {
|
|
86
|
-
code: 'AMBIGUOUS_CONTEXT',
|
|
87
|
-
conflicts: ['role_does_not_select_audience', 'role_does_not_select_subject'],
|
|
88
|
-
correlationId,
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const channel: Channel = rawCtx.conversation?.channel || (endpointPolicy as Channel);
|
|
94
|
-
let audienceId: string | undefined = rawCtx.conversation?.audienceId;
|
|
95
|
-
|
|
96
|
-
if (!audienceId) {
|
|
97
|
-
if (channel === 'public' || endpointPolicy === 'public') {
|
|
98
|
-
audienceId = defaultPublicAudience;
|
|
99
|
-
diagnostics.push('audience_defaulted_by_public_policy');
|
|
100
|
-
} else {
|
|
101
|
-
return {
|
|
102
|
-
accepted: false,
|
|
103
|
-
error: {
|
|
104
|
-
code: 'MISSING_CONTEXT',
|
|
105
|
-
fields: ['conversation.audienceId'],
|
|
106
|
-
correlationId,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (!correlationId) {
|
|
113
|
-
return {
|
|
114
|
-
accepted: false,
|
|
115
|
-
error: {
|
|
116
|
-
code: 'MISSING_CONTEXT',
|
|
117
|
-
fields: ['conversation.correlationId'],
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
59
|
const actor = rawCtx.actor;
|
|
123
60
|
if (!actor || typeof actor !== 'object') {
|
|
124
61
|
return {
|
|
@@ -131,64 +68,37 @@ export function mapRequestContext(
|
|
|
131
68
|
};
|
|
132
69
|
}
|
|
133
70
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
if (channel === 'private' && !capabilities.includes('chat:private')) {
|
|
137
|
-
return {
|
|
138
|
-
accepted: false,
|
|
139
|
-
error: {
|
|
140
|
-
code: 'UNAUTHORIZED_CHANNEL_OR_CAPABILITY',
|
|
141
|
-
message: 'Private channel requires explicit chat:private capability',
|
|
142
|
-
fields: ['actor.capabilities'],
|
|
143
|
-
correlationId,
|
|
144
|
-
},
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
if (channel === 'operator' && !capabilities.includes('memory:inspect') && !capabilities.includes('operator:access')) {
|
|
71
|
+
// Reject invalid primary_user subject
|
|
72
|
+
if (rawCtx.subject && (rawCtx.subject.subjectId === 'primary_user' || rawCtx.subject === 'primary_user')) {
|
|
148
73
|
return {
|
|
149
74
|
accepted: false,
|
|
150
75
|
error: {
|
|
151
|
-
code: '
|
|
152
|
-
message: '
|
|
153
|
-
|
|
76
|
+
code: 'FORBIDDEN_CONTEXT',
|
|
77
|
+
message: 'Global primary_user subject is forbidden',
|
|
78
|
+
field: 'subject.subjectId',
|
|
154
79
|
correlationId,
|
|
155
80
|
},
|
|
156
81
|
};
|
|
157
82
|
}
|
|
158
83
|
|
|
159
|
-
// Check subject policy
|
|
160
|
-
let subject = rawCtx.subject;
|
|
161
|
-
if (subject) {
|
|
162
|
-
if (subject.subjectId === 'primary_user' || subject === 'primary_user') {
|
|
163
|
-
// Global primary_user is rejected or quarantined
|
|
164
|
-
return {
|
|
165
|
-
accepted: false,
|
|
166
|
-
error: {
|
|
167
|
-
code: 'FORBIDDEN_CONTEXT',
|
|
168
|
-
message: 'Global primary_user subject is forbidden',
|
|
169
|
-
field: 'subject.subjectId',
|
|
170
|
-
correlationId,
|
|
171
|
-
},
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
84
|
const constructed: RequestContext = {
|
|
177
85
|
companionId,
|
|
178
86
|
actor: {
|
|
179
87
|
actorId: actor.actorId,
|
|
180
88
|
sessionId: actor.sessionId,
|
|
89
|
+
capabilities: Array.isArray(actor.capabilities) ? actor.capabilities : ['chat'],
|
|
90
|
+
authenticated: actor.authenticated !== undefined ? Boolean(actor.authenticated) : true,
|
|
181
91
|
authorizationRole: actor.authorizationRole,
|
|
182
|
-
|
|
183
|
-
authenticated: Boolean(actor.authenticated),
|
|
92
|
+
...actor,
|
|
184
93
|
},
|
|
185
94
|
conversation: {
|
|
186
|
-
channel,
|
|
187
|
-
audienceId,
|
|
188
|
-
isLive: rawCtx.conversation?.isLive,
|
|
189
95
|
correlationId,
|
|
96
|
+
channel: rawCtx.conversation?.channel || input.channel || 'direct',
|
|
97
|
+
isLive: rawCtx.conversation?.isLive,
|
|
98
|
+
...rawCtx.conversation,
|
|
190
99
|
},
|
|
191
|
-
|
|
100
|
+
source: input.source || rawCtx.source || 'local',
|
|
101
|
+
subject: rawCtx.subject,
|
|
192
102
|
};
|
|
193
103
|
|
|
194
104
|
const validated = validateRequestContext(constructed);
|
|
@@ -203,9 +113,9 @@ export function mapRequestContext(
|
|
|
203
113
|
};
|
|
204
114
|
}
|
|
205
115
|
|
|
206
|
-
// 2.
|
|
116
|
+
// 2. Synthesize clean RequestContext from request envelope
|
|
207
117
|
const companionId = input.companionId || input.id;
|
|
208
|
-
const correlationId = input.correlationId || input.conversation?.correlationId;
|
|
118
|
+
const correlationId = input.correlationId || input.conversation?.correlationId || (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
|
|
209
119
|
|
|
210
120
|
if (!companionId) {
|
|
211
121
|
return {
|
|
@@ -222,49 +132,16 @@ export function mapRequestContext(
|
|
|
222
132
|
diagnostics.push('companion_default_mapped_for_bootstrap');
|
|
223
133
|
}
|
|
224
134
|
|
|
225
|
-
|
|
226
|
-
const legacyRole = input.role?.toString().toUpperCase();
|
|
227
|
-
let authRole: AuthorizationRole = 'viewer';
|
|
228
|
-
if (legacyRole === 'OWNER') {
|
|
229
|
-
authRole = 'administrator';
|
|
230
|
-
diagnostics.push('legacy_role_mapped_to_authorization');
|
|
231
|
-
} else if (legacyRole === 'OPERATOR') {
|
|
232
|
-
authRole = 'operator';
|
|
233
|
-
diagnostics.push('legacy_role_mapped_to_authorization');
|
|
234
|
-
} else if (legacyRole === 'VIEWER') {
|
|
235
|
-
authRole = 'viewer';
|
|
236
|
-
diagnostics.push('legacy_role_mapped_to_authorization');
|
|
237
|
-
} else if (input.role) {
|
|
238
|
-
return {
|
|
239
|
-
accepted: false,
|
|
240
|
-
error: {
|
|
241
|
-
code: 'INVALID_CONTEXT',
|
|
242
|
-
field: 'role',
|
|
243
|
-
correlationId,
|
|
244
|
-
},
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Check endpoint policy vs legacy request
|
|
249
|
-
if (endpointPolicy === 'private' || endpointPolicy === 'operator' || endpointPolicy === 'direct') {
|
|
250
|
-
// Missing explicit channel, audience, or capability on private/operator endpoint is an error
|
|
251
|
-
const fields: string[] = [];
|
|
252
|
-
if (!input.channel) fields.push('conversation.channel');
|
|
253
|
-
if (!input.audienceId) fields.push('conversation.audienceId');
|
|
254
|
-
if (!input.capabilities && !input.actor?.capabilities) fields.push('actor.capabilities');
|
|
255
|
-
if (!correlationId) fields.push('conversation.correlationId');
|
|
256
|
-
|
|
135
|
+
if (!correlationId) {
|
|
257
136
|
return {
|
|
258
137
|
accepted: false,
|
|
259
138
|
error: {
|
|
260
139
|
code: 'MISSING_CONTEXT',
|
|
261
|
-
fields:
|
|
262
|
-
correlationId,
|
|
140
|
+
fields: ['conversation.correlationId'],
|
|
263
141
|
},
|
|
264
142
|
};
|
|
265
143
|
}
|
|
266
144
|
|
|
267
|
-
// Ambiguity check: if legacy input specifies role without correlationId or endpoint context for stateful op
|
|
268
145
|
if (input.subject === 'primary_user' || input.subjectId === 'primary_user') {
|
|
269
146
|
return {
|
|
270
147
|
accepted: false,
|
|
@@ -277,55 +154,43 @@ export function mapRequestContext(
|
|
|
277
154
|
};
|
|
278
155
|
}
|
|
279
156
|
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
if (!finalCorrelationId) {
|
|
283
|
-
return {
|
|
284
|
-
accepted: false,
|
|
285
|
-
error: {
|
|
286
|
-
code: 'MISSING_CONTEXT',
|
|
287
|
-
fields: ['conversation.correlationId'],
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Build anonymous public context
|
|
293
|
-
const actorId = input.actorId || input.actor?.actorId || 'anonymous-session-a';
|
|
294
|
-
const sessionId = input.sessionId || input.actor?.sessionId || 'session-a';
|
|
157
|
+
const actorId = input.actorId || input.actor?.actorId || 'local-user';
|
|
158
|
+
const sessionId = input.sessionId || input.actor?.sessionId || `session-${Date.now()}`;
|
|
295
159
|
if (!input.actorId && !input.actor?.actorId) {
|
|
296
160
|
diagnostics.push('anonymous_session_generated');
|
|
297
161
|
}
|
|
298
162
|
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
? ['chat:public', 'admin:access']
|
|
305
|
-
: authRole === 'operator'
|
|
306
|
-
? ['chat:public', 'operator:access']
|
|
307
|
-
: ['chat:public'];
|
|
163
|
+
const capabilities = Array.isArray(input.capabilities)
|
|
164
|
+
? input.capabilities
|
|
165
|
+
: Array.isArray(input.actor?.capabilities)
|
|
166
|
+
? input.actor.capabilities
|
|
167
|
+
: ['chat', 'system'];
|
|
308
168
|
|
|
309
169
|
const mappedContext: RequestContext = {
|
|
310
170
|
companionId,
|
|
311
171
|
actor: {
|
|
312
172
|
actorId,
|
|
313
173
|
sessionId,
|
|
314
|
-
authorizationRole: authRole,
|
|
315
174
|
capabilities,
|
|
316
|
-
authenticated: Boolean(input.authenticated),
|
|
175
|
+
authenticated: input.authenticated !== undefined ? Boolean(input.authenticated) : true,
|
|
176
|
+
authorizationRole: input.role ? (input.role.toLowerCase() === 'viewer' ? 'viewer' : 'administrator') : undefined,
|
|
317
177
|
},
|
|
318
178
|
conversation: {
|
|
319
|
-
channel,
|
|
320
|
-
|
|
321
|
-
correlationId: finalCorrelationId,
|
|
179
|
+
channel: input.channel || input.conversation?.channel || 'direct',
|
|
180
|
+
correlationId,
|
|
322
181
|
},
|
|
323
|
-
|
|
182
|
+
source: input.source || 'local',
|
|
183
|
+
subject: input.subject,
|
|
324
184
|
};
|
|
325
185
|
|
|
186
|
+
const validated = validateRequestContext(mappedContext);
|
|
187
|
+
if (!validated.accepted) {
|
|
188
|
+
return validated;
|
|
189
|
+
}
|
|
190
|
+
|
|
326
191
|
return {
|
|
327
192
|
accepted: true,
|
|
328
|
-
context:
|
|
193
|
+
context: validated.context,
|
|
329
194
|
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
330
195
|
};
|
|
331
196
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -31,7 +31,10 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
31
31
|
expect(res.status).toBe(200);
|
|
32
32
|
expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
|
|
33
33
|
'Hello neutral world',
|
|
34
|
-
|
|
34
|
+
expect.objectContaining({
|
|
35
|
+
companionId: 'companion-a',
|
|
36
|
+
conversation: expect.objectContaining({ channel: 'direct' }),
|
|
37
|
+
}),
|
|
35
38
|
[]
|
|
36
39
|
);
|
|
37
40
|
});
|
|
@@ -51,7 +54,6 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
51
54
|
},
|
|
52
55
|
conversation: {
|
|
53
56
|
channel: 'public',
|
|
54
|
-
audienceId: 'audience-public',
|
|
55
57
|
correlationId: 'corr-route-1',
|
|
56
58
|
},
|
|
57
59
|
},
|
|
@@ -62,39 +64,14 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
62
64
|
expect(res.status).toBe(200);
|
|
63
65
|
expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
|
|
64
66
|
'Hello structured context',
|
|
65
|
-
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
companionId: 'companion-a',
|
|
69
|
+
conversation: expect.objectContaining({ channel: 'public' }),
|
|
70
|
+
}),
|
|
66
71
|
[]
|
|
67
72
|
);
|
|
68
73
|
});
|
|
69
74
|
|
|
70
|
-
test('rejects MASTER_PRIVATE in public request with 400 and structured error', async () => {
|
|
71
|
-
const res = await request(app)
|
|
72
|
-
.post('/chat')
|
|
73
|
-
.send({
|
|
74
|
-
companionId: 'companion-a',
|
|
75
|
-
context: {
|
|
76
|
-
actor: {
|
|
77
|
-
actorId: 'actor-a',
|
|
78
|
-
sessionId: 'session-a',
|
|
79
|
-
authorizationRole: 'viewer',
|
|
80
|
-
capabilities: ['chat:public'],
|
|
81
|
-
authenticated: false,
|
|
82
|
-
},
|
|
83
|
-
conversation: {
|
|
84
|
-
channel: 'public',
|
|
85
|
-
audienceId: 'MASTER_PRIVATE',
|
|
86
|
-
correlationId: 'corr-err-1',
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
message: 'Forbidden audience test',
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
expect(res.status).toBe(400);
|
|
93
|
-
expect(res.body.accepted).toBe(false);
|
|
94
|
-
expect(res.body.error.code).toBe('LEGACY_PERSONAL_AUDIENCE');
|
|
95
|
-
expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
75
|
test('rejects global primary_user subject with 400 and structured error', async () => {
|
|
99
76
|
const res = await request(app)
|
|
100
77
|
.post('/chat')
|
|
@@ -110,7 +87,6 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
110
87
|
},
|
|
111
88
|
conversation: {
|
|
112
89
|
channel: 'public',
|
|
113
|
-
audienceId: 'audience-public',
|
|
114
90
|
correlationId: 'corr-err-2',
|
|
115
91
|
},
|
|
116
92
|
subject: {
|
|
@@ -126,4 +102,57 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
126
102
|
expect(res.body.error.code).toBe('FORBIDDEN_CONTEXT');
|
|
127
103
|
expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
|
|
128
104
|
});
|
|
105
|
+
|
|
106
|
+
test('streams response chunks via POST /chat/stream', async () => {
|
|
107
|
+
fakeRuntime.mouth = {
|
|
108
|
+
stream: async function* () {
|
|
109
|
+
yield { utteranceId: 'utt-1', index: 1, deltaText: 'Hello', isComplete: false, medium: 'web' };
|
|
110
|
+
yield { utteranceId: 'utt-1', index: 2, deltaText: ' world', isComplete: false, medium: 'web' };
|
|
111
|
+
yield { utteranceId: 'utt-1', index: 3, deltaText: '', isComplete: true, medium: 'web' };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const res = await request(app)
|
|
116
|
+
.post('/chat/stream')
|
|
117
|
+
.send({
|
|
118
|
+
id: 'companion-a',
|
|
119
|
+
message: 'Stream me',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
expect(res.headers['content-type']).toContain('text/event-stream');
|
|
124
|
+
expect(res.text).toContain('event: staged');
|
|
125
|
+
expect(res.text).toContain('event: chunk');
|
|
126
|
+
expect(res.text).toContain('event: done');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('handles barge-in interruption via POST /chat/interrupt', async () => {
|
|
130
|
+
fakeRuntime.interruptMouth = jest.fn();
|
|
131
|
+
|
|
132
|
+
const res = await request(app)
|
|
133
|
+
.post('/chat/interrupt')
|
|
134
|
+
.send({
|
|
135
|
+
companionId: 'companion-a',
|
|
136
|
+
reason: 'user_stop',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
expect(res.body.interrupted).toBe(true);
|
|
141
|
+
expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_stop');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('handles mouth interruption via POST /mouth/interrupt', async () => {
|
|
145
|
+
fakeRuntime.interruptMouth = jest.fn();
|
|
146
|
+
|
|
147
|
+
const res = await request(app)
|
|
148
|
+
.post('/mouth/interrupt')
|
|
149
|
+
.send({
|
|
150
|
+
companionId: 'companion-a',
|
|
151
|
+
reason: 'user_barge_in',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(res.status).toBe(200);
|
|
155
|
+
expect(res.body.interrupted).toBe(true);
|
|
156
|
+
expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_barge_in');
|
|
157
|
+
});
|
|
129
158
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Express } from 'express';
|
|
4
|
-
import { createApp, AppInstance } from './app';
|
|
4
|
+
import { createApp, AppInstance, AppBrainConfig, AppBehaviorConfig } from './app';
|
|
5
5
|
import { SiduriRuntime } from './runtime';
|
|
6
6
|
import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
|
|
7
7
|
import { PostgresMemoryOrgan } from '@siduri-x/memory';
|
|
8
|
-
import { VoiceAdapter } from '@siduri-x/voice';
|
|
9
|
-
import { EKnowledgeAdapter } from '@siduri-x/knowledge';
|
|
10
|
-
import { OpenRouterVisionAdapter } from '@siduri-x/vision';
|
|
8
|
+
import { VoiceAdapter, VoiceConfig } from '@siduri-x/voice';
|
|
9
|
+
import { EKnowledgeAdapter, EKnowledgeConfig } from '@siduri-x/knowledge';
|
|
10
|
+
import { OpenRouterVisionAdapter, OpenRouterVisionConfig } from '@siduri-x/vision';
|
|
11
11
|
import { ActiveSelfCompiler } from '@siduri-x/behavior';
|
|
12
|
-
import { Live2DAdapter } from '@siduri-x/body';
|
|
12
|
+
import { Live2DAdapter, Live2DAdapterConfig } from '@siduri-x/body';
|
|
13
13
|
import { FixtureObservationOrgan } from '@siduri-x/observation';
|
|
14
14
|
|
|
15
15
|
export { createApp, AppInstance };
|
|
@@ -20,49 +20,58 @@ const instance: AppInstance = createApp(runtimes);
|
|
|
20
20
|
export const app: Express = instance.app;
|
|
21
21
|
export default app;
|
|
22
22
|
|
|
23
|
-
function createBrain(config
|
|
24
|
-
const provider = config
|
|
23
|
+
function createBrain(config?: AppBrainConfig) {
|
|
24
|
+
const provider = config?.provider || 'openrouter';
|
|
25
25
|
const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
|
|
26
|
-
const apiKey = config
|
|
26
|
+
const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
|
|
27
27
|
if (provider === 'openai-compatible') {
|
|
28
28
|
return new OpenAICompatibleBrain({
|
|
29
29
|
apiKey,
|
|
30
|
-
model: config
|
|
31
|
-
baseUrl: config
|
|
30
|
+
model: config?.model || 'local-model',
|
|
31
|
+
baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
-
return new OpenRouterBrain({ apiKey, model: config
|
|
34
|
+
return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function isDisabled(config
|
|
37
|
+
function isDisabled(config?: { provider?: string }): boolean {
|
|
38
38
|
return !config || config.provider === 'none';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function createVoice(config
|
|
41
|
+
function createVoice(config?: VoiceConfig) {
|
|
42
42
|
return isDisabled(config)
|
|
43
43
|
? undefined
|
|
44
|
-
: new VoiceAdapter({
|
|
44
|
+
: new VoiceAdapter({
|
|
45
|
+
provider: (config?.provider as any) || 'voicevox',
|
|
46
|
+
baseUrl: config?.baseUrl || process.env.VOICEVOX_URL || 'http://localhost:50021',
|
|
47
|
+
speakerId: config?.speakerId || 1,
|
|
48
|
+
...config,
|
|
49
|
+
});
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
function createKnowledge(config
|
|
52
|
+
function createKnowledge(config?: EKnowledgeConfig) {
|
|
48
53
|
if (isDisabled(config)) return undefined;
|
|
49
|
-
if (!config?.packPath && !config?.registryUrl && !config?.baseUrl
|
|
54
|
+
if (!config?.packPath && !config?.registryUrl && !config?.baseUrl) {
|
|
50
55
|
return undefined;
|
|
51
56
|
}
|
|
52
|
-
return new EKnowledgeAdapter(config);
|
|
57
|
+
return new EKnowledgeAdapter(config || {});
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
function createVision(config
|
|
60
|
+
function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
|
|
56
61
|
return isDisabled(config)
|
|
57
62
|
? undefined
|
|
58
|
-
: new OpenRouterVisionAdapter({
|
|
63
|
+
: new OpenRouterVisionAdapter({
|
|
64
|
+
apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
|
|
65
|
+
model: config?.model || 'gpt-4-vision',
|
|
66
|
+
...config,
|
|
67
|
+
});
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
function createBehavior(config
|
|
70
|
+
function createBehavior(config?: AppBehaviorConfig) {
|
|
62
71
|
return isDisabled(config) ? undefined : new ActiveSelfCompiler();
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
function createBody(config
|
|
74
|
+
function createBody(config?: Live2DAdapterConfig & { provider?: string }) {
|
|
66
75
|
return isDisabled(config)
|
|
67
76
|
? undefined
|
|
68
77
|
: new Live2DAdapter(config);
|
package/src/runtime.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { SiduriRuntime } from './runtime';
|
|
1
|
+
import { SiduriRuntime, dispatchCompanionChat } from './runtime';
|
|
2
2
|
import { DefaultHandsOrgan } from '@siduri-x/hands';
|
|
3
3
|
import { DefaultEarOrgan } from '@siduri-x/ear';
|
|
4
|
+
import { DefaultMouthOrgan } from '@siduri-x/mouth';
|
|
4
5
|
import { ActionPolicyEngine, RequestContext } from '@siduri-x/core';
|
|
5
6
|
|
|
6
7
|
describe('Siduri Runtime Orchestration', () => {
|
|
@@ -78,7 +79,7 @@ describe('Siduri Runtime Orchestration', () => {
|
|
|
78
79
|
expect(noKnowledgeContext).toBe(true);
|
|
79
80
|
expect(proposedClaims.length).toBe(1);
|
|
80
81
|
expect(proposedClaims[0].subject).toBe("Test");
|
|
81
|
-
expect(proposedClaims[0].scope).toBe("
|
|
82
|
+
expect(proposedClaims[0].scope).toBe("user");
|
|
82
83
|
expect(response.metadata.memory_proposals[0].proposal_id).toBe("claim-1");
|
|
83
84
|
});
|
|
84
85
|
|
|
@@ -94,7 +95,7 @@ describe('Siduri Runtime Orchestration', () => {
|
|
|
94
95
|
riskLevel: 'LOW',
|
|
95
96
|
requiredCapabilities: ['chat:public'],
|
|
96
97
|
},
|
|
97
|
-
execute: async (params) => {
|
|
98
|
+
execute: async (params: any) => {
|
|
98
99
|
toolExecuted = true;
|
|
99
100
|
return { hits: [`Result for ${params.query}`] };
|
|
100
101
|
},
|
|
@@ -146,7 +147,6 @@ describe('Siduri Runtime Orchestration', () => {
|
|
|
146
147
|
},
|
|
147
148
|
conversation: {
|
|
148
149
|
channel: 'direct',
|
|
149
|
-
audienceId: 'audience-direct',
|
|
150
150
|
correlationId: 'corr-alice-123',
|
|
151
151
|
},
|
|
152
152
|
};
|
|
@@ -233,7 +233,6 @@ describe('Siduri Runtime Orchestration', () => {
|
|
|
233
233
|
},
|
|
234
234
|
conversation: {
|
|
235
235
|
channel: 'public',
|
|
236
|
-
audienceId: 'audience-public',
|
|
237
236
|
correlationId: 'corr-bob-999',
|
|
238
237
|
},
|
|
239
238
|
};
|
|
@@ -281,4 +280,57 @@ describe('Siduri Runtime Orchestration', () => {
|
|
|
281
280
|
/Ear text input exceeds maximum allowed length/
|
|
282
281
|
);
|
|
283
282
|
});
|
|
283
|
+
|
|
284
|
+
test('Decoupled Output Delivery: Brain decisions are delivered and formatted through MouthOrgan', async () => {
|
|
285
|
+
let deliveredOutput: any = null;
|
|
286
|
+
const mouth = new DefaultMouthOrgan({
|
|
287
|
+
channels: [
|
|
288
|
+
{
|
|
289
|
+
id: 'test-web-channel',
|
|
290
|
+
name: 'Web Channel',
|
|
291
|
+
medium: 'web',
|
|
292
|
+
deliver: async (out: any) => {
|
|
293
|
+
deliveredOutput = out;
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const mockBrain = {
|
|
300
|
+
generatePlan: async () => ({
|
|
301
|
+
speech: '**Hello** from Siduri!',
|
|
302
|
+
language: 'en',
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const mockMemory = {
|
|
307
|
+
initialize: async () => {},
|
|
308
|
+
searchClaims: async () => [],
|
|
309
|
+
getDirectives: async () => [],
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const runtime = new SiduriRuntime(
|
|
313
|
+
'companion-mouth-test',
|
|
314
|
+
{ name: 'MouthCompanion' } as any,
|
|
315
|
+
{
|
|
316
|
+
brain: mockBrain as any,
|
|
317
|
+
memory: mockMemory as any,
|
|
318
|
+
mouth,
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
await runtime.initialize();
|
|
322
|
+
|
|
323
|
+
const response = await dispatchCompanionChat(runtime, {
|
|
324
|
+
message: 'Hi there',
|
|
325
|
+
role: 'OWNER',
|
|
326
|
+
medium: 'web',
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(response.delivery).toBeDefined();
|
|
330
|
+
expect(response.delivery?.medium).toBe('web');
|
|
331
|
+
expect(response.delivery?.displayText).toBe('**Hello** from Siduri!');
|
|
332
|
+
expect(deliveredOutput).toBeDefined();
|
|
333
|
+
expect(deliveredOutput.medium).toBe('web');
|
|
334
|
+
expect(deliveredOutput.displayText).toBe('**Hello** from Siduri!');
|
|
335
|
+
});
|
|
284
336
|
});
|
|
@@ -161,7 +161,6 @@ describe('T5 Experience Event and Output Adapters Suite', () => {
|
|
|
161
161
|
responseId: 'resp-1',
|
|
162
162
|
correlationId: 'corr-1',
|
|
163
163
|
channel: 'public' as const,
|
|
164
|
-
audienceId: 'audience-public',
|
|
165
164
|
approval: 'STAGED' as any, // Not approved!
|
|
166
165
|
kind: 'voice' as const,
|
|
167
166
|
lifecycle: 'STARTED' as const,
|
package/src/t6-security.test.ts
CHANGED
package/dist/app.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { Express } from 'express';
|
|
2
|
-
import { SiduriRuntime } from './runtime';
|
|
3
|
-
import { FixtureObservationOrgan } from '@siduri-x/observation';
|
|
4
|
-
export interface AppInstance {
|
|
5
|
-
app: Express;
|
|
6
|
-
runtimes: Map<string, SiduriRuntime>;
|
|
7
|
-
setObservationOrgan: (org: FixtureObservationOrgan) => void;
|
|
8
|
-
}
|
|
9
|
-
export declare function createApp(runtimes?: Map<string, SiduriRuntime>): AppInstance;
|