@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.
- package/LICENSE +190 -0
- package/README.md +40 -0
- package/cli/__tests__/gates.test.ts +97 -0
- package/cli/__tests__/inspect.test.ts +184 -0
- package/cli/__tests__/task-lifecycle.test.ts +203 -0
- package/cli/gates.ts +46 -0
- package/cli/index.ts +6 -0
- package/cli/inspect.ts +138 -0
- package/cli/task-lifecycle.ts +46 -0
- package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
- package/http/__tests__/auth-boundary.test.ts +57 -0
- package/http/__tests__/channel-send-governance.test.ts +158 -0
- package/http/__tests__/event-stream.test.ts +340 -0
- package/http/__tests__/phone-device-tool-api.test.ts +177 -0
- package/http/__tests__/remote-exposure.test.ts +212 -0
- package/http/__tests__/run-agent.test.ts +447 -0
- package/http/__tests__/scope-enforcement.test.ts +349 -0
- package/http/__tests__/sidecar-entry.test.ts +158 -0
- package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
- package/http/__tests__/tool-api.test.ts +491 -0
- package/http/__tests__/tool-discovery.test.ts +384 -0
- package/http/ag-ui-adapter.ts +352 -0
- package/http/auth.ts +294 -0
- package/http/control-plane-event-subscriber.ts +233 -0
- package/http/event-stream.ts +216 -0
- package/http/index.ts +10 -0
- package/http/run-agent.ts +416 -0
- package/http/server.ts +329 -0
- package/http/sidecar-entry.ts +218 -0
- package/http/task-api.ts +307 -0
- package/http/tool-api.ts +373 -0
- package/http/tool-discovery.ts +159 -0
- package/mcp/__tests__/server.test.ts +554 -0
- package/mcp/index.ts +4 -0
- package/mcp/server.ts +250 -0
- package/package.json +51 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const HTTP_SURFACE_ROOT = path.resolve(import.meta.dirname, '..');
|
|
9
|
+
const TYPESCRIPT_SOURCE_GLOB = '**/*.ts';
|
|
10
|
+
const CORE_BOUNDARY_PATTERNS = ['@lumenflow/core', '../../core/'] as const;
|
|
11
|
+
|
|
12
|
+
function collectTypeScriptFiles(root: string): string[] {
|
|
13
|
+
const results: string[] = [];
|
|
14
|
+
|
|
15
|
+
function visit(currentPath: string): void {
|
|
16
|
+
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
|
17
|
+
const entryPath = path.join(currentPath, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
visit(entryPath);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
24
|
+
results.push(entryPath);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
visit(root);
|
|
30
|
+
return results.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('http auth boundary', () => {
|
|
34
|
+
it(`keeps ${TYPESCRIPT_SOURCE_GLOB} free of core-boundary imports`, () => {
|
|
35
|
+
const matches = collectTypeScriptFiles(HTTP_SURFACE_ROOT)
|
|
36
|
+
.map((filePath) => ({
|
|
37
|
+
filePath,
|
|
38
|
+
content: readFileSync(filePath, 'utf8'),
|
|
39
|
+
}))
|
|
40
|
+
.filter(({ content }) =>
|
|
41
|
+
CORE_BOUNDARY_PATTERNS.some((pattern) => {
|
|
42
|
+
const importPattern = new RegExp(
|
|
43
|
+
String.raw`(?:from\s+['"]${escapeForRegExp(pattern)}['"]|require\(\s*['"]${escapeForRegExp(pattern)}['"]\s*\)|new URL\(\s*['"]${escapeForRegExp(pattern)})`,
|
|
44
|
+
'u',
|
|
45
|
+
);
|
|
46
|
+
return importPattern.test(content);
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
.map(({ filePath }) => path.relative(HTTP_SURFACE_ROOT, filePath));
|
|
50
|
+
|
|
51
|
+
expect(matches).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function escapeForRegExp(value: string): string {
|
|
56
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
57
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WU-2780 (P0 governance): ADR-013 §6 states `channel.send` MUST NOT be a
|
|
6
|
+
* top-level remote surface; it is `registered only as a runtime-callable
|
|
7
|
+
* tool` and the governed dispatch path is `agent:execute-turn`. This suite
|
|
8
|
+
* enforces that contract fail-closed at the HTTP tool-api boundary.
|
|
9
|
+
*
|
|
10
|
+
* Three assertions:
|
|
11
|
+
* 1. `createToolApiRouter` refuses to construct a router whose allowlist
|
|
12
|
+
* contains a forbidden remote-callable tool name (throws with an
|
|
13
|
+
* ADR-013 §6 citation).
|
|
14
|
+
* 2. When a router is built with a non-violating allowlist, POST
|
|
15
|
+
* /tools/channel:send returns 403 because the tool is NOT allowlisted
|
|
16
|
+
* (no implicit remote exposure).
|
|
17
|
+
* 3. The runtime `executeTool` path itself is NOT short-circuited by the
|
|
18
|
+
* governance check — `agent:execute-turn` (or any other caller that
|
|
19
|
+
* dispatches through `runtime.executeTool`) remains free to invoke
|
|
20
|
+
* `channel:send`. The governance applies to the HTTP surface only.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
|
|
24
|
+
import { EventEmitter } from 'node:events';
|
|
25
|
+
import { PassThrough } from 'node:stream';
|
|
26
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
27
|
+
import { ADR_013_SECTION_6_FORBIDDEN_REMOTE_TOOLS, createToolApiRouter } from '../tool-api.js';
|
|
28
|
+
|
|
29
|
+
const HTTP_METHOD_POST = 'POST';
|
|
30
|
+
const HTTP_STATUS_FORBIDDEN = 403;
|
|
31
|
+
const HTTP_STATUS_OK = 200;
|
|
32
|
+
|
|
33
|
+
const CHANNEL_SEND_TOOL = 'channel:send';
|
|
34
|
+
const SAFE_ALLOWLISTED_TOOL = 'task:status';
|
|
35
|
+
|
|
36
|
+
const ADR_013_CITATION_FRAGMENT = 'ADR-013';
|
|
37
|
+
|
|
38
|
+
class MockResponse extends EventEmitter {
|
|
39
|
+
statusCode = HTTP_STATUS_OK;
|
|
40
|
+
body = '';
|
|
41
|
+
private readonly headers = new Map<string, string>();
|
|
42
|
+
|
|
43
|
+
setHeader(name: string, value: string | number | readonly string[]): this {
|
|
44
|
+
this.headers.set(name.toLowerCase(), String(value));
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
write(chunk: string | Buffer): boolean {
|
|
49
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
end(chunk?: string | Buffer): this {
|
|
54
|
+
if (chunk !== undefined) {
|
|
55
|
+
this.write(chunk);
|
|
56
|
+
}
|
|
57
|
+
this.emit('finish');
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createRequest(body: unknown): IncomingMessage {
|
|
63
|
+
const request = new PassThrough() as unknown as IncomingMessage & {
|
|
64
|
+
method: string;
|
|
65
|
+
url: string;
|
|
66
|
+
headers: IncomingHttpHeaders;
|
|
67
|
+
};
|
|
68
|
+
request.method = HTTP_METHOD_POST;
|
|
69
|
+
request.url = '/';
|
|
70
|
+
request.headers = {
|
|
71
|
+
'content-type': 'application/json; charset=utf-8',
|
|
72
|
+
authorization: 'Bearer legacy-opaque-test-token',
|
|
73
|
+
};
|
|
74
|
+
const payload = body === undefined ? '' : JSON.stringify(body);
|
|
75
|
+
(request as unknown as PassThrough).end(payload);
|
|
76
|
+
return request;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createContext() {
|
|
80
|
+
return {
|
|
81
|
+
run_id: 'run-governance',
|
|
82
|
+
task_id: 'WU-2780',
|
|
83
|
+
session_id: 'session-governance',
|
|
84
|
+
allowed_scopes: [
|
|
85
|
+
{
|
|
86
|
+
type: 'path' as const,
|
|
87
|
+
pattern: 'workspace/**',
|
|
88
|
+
access: 'read' as const,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('WU-2780 ADR-013 §6 channel.send governance (HTTP surface fail-closed)', () => {
|
|
95
|
+
it('exports the forbidden-remote-tools list with channel:send included', () => {
|
|
96
|
+
expect(ADR_013_SECTION_6_FORBIDDEN_REMOTE_TOOLS).toContain(CHANNEL_SEND_TOOL);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('createToolApiRouter throws when channel:send is in allowlistedTools', () => {
|
|
100
|
+
expect(() =>
|
|
101
|
+
createToolApiRouter({ executeTool: vi.fn() } as never, {
|
|
102
|
+
allowlistedTools: [SAFE_ALLOWLISTED_TOOL, CHANNEL_SEND_TOOL],
|
|
103
|
+
}),
|
|
104
|
+
).toThrowError(new RegExp(`${CHANNEL_SEND_TOOL}.*${ADR_013_CITATION_FRAGMENT}`, 's'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('the governance error names ADR-013 §6 and the forbidden tool explicitly', () => {
|
|
108
|
+
let caught: unknown;
|
|
109
|
+
try {
|
|
110
|
+
createToolApiRouter({ executeTool: vi.fn() } as never, {
|
|
111
|
+
allowlistedTools: [CHANNEL_SEND_TOOL],
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
caught = error;
|
|
115
|
+
}
|
|
116
|
+
expect(caught).toBeInstanceOf(Error);
|
|
117
|
+
const message = (caught as Error).message;
|
|
118
|
+
expect(message).toContain(CHANNEL_SEND_TOOL);
|
|
119
|
+
expect(message).toContain(ADR_013_CITATION_FRAGMENT);
|
|
120
|
+
expect(message).toContain('agent:execute-turn');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('POST /tools/channel:send returns 403 when the router is built without channel:send (not allowlisted)', async () => {
|
|
124
|
+
const runtime = { executeTool: vi.fn() };
|
|
125
|
+
const router = createToolApiRouter(runtime as never, {
|
|
126
|
+
allowlistedTools: [SAFE_ALLOWLISTED_TOOL],
|
|
127
|
+
});
|
|
128
|
+
const request = createRequest({ input: { content: 'hi' }, context: createContext() });
|
|
129
|
+
const response = new MockResponse();
|
|
130
|
+
|
|
131
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
132
|
+
CHANNEL_SEND_TOOL,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
expect(runtime.executeTool).not.toHaveBeenCalled();
|
|
136
|
+
expect(response.statusCode).toBe(HTTP_STATUS_FORBIDDEN);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('runtime.executeTool path for channel:send is NOT blocked by the HTTP governance (agent:execute-turn preserved)', async () => {
|
|
140
|
+
// Governance applies to the HTTP allowlist construction, NOT to the
|
|
141
|
+
// runtime dispatch path. `agent:execute-turn` calls runtime.executeTool
|
|
142
|
+
// directly to invoke `channel:send` through the manifest-driven
|
|
143
|
+
// dispatch (ADR-013 §6). This test proves the in-process path is free
|
|
144
|
+
// to dispatch channel:send without tripping the HTTP-surface guard.
|
|
145
|
+
const executeTool = vi.fn(async () => ({ success: true, data: { accepted: true } }));
|
|
146
|
+
const runtime = { executeTool };
|
|
147
|
+
const dispatchedContext = createContext();
|
|
148
|
+
|
|
149
|
+
await runtime.executeTool(CHANNEL_SEND_TOOL, { content: 'governed-path' }, dispatchedContext);
|
|
150
|
+
|
|
151
|
+
expect(executeTool).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(executeTool).toHaveBeenCalledWith(
|
|
153
|
+
CHANNEL_SEND_TOOL,
|
|
154
|
+
{ content: 'governed-path' },
|
|
155
|
+
dispatchedContext,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { PassThrough } from 'node:stream';
|
|
7
|
+
import type { Disposable, KernelEvent, ReplayFilter, ToolTraceEntry } from '@lumenflow/kernel';
|
|
8
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
createEventStreamRouter,
|
|
11
|
+
type EventSubscriber,
|
|
12
|
+
type TraceSubscriber,
|
|
13
|
+
type StreamEvent,
|
|
14
|
+
} from '../event-stream.js';
|
|
15
|
+
|
|
16
|
+
// --- Constants ---
|
|
17
|
+
|
|
18
|
+
const TASK_ID = 'task-sse-1918';
|
|
19
|
+
const SSE_DATA_PREFIX = 'data: ';
|
|
20
|
+
const SSE_DOUBLE_NEWLINE = '\n\n';
|
|
21
|
+
const SSE_HEARTBEAT_COMMENT = ':heartbeat\n\n';
|
|
22
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
23
|
+
|
|
24
|
+
// --- Test helpers ---
|
|
25
|
+
|
|
26
|
+
class MockResponse extends EventEmitter {
|
|
27
|
+
statusCode = 200;
|
|
28
|
+
body = '';
|
|
29
|
+
readonly headers = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
setHeader(name: string, value: string | number | readonly string[]): this {
|
|
32
|
+
this.headers.set(name.toLowerCase(), String(value));
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
write(chunk: string | Buffer): boolean {
|
|
37
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
end(chunk?: string | Buffer): this {
|
|
42
|
+
if (chunk !== undefined) {
|
|
43
|
+
this.write(chunk);
|
|
44
|
+
}
|
|
45
|
+
this.emit('finish');
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createRequest(url: string): IncomingMessage {
|
|
51
|
+
const request = new PassThrough() as unknown as IncomingMessage & {
|
|
52
|
+
method: string;
|
|
53
|
+
url: string;
|
|
54
|
+
headers: Record<string, string>;
|
|
55
|
+
};
|
|
56
|
+
request.method = 'GET';
|
|
57
|
+
request.url = url;
|
|
58
|
+
request.headers = {};
|
|
59
|
+
return request;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createSampleKernelEvent(): KernelEvent {
|
|
63
|
+
return {
|
|
64
|
+
schema_version: 1,
|
|
65
|
+
kind: 'task_claimed',
|
|
66
|
+
task_id: TASK_ID,
|
|
67
|
+
timestamp: '2026-02-20T00:00:01.000Z',
|
|
68
|
+
by: 'tom',
|
|
69
|
+
session_id: 'session-1',
|
|
70
|
+
} as unknown as KernelEvent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createSampleTrace(): ToolTraceEntry {
|
|
74
|
+
return {
|
|
75
|
+
schema_version: 1,
|
|
76
|
+
kind: 'tool_call_started',
|
|
77
|
+
receipt_id: 'receipt-001',
|
|
78
|
+
run_id: 'run-1',
|
|
79
|
+
task_id: TASK_ID,
|
|
80
|
+
session_id: 'session-1',
|
|
81
|
+
timestamp: '2026-02-20T00:00:02.000Z',
|
|
82
|
+
tool_name: 'file_read',
|
|
83
|
+
execution_mode: 'in_process',
|
|
84
|
+
scope_requested: [],
|
|
85
|
+
scope_allowed: [],
|
|
86
|
+
scope_enforced: [],
|
|
87
|
+
input_hash: 'a'.repeat(64),
|
|
88
|
+
input_ref: '/tmp/inputs/a',
|
|
89
|
+
tool_version: '1.0.0',
|
|
90
|
+
workspace_config_hash: 'b'.repeat(64),
|
|
91
|
+
runtime_version: '1.0.0',
|
|
92
|
+
} as ToolTraceEntry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Tests ---
|
|
96
|
+
|
|
97
|
+
describe('event-stream SSE framing (AC-1)', () => {
|
|
98
|
+
it('writes kernel events with data: prefix and double newline', async () => {
|
|
99
|
+
const dispose = vi.fn();
|
|
100
|
+
let capturedCallback: ((event: KernelEvent) => void | Promise<void>) | null = null;
|
|
101
|
+
const eventSubscriber: EventSubscriber = {
|
|
102
|
+
subscribe: vi.fn((_filter, callback) => {
|
|
103
|
+
capturedCallback = callback;
|
|
104
|
+
return { dispose } satisfies Disposable;
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const router = createEventStreamRouter(eventSubscriber);
|
|
109
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
110
|
+
const response = new MockResponse();
|
|
111
|
+
const searchParams = new URLSearchParams();
|
|
112
|
+
|
|
113
|
+
await router.handleRequest(
|
|
114
|
+
request as IncomingMessage,
|
|
115
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
116
|
+
[TASK_ID],
|
|
117
|
+
searchParams,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const event = createSampleKernelEvent();
|
|
121
|
+
expect(capturedCallback).not.toBeNull();
|
|
122
|
+
await capturedCallback!(event);
|
|
123
|
+
|
|
124
|
+
const expectedPayload = `${SSE_DATA_PREFIX}${JSON.stringify({ source: 'kernel', event })}${SSE_DOUBLE_NEWLINE}`;
|
|
125
|
+
expect(response.body).toContain(expectedPayload);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('does NOT write bare NDJSON (no data: prefix)', async () => {
|
|
129
|
+
const dispose = vi.fn();
|
|
130
|
+
let capturedCallback: ((event: KernelEvent) => void | Promise<void>) | null = null;
|
|
131
|
+
const eventSubscriber: EventSubscriber = {
|
|
132
|
+
subscribe: vi.fn((_filter, callback) => {
|
|
133
|
+
capturedCallback = callback;
|
|
134
|
+
return { dispose } satisfies Disposable;
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const router = createEventStreamRouter(eventSubscriber);
|
|
139
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
140
|
+
const response = new MockResponse();
|
|
141
|
+
|
|
142
|
+
await router.handleRequest(
|
|
143
|
+
request as IncomingMessage,
|
|
144
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
145
|
+
[TASK_ID],
|
|
146
|
+
new URLSearchParams(),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const event = createSampleKernelEvent();
|
|
150
|
+
await capturedCallback!(event);
|
|
151
|
+
|
|
152
|
+
// The old NDJSON format was: JSON.stringify(event) + '\n'
|
|
153
|
+
// This must NOT appear (without the data: prefix)
|
|
154
|
+
const bareNdjson = `${JSON.stringify(event)}\n`;
|
|
155
|
+
// The body should NOT start with this bare format
|
|
156
|
+
expect(response.body.startsWith(bareNdjson)).toBe(false);
|
|
157
|
+
// Every data line must start with 'data: '
|
|
158
|
+
const lines = response.body.split('\n').filter((line: string) => line.length > 0);
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
expect(line.startsWith('data: ') || line.startsWith(':')).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('event-stream StreamEvent envelope (AC-2)', () => {
|
|
166
|
+
it('wraps KernelEvent in StreamEvent with source: kernel', async () => {
|
|
167
|
+
const dispose = vi.fn();
|
|
168
|
+
let capturedCallback: ((event: KernelEvent) => void | Promise<void>) | null = null;
|
|
169
|
+
const eventSubscriber: EventSubscriber = {
|
|
170
|
+
subscribe: vi.fn((_filter, callback) => {
|
|
171
|
+
capturedCallback = callback;
|
|
172
|
+
return { dispose } satisfies Disposable;
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const router = createEventStreamRouter(eventSubscriber);
|
|
177
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
178
|
+
const response = new MockResponse();
|
|
179
|
+
|
|
180
|
+
await router.handleRequest(
|
|
181
|
+
request as IncomingMessage,
|
|
182
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
183
|
+
[TASK_ID],
|
|
184
|
+
new URLSearchParams(),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const event = createSampleKernelEvent();
|
|
188
|
+
await capturedCallback!(event);
|
|
189
|
+
|
|
190
|
+
// Parse the data line
|
|
191
|
+
const dataLine = response.body.split('\n').find((line: string) => line.startsWith('data: '));
|
|
192
|
+
expect(dataLine).toBeDefined();
|
|
193
|
+
const parsed = JSON.parse(dataLine!.slice('data: '.length)) as StreamEvent;
|
|
194
|
+
expect(parsed.source).toBe('kernel');
|
|
195
|
+
expect(parsed.event).toEqual(event);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('wraps ToolTraceEntry in StreamEvent with source: evidence', async () => {
|
|
199
|
+
const dispose = vi.fn();
|
|
200
|
+
const eventSubscriber: EventSubscriber = {
|
|
201
|
+
subscribe: vi.fn((_filter, _callback) => {
|
|
202
|
+
return { dispose } satisfies Disposable;
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
let capturedTraceCallback: ((trace: ToolTraceEntry) => void) | null = null;
|
|
206
|
+
const traceSubscriber: TraceSubscriber = {
|
|
207
|
+
subscribe: vi.fn((_taskId, callback) => {
|
|
208
|
+
capturedTraceCallback = callback;
|
|
209
|
+
return { dispose } satisfies Disposable;
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const router = createEventStreamRouter(eventSubscriber, { traceSubscriber });
|
|
214
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
215
|
+
const response = new MockResponse();
|
|
216
|
+
|
|
217
|
+
await router.handleRequest(
|
|
218
|
+
request as IncomingMessage,
|
|
219
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
220
|
+
[TASK_ID],
|
|
221
|
+
new URLSearchParams(),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const trace = createSampleTrace();
|
|
225
|
+
expect(capturedTraceCallback).not.toBeNull();
|
|
226
|
+
capturedTraceCallback!(trace);
|
|
227
|
+
|
|
228
|
+
const dataLine = response.body.split('\n').find((line: string) => line.startsWith('data: '));
|
|
229
|
+
expect(dataLine).toBeDefined();
|
|
230
|
+
const parsed = JSON.parse(dataLine!.slice('data: '.length)) as StreamEvent;
|
|
231
|
+
expect(parsed.source).toBe('evidence');
|
|
232
|
+
expect(parsed.trace).toEqual(trace);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('event-stream heartbeat (AC-4)', () => {
|
|
237
|
+
beforeEach(() => {
|
|
238
|
+
vi.useFakeTimers();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
vi.useRealTimers();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('sends heartbeat comment every 15 seconds', async () => {
|
|
246
|
+
const dispose = vi.fn();
|
|
247
|
+
const eventSubscriber: EventSubscriber = {
|
|
248
|
+
subscribe: vi.fn((_filter, _callback) => {
|
|
249
|
+
return { dispose } satisfies Disposable;
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const router = createEventStreamRouter(eventSubscriber);
|
|
254
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
255
|
+
const response = new MockResponse();
|
|
256
|
+
|
|
257
|
+
await router.handleRequest(
|
|
258
|
+
request as IncomingMessage,
|
|
259
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
260
|
+
[TASK_ID],
|
|
261
|
+
new URLSearchParams(),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Initially no heartbeat
|
|
265
|
+
expect(response.body).toBe('');
|
|
266
|
+
|
|
267
|
+
// Advance 15 seconds
|
|
268
|
+
vi.advanceTimersByTime(HEARTBEAT_INTERVAL_MS);
|
|
269
|
+
expect(response.body).toContain(SSE_HEARTBEAT_COMMENT);
|
|
270
|
+
|
|
271
|
+
// Advance another 15 seconds
|
|
272
|
+
const bodyAfterFirst = response.body;
|
|
273
|
+
vi.advanceTimersByTime(HEARTBEAT_INTERVAL_MS);
|
|
274
|
+
// Should have two heartbeats now
|
|
275
|
+
const heartbeatCount = response.body.split(SSE_HEARTBEAT_COMMENT).length - 1;
|
|
276
|
+
expect(heartbeatCount).toBe(2);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('stops heartbeat when connection closes', async () => {
|
|
280
|
+
const dispose = vi.fn();
|
|
281
|
+
const eventSubscriber: EventSubscriber = {
|
|
282
|
+
subscribe: vi.fn((_filter, _callback) => {
|
|
283
|
+
return { dispose } satisfies Disposable;
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const router = createEventStreamRouter(eventSubscriber);
|
|
288
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
289
|
+
const response = new MockResponse();
|
|
290
|
+
|
|
291
|
+
await router.handleRequest(
|
|
292
|
+
request as IncomingMessage,
|
|
293
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
294
|
+
[TASK_ID],
|
|
295
|
+
new URLSearchParams(),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Close the connection
|
|
299
|
+
request.emit('close');
|
|
300
|
+
|
|
301
|
+
// Advance past heartbeat interval
|
|
302
|
+
vi.advanceTimersByTime(HEARTBEAT_INTERVAL_MS * 2);
|
|
303
|
+
|
|
304
|
+
// No heartbeats should have been sent
|
|
305
|
+
expect(response.body).toBe('');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('event-stream disposes subscriptions on close', () => {
|
|
310
|
+
it('disposes both event and trace subscriptions on request close', async () => {
|
|
311
|
+
const eventDispose = vi.fn();
|
|
312
|
+
const traceDispose = vi.fn();
|
|
313
|
+
const eventSubscriber: EventSubscriber = {
|
|
314
|
+
subscribe: vi.fn((_filter, _callback) => {
|
|
315
|
+
return { dispose: eventDispose } satisfies Disposable;
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
const traceSubscriber: TraceSubscriber = {
|
|
319
|
+
subscribe: vi.fn((_taskId, _callback) => {
|
|
320
|
+
return { dispose: traceDispose } satisfies Disposable;
|
|
321
|
+
}),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const router = createEventStreamRouter(eventSubscriber, { traceSubscriber });
|
|
325
|
+
const request = createRequest(`/events/${TASK_ID}`);
|
|
326
|
+
const response = new MockResponse();
|
|
327
|
+
|
|
328
|
+
await router.handleRequest(
|
|
329
|
+
request as IncomingMessage,
|
|
330
|
+
response as unknown as ServerResponse<IncomingMessage>,
|
|
331
|
+
[TASK_ID],
|
|
332
|
+
new URLSearchParams(),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
request.emit('close');
|
|
336
|
+
|
|
337
|
+
expect(eventDispose).toHaveBeenCalledTimes(1);
|
|
338
|
+
expect(traceDispose).toHaveBeenCalledTimes(1);
|
|
339
|
+
});
|
|
340
|
+
});
|