@lumenflow/surfaces 5.1.0

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.
Files changed (36) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +40 -0
  3. package/cli/__tests__/gates.test.ts +97 -0
  4. package/cli/__tests__/inspect.test.ts +184 -0
  5. package/cli/__tests__/task-lifecycle.test.ts +203 -0
  6. package/cli/gates.ts +46 -0
  7. package/cli/index.ts +6 -0
  8. package/cli/inspect.ts +138 -0
  9. package/cli/task-lifecycle.ts +46 -0
  10. package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
  11. package/http/__tests__/auth-boundary.test.ts +57 -0
  12. package/http/__tests__/channel-send-governance.test.ts +158 -0
  13. package/http/__tests__/event-stream.test.ts +340 -0
  14. package/http/__tests__/phone-device-tool-api.test.ts +177 -0
  15. package/http/__tests__/remote-exposure.test.ts +212 -0
  16. package/http/__tests__/run-agent.test.ts +447 -0
  17. package/http/__tests__/scope-enforcement.test.ts +349 -0
  18. package/http/__tests__/sidecar-entry.test.ts +158 -0
  19. package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
  20. package/http/__tests__/tool-api.test.ts +491 -0
  21. package/http/__tests__/tool-discovery.test.ts +384 -0
  22. package/http/ag-ui-adapter.ts +352 -0
  23. package/http/auth.ts +294 -0
  24. package/http/control-plane-event-subscriber.ts +233 -0
  25. package/http/event-stream.ts +216 -0
  26. package/http/index.ts +10 -0
  27. package/http/run-agent.ts +416 -0
  28. package/http/server.ts +329 -0
  29. package/http/sidecar-entry.ts +218 -0
  30. package/http/task-api.ts +307 -0
  31. package/http/tool-api.ts +373 -0
  32. package/http/tool-discovery.ts +159 -0
  33. package/mcp/__tests__/server.test.ts +554 -0
  34. package/mcp/index.ts +4 -0
  35. package/mcp/server.ts +250 -0
  36. package/package.json +51 -0
