@anhlp/alp-protocol 0.0.0-beta.1
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 +202 -0
- package/NOTICE +7 -0
- package/README.md +17 -0
- package/conformance/cases.json +951 -0
- package/conformance/run.d.mts +32 -0
- package/conformance/run.mjs +403 -0
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +0 -0
- package/dist/contract-v1.d.ts +4171 -0
- package/dist/contract-v1.js +5 -0
- package/dist/contract.d.ts +25 -0
- package/dist/contract.js +28 -0
- package/dist/dto.d.ts +104 -0
- package/dist/dto.js +0 -0
- package/dist/events.d.ts +225 -0
- package/dist/events.js +0 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/public.d.ts +1 -0
- package/dist/public.js +2 -0
- package/dist/rpc.d.ts +498 -0
- package/dist/rpc.js +0 -0
- package/dist/session.d.ts +134 -0
- package/dist/session.js +0 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.js +147 -0
- package/dist/wire.d.ts +41 -0
- package/dist/wire.js +32 -0
- package/fixtures/ipc-v1.jsonl +4 -0
- package/fixtures/v1/cases.json +2827 -0
- package/package.json +129 -0
- package/schema/ipc-v1.json +2007 -0
- package/schema/methods-v1.json +4070 -0
- package/src/connection.ts +9 -0
- package/src/contract-v1.js +2 -0
- package/src/contract.js +44 -0
- package/src/dto.ts +78 -0
- package/src/events.ts +127 -0
- package/src/index.ts +6 -0
- package/src/public.js +2 -0
- package/src/rpc.ts +102 -0
- package/src/session.ts +94 -0
- package/src/validate.js +113 -0
- package/src/wire.js +26 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Names accepted by the conformance runner. */
|
|
2
|
+
export type Profile = 'handshake' | 'wire' | 'errors' | 'limits' | 'session';
|
|
3
|
+
export interface ConformanceOptions {
|
|
4
|
+
/** Unix socket path or Windows named pipe path. */
|
|
5
|
+
socket: string;
|
|
6
|
+
/** Defaults to all profiles except session. */
|
|
7
|
+
profiles?: Profile[] | undefined;
|
|
8
|
+
/** Required by the session profile. */
|
|
9
|
+
project?: string | undefined;
|
|
10
|
+
model?: string | undefined;
|
|
11
|
+
/** Prompt marker that keeps the test provider running until interrupted. */
|
|
12
|
+
hold?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
export interface ConformanceResult {
|
|
15
|
+
id: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
/** A capability-dependent scenario was skipped. */
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ConformanceCase {
|
|
22
|
+
id: string;
|
|
23
|
+
profile: Profile;
|
|
24
|
+
description: string;
|
|
25
|
+
hello: boolean;
|
|
26
|
+
steps: Array<Record<string, unknown>>;
|
|
27
|
+
}
|
|
28
|
+
export const PROFILES: Profile[];
|
|
29
|
+
export const CASES: ConformanceCase[];
|
|
30
|
+
export const SESSION_SCENARIOS: Array<{ id: string; description: string }>;
|
|
31
|
+
/** Run selected profiles against an already-running disposable daemon. */
|
|
32
|
+
export function runConformance(options: ConformanceOptions): Promise<ConformanceResult[]>;
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Conformance runner for ALP JSON-RPC v1 (docs/protocol/alp-json-rpc-v1.md ยง11).
|
|
3
|
+
//
|
|
4
|
+
// node run.mjs --socket <path> [--profiles handshake,wire,errors,limits,session]
|
|
5
|
+
// [--project <dir> --model <model> [--hold-marker <text>]] [--json]
|
|
6
|
+
//
|
|
7
|
+
// It speaks to a daemon over its newline-delimited stream endpoint (a Unix socket or a Windows
|
|
8
|
+
// named pipe) and imports nothing of any implementation: only node:net and the protocol's own
|
|
9
|
+
// schemas. The session profile creates sessions in --project and needs a provider whose turns
|
|
10
|
+
// end by themselves, except a prompt containing the hold marker, whose turn runs until interrupted.
|
|
11
|
+
// Run it against a disposable daemon home, never a user's real one.
|
|
12
|
+
import net from 'node:net';
|
|
13
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { parseArgs } from 'node:util';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { validateDefinition, validateResult } from '../src/contract.js';
|
|
18
|
+
|
|
19
|
+
const MAX_FRAME = 16 * 1024 * 1024;
|
|
20
|
+
const TIMEOUT = 10_000;
|
|
21
|
+
const CASES = JSON.parse(readFileSync(new URL('./cases.json', import.meta.url), 'utf8')).cases;
|
|
22
|
+
export const PROFILES = ['handshake', 'wire', 'errors', 'limits', 'session'];
|
|
23
|
+
|
|
24
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
25
|
+
|
|
26
|
+
/** One connection: raw writes, every frame it receives, and waits on them. */
|
|
27
|
+
function open(socketPath) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const socket = net.createConnection(socketPath);
|
|
30
|
+
socket.once('error', reject);
|
|
31
|
+
socket.once('connect', () => {
|
|
32
|
+
socket.off('error', reject);
|
|
33
|
+
socket.setEncoding('utf8');
|
|
34
|
+
const frames = [];
|
|
35
|
+
const problems = [];
|
|
36
|
+
let buffer = '';
|
|
37
|
+
let closed = false;
|
|
38
|
+
let notify = () => {};
|
|
39
|
+
socket.on('data', chunk => {
|
|
40
|
+
buffer += chunk;
|
|
41
|
+
for (let index = buffer.indexOf('\n'); index >= 0; index = buffer.indexOf('\n')) {
|
|
42
|
+
const line = buffer.slice(0, index);
|
|
43
|
+
buffer = buffer.slice(index + 1);
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
let frame;
|
|
46
|
+
try { frame = JSON.parse(line); } catch { problems.push(`daemon sent a frame that is not JSON: ${line.slice(0, 200)}`); continue; }
|
|
47
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame)) { problems.push('daemon sent an invalid frame: expected an object'); continue; }
|
|
48
|
+
// Everything the daemon sends must be a v1 message: a response, an event or an extension.invoke.
|
|
49
|
+
const issues = validateDefinition(frame.method === 'event' ? 'notification' : 'method' in frame ? 'request' : 'response', frame);
|
|
50
|
+
if (issues.length) problems.push(`daemon sent an invalid ${frame.method ?? 'response'} frame: ${describe(issues)} in ${line.slice(0, 300)}`);
|
|
51
|
+
frames.push(frame);
|
|
52
|
+
}
|
|
53
|
+
notify();
|
|
54
|
+
});
|
|
55
|
+
socket.on('error', () => {});
|
|
56
|
+
socket.on('close', () => { closed = true; notify(); });
|
|
57
|
+
const connection = {
|
|
58
|
+
frames, problems,
|
|
59
|
+
get closed() { return closed; },
|
|
60
|
+
write(text) { socket.write(text); },
|
|
61
|
+
send(value) { socket.write(`${JSON.stringify(value)}\n`); },
|
|
62
|
+
/** Resolves with the first frame matching `test`, removing it; rejects on timeout or close. */
|
|
63
|
+
async take(test, what, timeout = TIMEOUT) {
|
|
64
|
+
const deadline = Date.now() + timeout;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const index = frames.findIndex(test);
|
|
67
|
+
if (index >= 0) return frames.splice(index, 1)[0];
|
|
68
|
+
if (closed) throw new Error(`connection closed while waiting for ${what}`);
|
|
69
|
+
const left = deadline - Date.now();
|
|
70
|
+
if (left <= 0) throw new Error(`timed out waiting for ${what}`);
|
|
71
|
+
await new Promise(wake => { const timer = setTimeout(wake, Math.min(left, 50)); notify = () => { clearTimeout(timer); wake(); }; });
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
async request(method, params, id = randomUUID()) {
|
|
75
|
+
connection.send({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });
|
|
76
|
+
const reply = await connection.take(frame => frame.id === id && !('method' in frame), `the answer to ${method}`);
|
|
77
|
+
if (reply.result !== undefined) {
|
|
78
|
+
const issues = validateResult(method, reply.result);
|
|
79
|
+
if (issues?.length) problems.push(`${method} result does not match its schema: ${describe(issues)}`);
|
|
80
|
+
}
|
|
81
|
+
return reply;
|
|
82
|
+
},
|
|
83
|
+
events: () => frames.filter(frame => frame.method === 'event').map(frame => frame.params),
|
|
84
|
+
drainEvents() { const taken = connection.events(); for (let i = frames.length - 1; i >= 0; i--) if (frames[i].method === 'event') frames.splice(i, 1); return taken; },
|
|
85
|
+
async hello() {
|
|
86
|
+
const reply = await connection.request('daemon.hello', { protocolVersion: 1, client: { name: 'alp-conformance', version: '1' } });
|
|
87
|
+
if (reply.error) throw new Error(`daemon.hello failed: ${JSON.stringify(reply.error)}`);
|
|
88
|
+
return reply.result;
|
|
89
|
+
},
|
|
90
|
+
close() { socket.destroy(); },
|
|
91
|
+
};
|
|
92
|
+
resolve(connection);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const describe = issues => issues.map(issue => `${issue.path || '/'} ${issue.message}`).join('; ');
|
|
98
|
+
|
|
99
|
+
/** Subset match: every expected property must match; {"$type"} matches a JSON type, {"$absent": true} an absent one. */
|
|
100
|
+
function mismatch(expected, actual, at = '') {
|
|
101
|
+
if (expected !== null && typeof expected === 'object' && !Array.isArray(expected)) {
|
|
102
|
+
if ('$absent' in expected) return actual === undefined ? undefined : `${at || '/'} should be absent`;
|
|
103
|
+
if ('$type' in expected) {
|
|
104
|
+
const type = actual === null ? 'null' : Array.isArray(actual) ? 'array' : typeof actual;
|
|
105
|
+
return type === expected.$type ? undefined : `${at || '/'} should be ${expected.$type}, is ${type}`;
|
|
106
|
+
}
|
|
107
|
+
if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return `${at || '/'} should be an object, is ${JSON.stringify(actual)}`;
|
|
108
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
109
|
+
const problem = mismatch(value, actual[key], `${at}/${key}`);
|
|
110
|
+
if (problem) return problem;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(expected)) {
|
|
115
|
+
if (!Array.isArray(actual) || actual.length !== expected.length) return `${at || '/'} should be ${JSON.stringify(expected)}, is ${JSON.stringify(actual)}`;
|
|
116
|
+
for (let index = 0; index < expected.length; index++) {
|
|
117
|
+
const problem = mismatch(expected[index], actual[index], `${at}/${index}`);
|
|
118
|
+
if (problem) return problem;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return Object.is(expected, actual) ? undefined : `${at || '/'} should be ${JSON.stringify(expected)}, is ${JSON.stringify(actual)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const usableId = id => (typeof id === 'string' && [...id].length <= 128) || Number.isSafeInteger(id);
|
|
126
|
+
const isAnswer = frame => !('method' in frame);
|
|
127
|
+
|
|
128
|
+
async function runCase(socketPath, testCase) {
|
|
129
|
+
const connection = await open(socketPath);
|
|
130
|
+
try {
|
|
131
|
+
if (testCase.hello) await connection.hello();
|
|
132
|
+
for (const step of testCase.steps) {
|
|
133
|
+
if (step.probe) {
|
|
134
|
+
// Registering the method now succeeds only if no earlier frame of this case registered it.
|
|
135
|
+
const reply = await connection.request('extension.register', { method: step.probe });
|
|
136
|
+
if (reply.error) throw new Error(`an earlier frame ran: registering ${step.probe} again failed with ${JSON.stringify(reply.error)}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (step.sendBytes) {
|
|
140
|
+
const raw = JSON.stringify(step.send);
|
|
141
|
+
const padding = step.sendBytes - Buffer.byteLength(raw, 'utf8') - 1;
|
|
142
|
+
if (padding < 0) throw new Error('sendBytes is smaller than the request');
|
|
143
|
+
connection.write(raw.slice(0, -1) + ' '.repeat(padding) + '}\n');
|
|
144
|
+
} else if (step.sendOversized) {
|
|
145
|
+
// One frame of that many bytes before its newline, written in pieces, never finished.
|
|
146
|
+
const piece = 'x'.repeat(1024 * 1024);
|
|
147
|
+
connection.write(`{"jsonrpc":"2.0","id":1,"method":"catalog.get","params":{"pad":"`);
|
|
148
|
+
for (let sent = 0; sent < step.sendOversized && !connection.closed; sent += piece.length) { connection.write(piece); await sleep(1); }
|
|
149
|
+
} else if ('sendRaw' in step) connection.write(step.sendRaw);
|
|
150
|
+
else connection.send(step.send);
|
|
151
|
+
if (step.expectClose) {
|
|
152
|
+
const deadline = Date.now() + 3 * TIMEOUT;
|
|
153
|
+
while (!connection.closed && Date.now() < deadline) await sleep(20);
|
|
154
|
+
if (!connection.closed) throw new Error('the daemon kept the connection open');
|
|
155
|
+
if (connection.frames.some(isAnswer)) throw new Error(`the daemon answered before closing: ${JSON.stringify(connection.frames.find(isAnswer)).slice(0, 200)}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (step.expectNone !== undefined) {
|
|
159
|
+
await sleep(step.expectNone);
|
|
160
|
+
const answer = connection.frames.find(isAnswer);
|
|
161
|
+
if (answer) throw new Error(`expected no answer, got ${JSON.stringify(answer).slice(0, 300)}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (step.expectIds) {
|
|
165
|
+
for (const [id, expected] of Object.entries(step.expectIds)) {
|
|
166
|
+
const frame = await connection.take(candidate => isAnswer(candidate) && candidate.id === id, `the answer with id ${id}`);
|
|
167
|
+
const problem = mismatch(expected, frame);
|
|
168
|
+
if (problem) throw new Error(`answer ${id}: ${problem}: ${JSON.stringify(frame).slice(0, 300)}`);
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const sentId = step.send && typeof step.send === 'object' && !Array.isArray(step.send) ? step.send.id : undefined;
|
|
173
|
+
const frame = await connection.take(candidate => isAnswer(candidate) && (usableId(sentId) && step.expect.id !== null ? candidate.id === sentId : true), 'an answer');
|
|
174
|
+
const problem = mismatch(step.expect, frame);
|
|
175
|
+
if (problem) throw new Error(`${problem}: ${JSON.stringify(frame).slice(0, 400)}`);
|
|
176
|
+
if (frame.result !== undefined && step.send?.method) {
|
|
177
|
+
const issues = validateResult(step.send.method, frame.result);
|
|
178
|
+
if (issues?.length) throw new Error(`${step.send.method} result does not match its schema: ${describe(issues)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await sleep(50);
|
|
182
|
+
if (connection.frames.some(isAnswer)) throw new Error('daemon sent an unexpected or duplicate response');
|
|
183
|
+
if (connection.problems.length) throw new Error(connection.problems.join('\n'));
|
|
184
|
+
} finally {
|
|
185
|
+
connection.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Session scenarios: lifecycle events, cursor replay, reset, idempotent prompts, cancellation (ยง8โยง9). */
|
|
190
|
+
function sessionScenarios({ project, model, hold }) {
|
|
191
|
+
const spec = { cwd: project, persist: true, ...(model ? { model } : {}) };
|
|
192
|
+
const text = body => [{ type: 'text', text: body }];
|
|
193
|
+
const kept = events => events.filter(envelope => envelope.cursor !== undefined);
|
|
194
|
+
const until = async (connection, test, what, timeout = TIMEOUT) => {
|
|
195
|
+
const deadline = Date.now() + timeout;
|
|
196
|
+
for (;;) {
|
|
197
|
+
const found = connection.events().find(test);
|
|
198
|
+
if (found) return found;
|
|
199
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
|
|
200
|
+
await sleep(25);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const turnEnded = (connection, sessionId) => until(connection, envelope => envelope.sessionId === sessionId && envelope.event.type === 'turn.ended', 'turn.ended');
|
|
204
|
+
/** Cursors of kept envelopes rise strictly; seq rises per session and epoch. */
|
|
205
|
+
function ordered(events) {
|
|
206
|
+
let cursor = 0;
|
|
207
|
+
const seqs = new Map();
|
|
208
|
+
for (const envelope of kept(events)) {
|
|
209
|
+
if (envelope.cursor <= cursor) throw new Error(`cursor ${envelope.cursor} after ${cursor}`);
|
|
210
|
+
cursor = envelope.cursor;
|
|
211
|
+
const key = `${envelope.sessionId}\u0000${envelope.epoch}`;
|
|
212
|
+
if (envelope.seq <= (seqs.get(key) ?? -1)) throw new Error(`seq ${envelope.seq} of ${envelope.sessionId} did not rise`);
|
|
213
|
+
seqs.set(key, envelope.seq);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function created(connection) {
|
|
217
|
+
const capabilities = (await connection.hello()).capabilities ?? [];
|
|
218
|
+
const sessionId = `conf_${randomUUID().slice(0, 12)}`;
|
|
219
|
+
const reply = await connection.request('session.create', { sessionId, spec });
|
|
220
|
+
if (reply.error) throw new Error(`session.create failed: ${JSON.stringify(reply.error)}`);
|
|
221
|
+
if (reply.result.attached !== false) throw new Error('a new session answered attached');
|
|
222
|
+
await until(connection, envelope => envelope.sessionId === sessionId && envelope.event.type === 'session.ready', 'session.ready');
|
|
223
|
+
return { sessionId, capabilities };
|
|
224
|
+
}
|
|
225
|
+
const prompt = (connection, sessionId, body, clientMessageId = randomUUID(), delivery = 'auto') =>
|
|
226
|
+
connection.request('session.prompt', { sessionId, clientMessageId, delivery, content: text(body) });
|
|
227
|
+
|
|
228
|
+
return [
|
|
229
|
+
['session.lifecycle', 'create opens the session with session.opened then session.ready; a prompt is accepted, starts and ends a turn; every envelope validates and cursors rise', async connection => {
|
|
230
|
+
const { sessionId } = await created(connection);
|
|
231
|
+
const initial = connection.events().filter(envelope => envelope.sessionId === sessionId);
|
|
232
|
+
const opened = initial.find(envelope => envelope.event.type === 'session.opened');
|
|
233
|
+
if (initial.findIndex(envelope => envelope.event.type === 'session.opened') >= initial.findIndex(envelope => envelope.event.type === 'session.ready')) throw new Error('session.opened must precede session.ready');
|
|
234
|
+
if (!opened || opened.sessionId !== sessionId) throw new Error('no session.opened for the new session');
|
|
235
|
+
const reply = await prompt(connection, sessionId, 'conformance hello');
|
|
236
|
+
if (reply.error) throw new Error(`session.prompt failed: ${JSON.stringify(reply.error)}`);
|
|
237
|
+
const ended = await turnEnded(connection, sessionId);
|
|
238
|
+
if (ended.event.state !== 'completed') throw new Error(`turn ended ${ended.event.state}`);
|
|
239
|
+
const types = connection.events().filter(envelope => envelope.sessionId === sessionId).map(envelope => envelope.event.type);
|
|
240
|
+
for (const type of ['prompt.accepted', 'turn.started', 'turn.ended']) if (!types.includes(type)) throw new Error(`no ${type} event`);
|
|
241
|
+
if (types.indexOf('turn.started') > types.indexOf('turn.ended')) throw new Error('turn.ended before turn.started');
|
|
242
|
+
ordered(connection.events());
|
|
243
|
+
}],
|
|
244
|
+
['session.replay-after', 'attach { after } replays exactly the kept events after the cursor, in order, and answers the tip; after = tip replays nothing', async (connection, socketPath) => {
|
|
245
|
+
const { sessionId, capabilities } = await created(connection);
|
|
246
|
+
if (!capabilities.includes('attach.after')) return 'skipped: attach.after not advertised';
|
|
247
|
+
await prompt(connection, sessionId, 'conformance replay');
|
|
248
|
+
await turnEnded(connection, sessionId);
|
|
249
|
+
const seen = kept(connection.events());
|
|
250
|
+
const middle = seen[Math.floor(seen.length / 2)].cursor;
|
|
251
|
+
const other = await open(socketPath);
|
|
252
|
+
try {
|
|
253
|
+
await other.hello();
|
|
254
|
+
const reply = await other.request('session.attach', { sessionId, after: middle });
|
|
255
|
+
if (reply.error) throw new Error(`session.attach failed: ${JSON.stringify(reply.error)}`);
|
|
256
|
+
if (reply.result.reset) throw new Error('a cursor the tree reached answered reset');
|
|
257
|
+
const tip = seen.at(-1).cursor;
|
|
258
|
+
if (reply.result.cursor < tip) throw new Error(`attach answered cursor ${reply.result.cursor} below ${tip}`);
|
|
259
|
+
const replayed = kept(other.drainEvents()).filter(envelope => envelope.cursor <= reply.result.cursor);
|
|
260
|
+
if (reply.result.cursor > tip) await until(connection, envelope => envelope.cursor >= reply.result.cursor, 'the attach tip on the original connection');
|
|
261
|
+
const want = kept(connection.events()).filter(envelope => envelope.cursor > middle && envelope.cursor <= reply.result.cursor && !envelope.event.type.startsWith('question')).map(envelope => envelope.cursor);
|
|
262
|
+
const got = replayed.map(envelope => envelope.cursor);
|
|
263
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) throw new Error(`replayed cursors ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`);
|
|
264
|
+
const again = await other.request('session.attach', { sessionId, after: reply.result.cursor });
|
|
265
|
+
if (again.error || again.result.reset) throw new Error(`attach at the tip failed: ${JSON.stringify(again)}`);
|
|
266
|
+
if (kept(other.drainEvents()).some(envelope => envelope.cursor <= reply.result.cursor)) throw new Error('attach at the tip replayed events');
|
|
267
|
+
if (other.problems.length) throw new Error(other.problems.join('\n'));
|
|
268
|
+
} finally { other.close(); if (other.problems.length) throw new Error(other.problems.join('\n')); }
|
|
269
|
+
}],
|
|
270
|
+
['session.replay-reset', 'attach with a cursor beyond the tip answers reset: true and replays the timeline from its start', async (connection, socketPath) => {
|
|
271
|
+
const { sessionId, capabilities } = await created(connection);
|
|
272
|
+
if (!capabilities.includes('attach.after')) return 'skipped: attach.after not advertised';
|
|
273
|
+
const other = await open(socketPath);
|
|
274
|
+
try {
|
|
275
|
+
await other.hello();
|
|
276
|
+
const reply = await other.request('session.attach', { sessionId, after: 9_000_000_000_000 });
|
|
277
|
+
if (reply.error) throw new Error(`session.attach failed: ${JSON.stringify(reply.error)}`);
|
|
278
|
+
if (reply.result.reset !== true) throw new Error('a cursor beyond the tip did not answer reset');
|
|
279
|
+
const replayed = kept(other.drainEvents()).filter(envelope => envelope.cursor <= reply.result.cursor);
|
|
280
|
+
if (reply.result.cursor > kept(connection.events()).at(-1).cursor) await until(connection, envelope => envelope.cursor >= reply.result.cursor, 'the reset tip');
|
|
281
|
+
const want = kept(connection.events()).filter(envelope => envelope.cursor <= reply.result.cursor && !envelope.event.type.startsWith('question')).map(envelope => envelope.cursor);
|
|
282
|
+
if (JSON.stringify(replayed.map(envelope => envelope.cursor)) !== JSON.stringify(want)) throw new Error('the reset replay did not contain the full kept timeline');
|
|
283
|
+
ordered(replayed);
|
|
284
|
+
} finally { other.close(); if (other.problems.length) throw new Error(other.problems.join('\n')); }
|
|
285
|
+
}],
|
|
286
|
+
['session.live-after-replay', 'events after an attach arrive after its replay with higher cursors, never duplicated', async (connection, socketPath) => {
|
|
287
|
+
const { sessionId, capabilities } = await created(connection);
|
|
288
|
+
if (!capabilities.includes('attach.after')) return 'skipped: attach.after not advertised';
|
|
289
|
+
const other = await open(socketPath);
|
|
290
|
+
try {
|
|
291
|
+
await other.hello();
|
|
292
|
+
const attach = await other.request('session.attach', { sessionId, after: 0 });
|
|
293
|
+
await prompt(connection, sessionId, 'conformance live');
|
|
294
|
+
await turnEnded(other, sessionId);
|
|
295
|
+
const cursors = kept(other.events()).map(envelope => envelope.cursor);
|
|
296
|
+
if (new Set(cursors).size !== cursors.length) throw new Error('an event arrived twice');
|
|
297
|
+
ordered(other.events());
|
|
298
|
+
if (!kept(other.events()).some(envelope => envelope.cursor > attach.result.cursor)) throw new Error('no live event after the attach');
|
|
299
|
+
} finally { other.close(); if (other.problems.length) throw new Error(other.problems.join('\n')); }
|
|
300
|
+
}],
|
|
301
|
+
['session.prompt-idempotent', 'a repeated clientMessageId answers the first outcome without a second turn; other content with it is a 1004 key_conflict', async connection => {
|
|
302
|
+
const { sessionId, capabilities } = await created(connection);
|
|
303
|
+
if (!capabilities.includes('prompt.receipts')) return 'skipped: prompt.receipts not advertised';
|
|
304
|
+
const id = randomUUID();
|
|
305
|
+
const first = await prompt(connection, sessionId, 'conformance once', id);
|
|
306
|
+
if (first.error || first.result.outcome !== 'accepted') throw new Error(`first prompt: ${JSON.stringify(first)}`);
|
|
307
|
+
await turnEnded(connection, sessionId);
|
|
308
|
+
const repeat = await prompt(connection, sessionId, 'conformance once', id);
|
|
309
|
+
if (repeat.error || repeat.result.duplicate !== true || repeat.result.outcome !== 'accepted') throw new Error(`repeat: ${JSON.stringify(repeat)}`);
|
|
310
|
+
const conflict = await prompt(connection, sessionId, 'conformance other', id);
|
|
311
|
+
if (conflict.error?.code !== 1004 || conflict.error.data?.reason !== 'key_conflict') throw new Error(`other content: ${JSON.stringify(conflict)}`);
|
|
312
|
+
await sleep(200);
|
|
313
|
+
const accepted = connection.events().filter(envelope => envelope.event.type === 'prompt.accepted' && envelope.event.clientMessageId === id);
|
|
314
|
+
if (accepted.length !== 1) throw new Error(`${accepted.length} prompt.accepted events for one clientMessageId`);
|
|
315
|
+
}],
|
|
316
|
+
['session.invalid-prompt-no-effect', 'a prompt with invalid params is refused before any receipt or turn: the same clientMessageId then goes through as a first delivery', async connection => {
|
|
317
|
+
const { sessionId } = await created(connection);
|
|
318
|
+
const id = randomUUID();
|
|
319
|
+
const bad = await connection.request('session.prompt', { sessionId, clientMessageId: id, content: 'not an array' });
|
|
320
|
+
if (bad.error?.code !== -32602) throw new Error(`invalid prompt answered ${JSON.stringify(bad)}`);
|
|
321
|
+
const good = await prompt(connection, sessionId, 'conformance after invalid', id);
|
|
322
|
+
if (good.error || good.result.duplicate) throw new Error(`valid prompt after the invalid one: ${JSON.stringify(good)}`);
|
|
323
|
+
await turnEnded(connection, sessionId);
|
|
324
|
+
}],
|
|
325
|
+
['session.interrupt', 'session.interrupt cancels the running turn: turn.ended with state canceled; the request answers before or after it, either is valid', async connection => {
|
|
326
|
+
const { sessionId } = await created(connection);
|
|
327
|
+
if (!hold) return 'skipped: no --hold-marker';
|
|
328
|
+
await prompt(connection, sessionId, `conformance ${hold}`);
|
|
329
|
+
await until(connection, envelope => envelope.sessionId === sessionId && envelope.event.type === 'turn.started', 'turn.started');
|
|
330
|
+
const reply = await connection.request('session.interrupt', { sessionId });
|
|
331
|
+
if (reply.error) throw new Error(`session.interrupt failed: ${JSON.stringify(reply.error)}`);
|
|
332
|
+
const ended = await turnEnded(connection, sessionId);
|
|
333
|
+
if (ended.event.state !== 'canceled') throw new Error(`turn ended ${ended.event.state}, expected canceled`);
|
|
334
|
+
}],
|
|
335
|
+
['session.unicode', 'titles round-trip Unicode exactly; the 200-character limit counts code points', async connection => {
|
|
336
|
+
const { sessionId } = await created(connection);
|
|
337
|
+
const title = 'Phแป โ ๐ \u{1F468}โ\u{1F469}';
|
|
338
|
+
const renamed = await connection.request('session.rename', { sessionId, title });
|
|
339
|
+
if (renamed.error || renamed.result.session.title !== title) throw new Error(`rename: ${JSON.stringify(renamed)}`);
|
|
340
|
+
const astral = await connection.request('session.rename', { sessionId, title: '๐'.repeat(200) });
|
|
341
|
+
if (astral.error) throw new Error(`200 astral code points were refused: ${JSON.stringify(astral.error)}`);
|
|
342
|
+
const over = await connection.request('session.rename', { sessionId, title: '๐'.repeat(201) });
|
|
343
|
+
if (over.error?.code !== -32602) throw new Error(`201 code points answered ${JSON.stringify(over)}`);
|
|
344
|
+
}],
|
|
345
|
+
['session.release-and-close', 'release answers whether the idle root closed; otherwise close makes session.get report closed', async connection => {
|
|
346
|
+
const { sessionId } = await created(connection);
|
|
347
|
+
const released = await connection.request('session.release', { sessionId });
|
|
348
|
+
if (released.error || typeof released.result.closed !== 'boolean') throw new Error(`release: ${JSON.stringify(released)}`);
|
|
349
|
+
if (!released.result.closed) {
|
|
350
|
+
const closed = await connection.request('session.close', { sessionId });
|
|
351
|
+
if (closed.error) throw new Error(`close: ${JSON.stringify(closed.error)}`);
|
|
352
|
+
}
|
|
353
|
+
const listed = await connection.request('session.get', { sessionId });
|
|
354
|
+
if (listed.error || listed.result.session.status !== 'closed') throw new Error(`after close: ${JSON.stringify(listed)}`);
|
|
355
|
+
}],
|
|
356
|
+
];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function runConformance({ socket, profiles = PROFILES.filter(name => name !== 'session'), project, model, hold }) {
|
|
360
|
+
if (!Array.isArray(profiles) || !profiles.length || profiles.some(name => !PROFILES.includes(name))) throw new Error(`Select known profiles: ${PROFILES.join(', ')}`);
|
|
361
|
+
if (profiles.includes('session') && !project) throw new Error('the session profile needs --project');
|
|
362
|
+
const results = [];
|
|
363
|
+
for (const testCase of CASES.filter(candidate => profiles.includes(candidate.profile))) {
|
|
364
|
+
try { await runCase(socket, testCase); results.push({ id: testCase.id, ok: true }); }
|
|
365
|
+
catch (error) { results.push({ id: testCase.id, ok: false, error: error.message }); }
|
|
366
|
+
}
|
|
367
|
+
if (profiles.includes('session')) {
|
|
368
|
+
for (const [id, , scenario] of sessionScenarios({ project, model, hold })) {
|
|
369
|
+
let connection;
|
|
370
|
+
try {
|
|
371
|
+
connection = await open(socket);
|
|
372
|
+
const note = await scenario(connection, socket);
|
|
373
|
+
if (connection.problems.length) throw new Error(connection.problems.join('\n'));
|
|
374
|
+
results.push({ id, ok: true, ...(note ? { note } : {}) });
|
|
375
|
+
} catch (error) {
|
|
376
|
+
results.push({ id, ok: false, error: error.message });
|
|
377
|
+
} finally { connection?.close(); }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return results;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The scenario ids and descriptions, for documentation and tests. */
|
|
384
|
+
export const SESSION_SCENARIOS = sessionScenarios({}).map(([id, description]) => ({ id, description }));
|
|
385
|
+
export { CASES };
|
|
386
|
+
|
|
387
|
+
function isMain() {
|
|
388
|
+
if (!process.argv[1]) return false;
|
|
389
|
+
try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); }
|
|
390
|
+
catch { return false; }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isMain()) {
|
|
394
|
+
const { values } = parseArgs({ options: { socket: { type: 'string' }, profiles: { type: 'string' }, project: { type: 'string' }, model: { type: 'string' }, 'hold-marker': { type: 'string' }, json: { type: 'boolean' } } });
|
|
395
|
+
if (!values.socket) { console.error('Usage: run.mjs --socket <path> [--profiles a,b] [--project <dir> --model <model> --hold-marker <text>] [--json]'); process.exit(2); }
|
|
396
|
+
const profiles = values.profiles ? values.profiles.split(',').map(name => name.trim()) : undefined;
|
|
397
|
+
const results = await runConformance({ socket: values.socket, profiles, project: values.project, model: values.model, hold: values['hold-marker'] });
|
|
398
|
+
if (values.json) console.log(JSON.stringify(results, null, 2));
|
|
399
|
+
else for (const result of results) console.log(`${result.ok ? 'ok ' : 'FAIL'} ${result.id}${result.note ? ` (${result.note})` : ''}${result.error ? `\n ${result.error.replaceAll('\n', '\n ')}` : ''}`);
|
|
400
|
+
const failed = results.filter(result => !result.ok).length;
|
|
401
|
+
console.error(`${results.length - failed}/${results.length} passed`);
|
|
402
|
+
process.exitCode = failed ? 1 : 0;
|
|
403
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Envelope } from './events.js';
|
|
2
|
+
export type DaemonConnection = {
|
|
3
|
+
request<T = any>(method: string, params?: unknown): Promise<T>;
|
|
4
|
+
onEvent(listener: (envelope: Envelope) => void): () => void;
|
|
5
|
+
onClose(listener: (error: Error) => void): () => void;
|
|
6
|
+
close(): void;
|
|
7
|
+
/** True once the connection is gone; socket clients report it, in-process ones may not. */
|
|
8
|
+
readonly closed?: boolean;
|
|
9
|
+
};
|
|
File without changes
|