@siduri-x/api 2.0.1 → 2.0.3

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/src/boot.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
2
+ import { SqliteMemoryStore } from '@siduri-x/memory';
3
+ import { VoiceAdapter, VoiceConfig } from '@siduri-x/voice';
4
+ import { UnifiedKnowledgeOrgan, UnifiedKnowledgeConfig } from '@siduri-x/knowledge';
5
+ import { OpenRouterVisionAdapter, OpenRouterVisionConfig } from '@siduri-x/vision';
6
+ import { ActiveSelfCompiler, SqliteSelfRepository } from '@siduri-x/self';
7
+ import { Live2DAdapter, Live2DAdapterConfig } from '@siduri-x/body';
8
+ import { FixtureObservationOrgan } from '@siduri-x/observation';
9
+ import { DefaultHandsOrgan, DefaultHandsOrganConfig } from '@siduri-x/hands';
10
+ import { DefaultEarOrgan, EarOrganConfig } from '@siduri-x/ear';
11
+ import { DefaultMouthOrgan, DefaultMouthOrganConfig } from '@siduri-x/mouth';
12
+ import { SiduriRuntime } from './runtime';
13
+
14
+ export interface AppBrainConfig {
15
+ provider?: 'openrouter' | 'openai-compatible' | string;
16
+ model?: string;
17
+ apiKey?: string;
18
+ apiKeyEnv?: string;
19
+ baseUrl?: string;
20
+ timeoutMs?: number;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface AppBehaviorConfig {
25
+ provider?: 'active_self' | 'none' | string;
26
+ preset?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export interface AppBootCompanionConfig {
31
+ name: string;
32
+ id?: string;
33
+ brain?: AppBrainConfig;
34
+ voice?: VoiceConfig;
35
+ memory?: { provider?: string; connectionString?: string; maxConnections?: number; dbPath?: string; [key: string]: unknown };
36
+ knowledge?: UnifiedKnowledgeConfig;
37
+ vision?: OpenRouterVisionConfig;
38
+ behavior?: AppBehaviorConfig;
39
+ body?: Live2DAdapterConfig;
40
+ hands?: DefaultHandsOrganConfig;
41
+ ear?: EarOrganConfig;
42
+ mouth?: DefaultMouthOrganConfig;
43
+ self?: { dbPath?: string; [key: string]: unknown };
44
+ organs?: Record<string, any>;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ export interface BootCompanionOptions {
49
+ observationOrgan?: FixtureObservationOrgan;
50
+ }
51
+
52
+ export function isDisabled(config?: { provider?: string }): boolean {
53
+ return !config || config.provider === 'none';
54
+ }
55
+
56
+ export function createBrain(config?: AppBrainConfig) {
57
+ const provider = config?.provider || 'openrouter';
58
+ const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
59
+ const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
60
+ if (provider === 'openai-compatible') {
61
+ return new OpenAICompatibleBrain({
62
+ apiKey,
63
+ model: config?.model || 'local-model',
64
+ baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
65
+ });
66
+ }
67
+ return new OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
68
+ }
69
+
70
+ export function createVoice(config?: VoiceConfig) {
71
+ return isDisabled(config)
72
+ ? undefined
73
+ : new VoiceAdapter({
74
+ provider: (config?.provider as any) || 'voicevox',
75
+ baseUrl: config?.baseUrl || process.env.VOICEVOX_URL || 'http://localhost:50021',
76
+ speakerId: config?.speakerId || 1,
77
+ ...config,
78
+ });
79
+ }
80
+
81
+ export function createKnowledge(config?: UnifiedKnowledgeConfig) {
82
+ if (isDisabled(config)) return undefined;
83
+ const dbPath = (config?.dbPath as string) || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite';
84
+ return new UnifiedKnowledgeOrgan({
85
+ ...config,
86
+ dbPath,
87
+ lifeDatabase: config?.lifeDatabase ?? true,
88
+ });
89
+ }
90
+
91
+ export function createVision(config?: OpenRouterVisionConfig & { provider?: string }) {
92
+ return isDisabled(config)
93
+ ? undefined
94
+ : new OpenRouterVisionAdapter({
95
+ apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
96
+ model: config?.model || 'gpt-4-vision',
97
+ ...config,
98
+ });
99
+ }
100
+
101
+ export function createBehavior(config?: AppBehaviorConfig) {
102
+ return isDisabled(config) ? undefined : new ActiveSelfCompiler();
103
+ }
104
+
105
+ export function createBody(config?: Live2DAdapterConfig & { provider?: string }) {
106
+ return isDisabled(config)
107
+ ? undefined
108
+ : new Live2DAdapter(config);
109
+ }
110
+
111
+ export function createHands(config?: DefaultHandsOrganConfig & { provider?: string }) {
112
+ return isDisabled(config)
113
+ ? new DefaultHandsOrgan()
114
+ : new DefaultHandsOrgan(config);
115
+ }
116
+
117
+ export function createEar(config?: EarOrganConfig & { provider?: string }) {
118
+ return isDisabled(config)
119
+ ? new DefaultEarOrgan()
120
+ : new DefaultEarOrgan(config);
121
+ }
122
+
123
+ export function createMouth(config?: DefaultMouthOrganConfig & { provider?: string }, voice?: any) {
124
+ return isDisabled(config)
125
+ ? new DefaultMouthOrgan({ voice })
126
+ : new DefaultMouthOrgan({ ...config, voice });
127
+ }
128
+
129
+ export function createMemory(config?: { provider?: string; connectionString?: string; maxConnections?: number; dbPath?: string }) {
130
+ if (isDisabled(config)) return undefined;
131
+ return new SqliteMemoryStore({ dbPath: config?.dbPath || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite' });
132
+ }
133
+
134
+ export function createSelf(config?: { dbPath?: string }) {
135
+ return new SqliteSelfRepository({ dbPath: config?.dbPath || process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite' });
136
+ }
137
+
138
+ export function createObservation(vision?: any): FixtureObservationOrgan {
139
+ return new FixtureObservationOrgan(
140
+ vision ?? { analyze: async () => JSON.stringify({ readings: [] }) }
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Canonical companion bootstrapper.
146
+ * Wires all 10 organs, runs memory migrations, and initializes the SiduriRuntime.
147
+ */
148
+ export async function bootCompanion(
149
+ id: string,
150
+ config: AppBootCompanionConfig,
151
+ options?: BootCompanionOptions
152
+ ): Promise<SiduriRuntime> {
153
+ const organs = (config as any)?.organs || {};
154
+
155
+ const brain = createBrain(organs.brain || config?.brain);
156
+ const memory = createMemory(organs.memory || (config as any)?.memory);
157
+ const selfRepo = createSelf(organs.self || (config as any)?.self);
158
+ const voice = createVoice(organs.voice || config?.voice);
159
+ const knowledge = createKnowledge(organs.knowledge || config?.knowledge);
160
+ const vision = createVision(organs.vision || config?.vision);
161
+ const behavior = createBehavior(organs.behavior || config?.behavior);
162
+ const body = createBody(organs.body || config?.body);
163
+ const hands = createHands(organs.hands || config?.hands);
164
+ const ear = createEar(organs.ear || config?.ear);
165
+ const mouth = createMouth(organs.mouth || config?.mouth, voice);
166
+ const observation = options?.observationOrgan;
167
+
168
+ if (memory && typeof (memory as any).runMigrations === 'function') {
169
+ await (memory as any).runMigrations().catch((e: any) => console.warn("Migrations warning:", e.message));
170
+ }
171
+
172
+ const runtime = new SiduriRuntime(id, config as any, {
173
+ brain,
174
+ memory,
175
+ voice,
176
+ knowledge,
177
+ vision,
178
+ behavior,
179
+ body,
180
+ hands,
181
+ ear,
182
+ mouth,
183
+ observation,
184
+ self: selfRepo,
185
+ externalKnowledge: (knowledge as any)?.eAdapter ?? knowledge,
186
+ });
187
+
188
+ await runtime.initialize();
189
+ return runtime;
190
+ }
@@ -176,5 +176,144 @@ describe('API Request Context Mapper (Single-Owner, Single-Machine)', () => {
176
176
  expect(result.context?.actor.capabilities).toEqual(['chat']);
177
177
  expect(result.context?.actor.authorizationRole).toBe('viewer');
178
178
  expect(result.context?.source).toBe('external');
179
+ expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
180
+ expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
181
+ });
182
+
183
+ test('Prevents attenuated caller from escalating to administrator role or forging elevated capabilities', () => {
184
+ const attenuatedPayload = {
185
+ companionId: 'companion-a',
186
+ authenticated: true,
187
+ role: 'VIEWER',
188
+ source: 'external',
189
+ context: {
190
+ actor: {
191
+ actorId: 'visitor-bob',
192
+ sessionId: 'session-vis-1',
193
+ authorizationRole: 'administrator', // Forged owner/admin role
194
+ capabilities: ['chat', 'system', 'root:manage', 'action:execute'], // Forged system/root capabilities
195
+ },
196
+ conversation: {
197
+ correlationId: 'corr-att-escalate',
198
+ },
199
+ },
200
+ };
201
+
202
+ const result = mapRequestContext(attenuatedPayload);
203
+ expect(result.accepted).toBe(true);
204
+ expect(result.context?.actor.authenticated).toBe(true);
205
+ expect(result.context?.actor.authorizationRole).toBe('viewer');
206
+ expect(result.context?.actor.capabilities).toEqual(['chat']);
207
+ expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
208
+ expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
209
+ });
210
+
211
+ test('Strips unknown capabilities for authenticated owner while preserving canonical companion capabilities', () => {
212
+ const ownerPayload = {
213
+ companionId: 'companion-a',
214
+ authenticated: true,
215
+ source: 'local',
216
+ context: {
217
+ actor: {
218
+ actorId: 'owner-user',
219
+ sessionId: 'session-owner-1',
220
+ capabilities: ['chat', 'system', 'root:unauthorized', 'arbitrary:hack'],
221
+ },
222
+ conversation: {
223
+ correlationId: 'corr-owner-caps',
224
+ },
225
+ },
226
+ };
227
+
228
+ const result = mapRequestContext(ownerPayload);
229
+ expect(result.accepted).toBe(true);
230
+ expect(result.context?.actor.authorizationRole).toBe('administrator');
231
+ expect(result.context?.actor.capabilities).toEqual(['chat', 'system']);
232
+ expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
233
+ });
234
+
235
+ test('Flat envelope: suppresses role and capability escalation when serverRole is VIEWER', () => {
236
+ const viewerPayload = {
237
+ companionId: 'companion-a',
238
+ authenticated: false,
239
+ serverRole: 'VIEWER',
240
+ role: 'OWNER', // Forged in flat envelope
241
+ capabilities: ['chat', 'system', 'action:execute'],
242
+ generateCorrelationId: true,
243
+ };
244
+
245
+ const result = mapRequestContext(viewerPayload);
246
+ expect(result.accepted).toBe(true);
247
+ expect(result.context?.actor.authorizationRole).toBe('viewer');
248
+ expect(result.context?.actor.capabilities).toEqual(['chat']);
249
+ expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
250
+ });
251
+
252
+ test('Authenticated owner receives canonical administrator role and capabilities, and can safely attenuate to viewer', () => {
253
+ const ownerPayload = {
254
+ companionId: 'companion-a',
255
+ authenticated: true,
256
+ serverRole: 'OWNER',
257
+ generateCorrelationId: true,
258
+ };
259
+
260
+ const ownerResult = mapRequestContext(ownerPayload);
261
+ expect(ownerResult.accepted).toBe(true);
262
+ expect(ownerResult.context?.actor.authorizationRole).toBe('administrator');
263
+ expect(ownerResult.context?.actor.capabilities).toEqual([
264
+ 'chat',
265
+ 'memory:approve',
266
+ 'action:execute',
267
+ 'system',
268
+ ]);
269
+
270
+ // Attenuation to viewer
271
+ const attenuatedPayload = {
272
+ ...ownerPayload,
273
+ role: 'VIEWER',
274
+ };
275
+ const attenuatedResult = mapRequestContext(attenuatedPayload);
276
+ expect(attenuatedResult.accepted).toBe(true);
277
+ expect(attenuatedResult.context?.actor.authorizationRole).toBe('viewer');
278
+ expect(attenuatedResult.diagnostics).not.toContain('role_escalation_attempt_suppressed');
279
+ });
280
+
281
+ describe('Mode Mapping (Casual, Teach, Hybrid)', () => {
282
+ test('maps explicit mode parameter to RequestContext', () => {
283
+ const casualResult = mapRequestContext({
284
+ companionId: 'comp-1',
285
+ mode: 'casual',
286
+ generateCorrelationId: true,
287
+ });
288
+ expect(casualResult.accepted).toBe(true);
289
+ expect(casualResult.context?.mode).toBe('casual');
290
+
291
+ const teachResult = mapRequestContext({
292
+ companionId: 'comp-1',
293
+ mode: 'teach',
294
+ generateCorrelationId: true,
295
+ });
296
+ expect(teachResult.accepted).toBe(true);
297
+ expect(teachResult.context?.mode).toBe('teach');
298
+
299
+ const hybridResult = mapRequestContext({
300
+ companionId: 'comp-1',
301
+ mode: 'hybrid',
302
+ generateCorrelationId: true,
303
+ });
304
+ expect(hybridResult.accepted).toBe(true);
305
+ expect(hybridResult.context?.mode).toBe('hybrid');
306
+ });
307
+
308
+ test('rejects invalid interaction mode', () => {
309
+ const invalidResult = mapRequestContext({
310
+ companionId: 'comp-1',
311
+ mode: 'super_turbo',
312
+ generateCorrelationId: true,
313
+ });
314
+ expect(invalidResult.accepted).toBe(false);
315
+ expect(invalidResult.error?.code).toBe('INVALID_CONTEXT');
316
+ expect(invalidResult.error?.field).toBe('mode');
317
+ });
179
318
  });
180
319
  });
@@ -20,8 +20,10 @@ export interface MapRequestContextResult {
20
20
  /**
21
21
  * Maps incoming HTTP requests to a canonical RequestContext.
22
22
  * In a single-owner, single-machine model:
23
- * - Security is enforced at the external boundary, not internally between roles.
23
+ * - Security is enforced at the external machine boundary, NOT internally between roles.
24
24
  * - No internal audience or viewer/operator/owner role hierarchies.
25
+ * - Authenticated callers are the single owner with full companion access.
26
+ * - Unauthenticated callers are bounded to public chat.
25
27
  */
26
28
  export function mapRequestContext(
27
29
  input: any,
@@ -43,7 +45,10 @@ export function mapRequestContext(
43
45
  if (input.context && typeof input.context === 'object') {
44
46
  const rawCtx = input.context;
45
47
  const companionId = input.companionId || rawCtx.companionId || input.id;
46
- const correlationId = rawCtx.conversation?.correlationId || input.correlationId;
48
+ const correlationId =
49
+ rawCtx.conversation?.correlationId ||
50
+ input.correlationId ||
51
+ (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
47
52
 
48
53
  if (!companionId) {
49
54
  return {
@@ -81,18 +86,46 @@ export function mapRequestContext(
81
86
  };
82
87
  }
83
88
 
84
- // Determine server-enforced authentication status
89
+ const isViewerRequested =
90
+ (typeof input.role === 'string' && input.role.toLowerCase() === 'viewer') ||
91
+ (typeof input.serverRole === 'string' && input.serverRole.toLowerCase() === 'viewer') ||
92
+ (typeof actor.authorizationRole === 'string' && actor.authorizationRole.toLowerCase() === 'viewer') ||
93
+ (typeof (actor as any).role === 'string' && (actor as any).role.toLowerCase() === 'viewer');
94
+
95
+ // Determine server-enforced authentication status at the machine boundary
85
96
  const isAuthenticated = input.authenticated !== undefined
86
97
  ? Boolean(input.authenticated)
87
98
  : (actor.authenticated !== undefined ? Boolean(actor.authenticated) : true);
88
99
 
89
- const safeCapabilities = isAuthenticated
90
- ? (Array.isArray(actor.capabilities) ? actor.capabilities : ['chat'])
91
- : ['chat'];
100
+ const isViewer = !isAuthenticated || isViewerRequested;
101
+
102
+ const CANONICAL_CAPABILITIES = new Set(['chat', 'memory:approve', 'action:execute', 'system']);
103
+ const DEFAULT_OWNER_CAPABILITIES = ['chat', 'memory:approve', 'action:execute', 'system'];
92
104
 
93
- const safeRole = isAuthenticated
94
- ? actor.authorizationRole
95
- : 'viewer';
105
+ let safeCapabilities: string[];
106
+ let authorizationRole: string;
107
+
108
+ if (isViewer) {
109
+ safeCapabilities = ['chat'];
110
+ authorizationRole = 'viewer';
111
+ if (Array.isArray(actor.capabilities) && actor.capabilities.some((c: string) => c !== 'chat' && c !== 'chat:public')) {
112
+ diagnostics.push('capability_escalation_attempt_suppressed');
113
+ }
114
+ if (actor.authorizationRole && actor.authorizationRole.toLowerCase() !== 'viewer') {
115
+ diagnostics.push('role_escalation_attempt_suppressed');
116
+ }
117
+ } else {
118
+ authorizationRole = 'administrator';
119
+ if (Array.isArray(actor.capabilities) && actor.capabilities.length > 0) {
120
+ const filtered = actor.capabilities.filter((c: string) => CANONICAL_CAPABILITIES.has(c));
121
+ if (filtered.length < actor.capabilities.length) {
122
+ diagnostics.push('capability_escalation_attempt_suppressed');
123
+ }
124
+ safeCapabilities = filtered.length > 0 ? filtered : [...DEFAULT_OWNER_CAPABILITIES];
125
+ } else {
126
+ safeCapabilities = [...DEFAULT_OWNER_CAPABILITIES];
127
+ }
128
+ }
96
129
 
97
130
  const constructed: RequestContext = {
98
131
  companionId,
@@ -102,7 +135,7 @@ export function mapRequestContext(
102
135
  sessionId: actor.sessionId,
103
136
  capabilities: safeCapabilities,
104
137
  authenticated: isAuthenticated,
105
- authorizationRole: safeRole,
138
+ authorizationRole,
106
139
  },
107
140
  conversation: {
108
141
  correlationId,
@@ -112,6 +145,7 @@ export function mapRequestContext(
112
145
  },
113
146
  source: input.source || rawCtx.source || (isAuthenticated ? 'local' : 'external'),
114
147
  subject: rawCtx.subject,
148
+ mode: rawCtx.mode || input.mode,
115
149
  };
116
150
 
117
151
  const validated = validateRequestContext(constructed);
@@ -167,20 +201,52 @@ export function mapRequestContext(
167
201
  };
168
202
  }
169
203
 
170
- const actorId = input.actorId || input.actor?.actorId || 'local-user';
204
+ const isViewerRequested =
205
+ (typeof input.role === 'string' && input.role.toLowerCase() === 'viewer') ||
206
+ (typeof input.serverRole === 'string' && input.serverRole.toLowerCase() === 'viewer');
207
+
208
+ const isAuthenticated = input.authenticated !== undefined ? Boolean(input.authenticated) : true;
209
+ const isViewer = !isAuthenticated || isViewerRequested;
210
+
211
+ const actorId = input.actorId || input.actor?.actorId || (isAuthenticated && !isViewer ? 'local-user' : 'anonymous-session');
171
212
  const sessionId = input.sessionId || input.actor?.sessionId || `session-${Date.now()}`;
172
213
  if (!input.actorId && !input.actor?.actorId) {
173
214
  diagnostics.push('anonymous_session_generated');
174
215
  }
175
216
 
176
- const isAuthenticated = input.authenticated !== undefined ? Boolean(input.authenticated) : true;
177
- const capabilities = isAuthenticated
178
- ? (Array.isArray(input.capabilities)
179
- ? input.capabilities
180
- : Array.isArray(input.actor?.capabilities)
181
- ? input.actor.capabilities
182
- : ['chat', 'system'])
183
- : ['chat'];
217
+ const CANONICAL_CAPABILITIES = new Set(['chat', 'memory:approve', 'action:execute', 'system']);
218
+ const DEFAULT_OWNER_CAPABILITIES = ['chat', 'memory:approve', 'action:execute', 'system'];
219
+
220
+ const rawCaps = Array.isArray(input.capabilities)
221
+ ? input.capabilities
222
+ : Array.isArray(input.actor?.capabilities)
223
+ ? input.actor.capabilities
224
+ : undefined;
225
+
226
+ let capabilities: string[];
227
+ let authorizationRole: string;
228
+
229
+ if (isViewer) {
230
+ capabilities = ['chat'];
231
+ authorizationRole = 'viewer';
232
+ if (rawCaps && rawCaps.some((c: string) => c !== 'chat' && c !== 'chat:public')) {
233
+ diagnostics.push('capability_escalation_attempt_suppressed');
234
+ }
235
+ if (input.role && input.role.toLowerCase() !== 'viewer') {
236
+ diagnostics.push('role_escalation_attempt_suppressed');
237
+ }
238
+ } else {
239
+ authorizationRole = 'administrator';
240
+ if (rawCaps && rawCaps.length > 0) {
241
+ const filtered = rawCaps.filter((c: string) => CANONICAL_CAPABILITIES.has(c));
242
+ if (filtered.length < rawCaps.length) {
243
+ diagnostics.push('capability_escalation_attempt_suppressed');
244
+ }
245
+ capabilities = filtered.length > 0 ? filtered : [...DEFAULT_OWNER_CAPABILITIES];
246
+ } else {
247
+ capabilities = [...DEFAULT_OWNER_CAPABILITIES];
248
+ }
249
+ }
184
250
 
185
251
  const mappedContext: RequestContext = {
186
252
  companionId,
@@ -189,7 +255,7 @@ export function mapRequestContext(
189
255
  sessionId,
190
256
  capabilities,
191
257
  authenticated: isAuthenticated,
192
- authorizationRole: input.role ? (input.role.toLowerCase() === 'viewer' ? 'viewer' : 'administrator') : undefined,
258
+ authorizationRole,
193
259
  },
194
260
  conversation: {
195
261
  channel: input.channel || input.conversation?.channel || 'direct',
@@ -197,6 +263,7 @@ export function mapRequestContext(
197
263
  },
198
264
  source: input.source || (isAuthenticated ? 'local' : 'external'),
199
265
  subject: input.subject,
266
+ mode: input.mode || input.conversation?.mode,
200
267
  };
201
268
 
202
269
  const validated = validateRequestContext(mappedContext);
package/src/index.test.ts CHANGED
@@ -127,7 +127,7 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
127
127
  });
128
128
 
129
129
  test('handles barge-in interruption via POST /chat/interrupt', async () => {
130
- fakeRuntime.interruptMouth = jest.fn();
130
+ fakeRuntime.mouth = { interrupt: jest.fn() };
131
131
 
132
132
  const res = await request(app)
133
133
  .post('/chat/interrupt')
@@ -138,11 +138,11 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
138
138
 
139
139
  expect(res.status).toBe(200);
140
140
  expect(res.body.interrupted).toBe(true);
141
- expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_stop');
141
+ expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_stop');
142
142
  });
143
143
 
144
144
  test('handles mouth interruption via POST /mouth/interrupt', async () => {
145
- fakeRuntime.interruptMouth = jest.fn();
145
+ fakeRuntime.mouth = { interrupt: jest.fn() };
146
146
 
147
147
  const res = await request(app)
148
148
  .post('/mouth/interrupt')
@@ -153,6 +153,26 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
153
153
 
154
154
  expect(res.status).toBe(200);
155
155
  expect(res.body.interrupted).toBe(true);
156
- expect(fakeRuntime.interruptMouth).toHaveBeenCalledWith('user_barge_in');
156
+ expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_barge_in');
157
+ });
158
+
159
+ test('POST /chat passes explicit interaction mode (casual, teach, hybrid) to runtime', async () => {
160
+ const res = await request(app)
161
+ .post('/chat')
162
+ .send({
163
+ id: 'companion-a',
164
+ message: 'Casual banter',
165
+ mode: 'casual',
166
+ });
167
+
168
+ expect(res.status).toBe(200);
169
+ expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
170
+ 'Casual banter',
171
+ expect.objectContaining({
172
+ companionId: 'companion-a',
173
+ mode: 'casual',
174
+ }),
175
+ []
176
+ );
157
177
  });
158
178
  });