@@ -0,0 +1,447 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
5
+ import { EventEmitter } from 'node:events';
6
+ import { PassThrough } from 'node:stream';
7
+ import type {
8
+ Disposable,
9
+ KernelEvent,
10
+ KernelRuntime,
11
+ ReplayFilter,
12
+ TaskSpec,
13
+ } from '@lumenflow/kernel';
14
+ import { describe, expect, it, vi } from 'vitest';
15
+ import { createRunAgentRouter, type RunAgentConfig, type RunAgentInput } from '../run-agent.js';
16
+ import { AG_UI_EVENT_TYPES } from '../ag-ui-adapter.js';
17
+ import type { EventSubscriber } from '../event-stream.js';
18
+
19
+ // --- Constants ---
20
+
21
+ const WORKSPACE_ID = 'ws-test-1923';
22
+
23
+ const HTTP_METHOD = {
24
+ POST: 'POST',
25
+ GET: 'GET',
26
+ } as const;
27
+
28
+ const HTTP_STATUS = {
29
+ OK: 200,
30
+ BAD_REQUEST: 400,
31
+ METHOD_NOT_ALLOWED: 405,
32
+ } as const;
33
+
34
+ const HTTP_HEADERS = {
35
+ CONTENT_TYPE: 'content-type',
36
+ } as const;
37
+
38
+ const CONTENT_TYPE = {
39
+ JSON: 'application/json; charset=utf-8',
40
+ EVENT_STREAM: 'text/event-stream; charset=utf-8',
41
+ } as const;
42
+
43
+ const TIMESTAMP = {
44
+ ZERO: '2026-02-20T00:00:00.000Z',
45
+ } as const;
46
+
47
+ const RUN_ID = 'run-task-1923-1';
48
+
49
+ // --- Test helpers ---
50
+
51
+ class MockResponse extends EventEmitter {
52
+ statusCode = HTTP_STATUS.OK;
53
+ body = '';
54
+ readonly headers = new Map<string, string>();
55
+ ended = false;
56
+
57
+ setHeader(name: string, value: string | number | readonly string[]): this {
58
+ this.headers.set(name.toLowerCase(), String(value));
59
+ return this;
60
+ }
61
+
62
+ write(chunk: string | Buffer): boolean {
63
+ this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
64
+ return true;
65
+ }
66
+
67
+ end(chunk?: string | Buffer): this {
68
+ if (chunk !== undefined) {
69
+ this.write(chunk);
70
+ }
71
+ this.ended = true;
72
+ this.emit('finish');
73
+ return this;
74
+ }
75
+ }
76
+
77
+ function createRequest(options: { method: string; url: string; body?: unknown }): IncomingMessage {
78
+ const request = new PassThrough() as unknown as IncomingMessage & {
79
+ method: string;
80
+ url: string;
81
+ headers: IncomingHttpHeaders;
82
+ };
83
+
84
+ request.method = options.method;
85
+ request.url = options.url;
86
+ request.headers = {
87
+ [HTTP_HEADERS.CONTENT_TYPE]: CONTENT_TYPE.JSON,
88
+ };
89
+
90
+ const payload = options.body === undefined ? '' : JSON.stringify(options.body);
91
+ (request as unknown as PassThrough).end(payload);
92
+ return request;
93
+ }
94
+
95
+ function createRunAgentInput(): RunAgentInput {
96
+ return {
97
+ threadId: 'thread-1923',
98
+ runId: 'copilot-run-1923',
99
+ messages: [
100
+ {
101
+ id: 'msg-1',
102
+ role: 'user',
103
+ content: 'List all tasks',
104
+ },
105
+ ],
106
+ tools: [
107
+ {
108
+ name: 'task.list',
109
+ description: 'Lists available tasks',
110
+ parameters: { type: 'object', properties: {} },
111
+ },
112
+ ],
113
+ };
114
+ }
115
+
116
+ function createRuntimeStub(): Pick<
117
+ KernelRuntime,
118
+ 'createTask' | 'claimTask' | 'completeTask' | 'inspectTask'
119
+ > {
120
+ return {
121
+ createTask: vi.fn(async (taskSpec: TaskSpec) => ({
122
+ task: taskSpec,
123
+ task_spec_path: `/tmp/${taskSpec.id}.yaml`,
124
+ event: {
125
+ schema_version: 1,
126
+ kind: 'task_created' as const,
127
+ task_id: taskSpec.id,
128
+ timestamp: TIMESTAMP.ZERO,
129
+ spec_hash: 'spec-hash',
130
+ },
131
+ })),
132
+ claimTask: vi.fn(async (input) => ({
133
+ task_id: input.task_id,
134
+ run: {
135
+ run_id: RUN_ID,
136
+ task_id: input.task_id,
137
+ status: 'executing',
138
+ started_at: TIMESTAMP.ZERO,
139
+ by: input.by,
140
+ session_id: input.session_id,
141
+ },
142
+ events: [
143
+ {
144
+ schema_version: 1,
145
+ kind: 'task_claimed' as const,
146
+ task_id: input.task_id,
147
+ timestamp: TIMESTAMP.ZERO,
148
+ by: input.by,
149
+ session_id: input.session_id,
150
+ },
151
+ {
152
+ schema_version: 1,
153
+ kind: 'run_started' as const,
154
+ task_id: input.task_id,
155
+ run_id: RUN_ID,
156
+ timestamp: TIMESTAMP.ZERO,
157
+ by: input.by,
158
+ session_id: input.session_id,
159
+ },
160
+ ],
161
+ policy: { decision: 'allow', decisions: [] },
162
+ })),
163
+ completeTask: vi.fn(async (input) => ({
164
+ task_id: input.task_id,
165
+ run_id: input.run_id ?? RUN_ID,
166
+ events: [],
167
+ policy: { decision: 'allow', decisions: [] },
168
+ })),
169
+ inspectTask: vi.fn(async (taskId: string) => ({ task_id: taskId })),
170
+ };
171
+ }
172
+
173
+ function createEventSubscriberStub(): {
174
+ subscriber: EventSubscriber;
175
+ triggerEvent: (event: KernelEvent) => void;
176
+ dispose: ReturnType<typeof vi.fn>;
177
+ } {
178
+ const dispose = vi.fn();
179
+ let callback: ((event: KernelEvent) => void | Promise<void>) | null = null;
180
+
181
+ return {
182
+ subscriber: {
183
+ subscribe: vi.fn((_filter: ReplayFilter, cb: (event: KernelEvent) => void) => {
184
+ callback = cb;
185
+ return { dispose } satisfies Disposable;
186
+ }),
187
+ },
188
+ triggerEvent: (event: KernelEvent) => {
189
+ callback?.(event);
190
+ },
191
+ dispose,
192
+ };
193
+ }
194
+
195
+ function createDefaultConfig(): RunAgentConfig {
196
+ return {
197
+ workspaceId: WORKSPACE_ID,
198
+ };
199
+ }
200
+
201
+ // --- Tests ---
202
+
203
+ describe('RunAgent workspace_id from config (B-WSVAL)', () => {
204
+ it('passes workspace_id from config to createTask, not hardcoded ag-ui', async () => {
205
+ const runtime = createRuntimeStub();
206
+ const eventStub = createEventSubscriberStub();
207
+ const config = createDefaultConfig();
208
+
209
+ const router = createRunAgentRouter(
210
+ runtime as unknown as KernelRuntime,
211
+ eventStub.subscriber,
212
+ config,
213
+ );
214
+
215
+ const request = createRequest({
216
+ method: HTTP_METHOD.POST,
217
+ url: '/ag-ui/v1/run',
218
+ body: createRunAgentInput(),
219
+ });
220
+ const response = new MockResponse();
221
+
222
+ await router.handleRequest(
223
+ request as IncomingMessage,
224
+ response as unknown as ServerResponse<IncomingMessage>,
225
+ );
226
+
227
+ // Allow time for async event processing
228
+ const createTaskArg = (runtime.createTask as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
229
+ expect(createTaskArg).toBeDefined();
230
+ expect(createTaskArg.workspace_id).toBe(WORKSPACE_ID);
231
+ expect(createTaskArg.workspace_id).not.toBe('ag-ui');
232
+ });
233
+ });
234
+
235
+ describe('RunAgent acceptance non-empty (B-ACCEPT)', () => {
236
+ it('builds TaskSpec with at least one acceptance criterion', async () => {
237
+ const runtime = createRuntimeStub();
238
+ const eventStub = createEventSubscriberStub();
239
+ const config = createDefaultConfig();
240
+
241
+ const router = createRunAgentRouter(
242
+ runtime as unknown as KernelRuntime,
243
+ eventStub.subscriber,
244
+ config,
245
+ );
246
+
247
+ const request = createRequest({
248
+ method: HTTP_METHOD.POST,
249
+ url: '/ag-ui/v1/run',
250
+ body: createRunAgentInput(),
251
+ });
252
+ const response = new MockResponse();
253
+
254
+ await router.handleRequest(
255
+ request as IncomingMessage,
256
+ response as unknown as ServerResponse<IncomingMessage>,
257
+ );
258
+
259
+ const createTaskArg = (runtime.createTask as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
260
+ expect(createTaskArg).toBeDefined();
261
+ expect(Array.isArray(createTaskArg.acceptance)).toBe(true);
262
+ expect(createTaskArg.acceptance.length).toBeGreaterThanOrEqual(1);
263
+ expect(typeof createTaskArg.acceptance[0]).toBe('string');
264
+ expect(createTaskArg.acceptance[0].length).toBeGreaterThan(0);
265
+ });
266
+ });
267
+
268
+ describe('RunAgent subscribes to events and streams them (B-RUNAG)', () => {
269
+ it('subscribes to task events via EventSubscriber', async () => {
270
+ const runtime = createRuntimeStub();
271
+ const eventStub = createEventSubscriberStub();
272
+ const config = createDefaultConfig();
273
+
274
+ const router = createRunAgentRouter(
275
+ runtime as unknown as KernelRuntime,
276
+ eventStub.subscriber,
277
+ config,
278
+ );
279
+
280
+ const request = createRequest({
281
+ method: HTTP_METHOD.POST,
282
+ url: '/ag-ui/v1/run',
283
+ body: createRunAgentInput(),
284
+ });
285
+ const response = new MockResponse();
286
+
287
+ await router.handleRequest(
288
+ request as IncomingMessage,
289
+ response as unknown as ServerResponse<IncomingMessage>,
290
+ );
291
+
292
+ expect(eventStub.subscriber.subscribe).toHaveBeenCalledTimes(1);
293
+ });
294
+
295
+ it('streams kernel events as AG-UI events to the response', async () => {
296
+ const runtime = createRuntimeStub();
297
+ const eventStub = createEventSubscriberStub();
298
+ const config = createDefaultConfig();
299
+
300
+ const router = createRunAgentRouter(
301
+ runtime as unknown as KernelRuntime,
302
+ eventStub.subscriber,
303
+ config,
304
+ );
305
+
306
+ const request = createRequest({
307
+ method: HTTP_METHOD.POST,
308
+ url: '/ag-ui/v1/run',
309
+ body: createRunAgentInput(),
310
+ });
311
+ const response = new MockResponse();
312
+
313
+ await router.handleRequest(
314
+ request as IncomingMessage,
315
+ response as unknown as ServerResponse<IncomingMessage>,
316
+ );
317
+
318
+ // Simulate a kernel event being emitted
319
+ const kernelEvent = {
320
+ schema_version: 1,
321
+ kind: 'task_claimed',
322
+ task_id: 'some-task-id',
323
+ timestamp: TIMESTAMP.ZERO,
324
+ by: 'test',
325
+ session_id: 'test-session',
326
+ } as unknown as KernelEvent;
327
+
328
+ eventStub.triggerEvent(kernelEvent);
329
+
330
+ // The streamed event should appear in the response body
331
+ const lines = response.body.split('\n').filter((line) => line.trim().length > 0);
332
+ // Should have at least RUN_STARTED + the streamed kernel event
333
+ expect(lines.length).toBeGreaterThanOrEqual(2);
334
+ });
335
+
336
+ it('emits RUN_COMPLETED only when task_completed kernel event arrives', async () => {
337
+ const runtime = createRuntimeStub();
338
+ const eventStub = createEventSubscriberStub();
339
+ const config = createDefaultConfig();
340
+
341
+ const router = createRunAgentRouter(
342
+ runtime as unknown as KernelRuntime,
343
+ eventStub.subscriber,
344
+ config,
345
+ );
346
+
347
+ const request = createRequest({
348
+ method: HTTP_METHOD.POST,
349
+ url: '/ag-ui/v1/run',
350
+ body: createRunAgentInput(),
351
+ });
352
+ const response = new MockResponse();
353
+
354
+ // handleRequest returns but should NOT have ended the response yet
355
+ // (it stays open waiting for events)
356
+ const handlePromise = router.handleRequest(
357
+ request as IncomingMessage,
358
+ response as unknown as ServerResponse<IncomingMessage>,
359
+ );
360
+
361
+ // Wait for the handler to set up
362
+ await handlePromise;
363
+
364
+ // Before task_completed, the response should NOT contain RUN_COMPLETED
365
+ const linesBeforeComplete = response.body.split('\n').filter((line) => line.trim().length > 0);
366
+ const hasRunCompletedBefore = linesBeforeComplete.some((line) => {
367
+ try {
368
+ const parsed = JSON.parse(line);
369
+ return parsed.type === AG_UI_EVENT_TYPES.RUN_COMPLETED;
370
+ } catch {
371
+ return false;
372
+ }
373
+ });
374
+ expect(hasRunCompletedBefore).toBe(false);
375
+
376
+ // Now emit the task_completed kernel event
377
+ const taskCompletedEvent = {
378
+ schema_version: 1,
379
+ kind: 'task_completed',
380
+ task_id: 'some-task-id',
381
+ timestamp: '2026-02-20T00:00:05.000Z',
382
+ } as unknown as KernelEvent;
383
+
384
+ eventStub.triggerEvent(taskCompletedEvent);
385
+
386
+ // Now RUN_COMPLETED should appear
387
+ const linesAfterComplete = response.body.split('\n').filter((line) => line.trim().length > 0);
388
+ const hasRunCompletedAfter = linesAfterComplete.some((line) => {
389
+ try {
390
+ const parsed = JSON.parse(line);
391
+ return parsed.type === AG_UI_EVENT_TYPES.RUN_COMPLETED;
392
+ } catch {
393
+ return false;
394
+ }
395
+ });
396
+ expect(hasRunCompletedAfter).toBe(true);
397
+ });
398
+
399
+ it('does NOT emit fake RUN_COMPLETED immediately after RUN_STARTED', async () => {
400
+ const runtime = createRuntimeStub();
401
+ const eventStub = createEventSubscriberStub();
402
+ const config = createDefaultConfig();
403
+
404
+ const router = createRunAgentRouter(
405
+ runtime as unknown as KernelRuntime,
406
+ eventStub.subscriber,
407
+ config,
408
+ );
409
+
410
+ const request = createRequest({
411
+ method: HTTP_METHOD.POST,
412
+ url: '/ag-ui/v1/run',
413
+ body: createRunAgentInput(),
414
+ });
415
+ const response = new MockResponse();
416
+
417
+ await router.handleRequest(
418
+ request as IncomingMessage,
419
+ response as unknown as ServerResponse<IncomingMessage>,
420
+ );
421
+
422
+ // Parse all events from the response body at this point (before any
423
+ // kernel events are triggered via the subscriber)
424
+ const lines = response.body.split('\n').filter((line) => line.trim().length > 0);
425
+ const events = lines
426
+ .map((line) => {
427
+ try {
428
+ return JSON.parse(line);
429
+ } catch {
430
+ return null;
431
+ }
432
+ })
433
+ .filter(Boolean);
434
+
435
+ // RUN_STARTED should be the only (or first) event
436
+ const runStartedEvents = events.filter(
437
+ (e: Record<string, unknown>) => e.type === AG_UI_EVENT_TYPES.RUN_STARTED,
438
+ );
439
+ expect(runStartedEvents.length).toBe(1);
440
+
441
+ // RUN_COMPLETED should NOT be present yet (no execution happened)
442
+ const runCompletedEvents = events.filter(
443
+ (e: Record<string, unknown>) => e.type === AG_UI_EVENT_TYPES.RUN_COMPLETED,
444
+ );
445
+ expect(runCompletedEvents.length).toBe(0);
446
+ });
447
+ });