plumb-bridge 0.1.2 → 0.1.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/LICENSE +21 -0
- package/README.md +193 -0
- package/package.json +17 -3
- package/src/adapters/claude.ts +21 -62
- package/src/adapters/cursor.ts +213 -0
- package/src/adapters/detect.ts +27 -0
- package/src/adapters/echo.ts +5 -23
- package/src/adapters/generic.ts +5 -14
- package/src/adapters/opencode.ts +11 -59
- package/src/adapters/pi.ts +40 -66
- package/src/adapters/registry.ts +24 -1
- package/src/adapters/stream-json.ts +89 -0
- package/src/adapters/venom.ts +78 -0
- package/src/adapters/wolfy.ts +94 -0
- package/src/cli.ts +215 -10
- package/src/config.test.ts +170 -0
- package/src/config.ts +178 -0
- package/src/core/executor.ts +113 -77
- package/src/core/ledger.ts +15 -10
- package/src/core/log.ts +12 -0
- package/src/core/process.ts +193 -10
- package/src/core/server.ts +38 -7
- package/src/core/session-store.ts +158 -0
- package/src/core/task-store.ts +137 -0
- package/src/types.ts +30 -1
- package/test/adapter-parse.test.ts +328 -0
- package/test/persistent-process.test.ts +56 -0
- package/test/rpc.test.ts +57 -0
- package/test/session-store.test.ts +129 -0
- package/test/task-store.test.ts +95 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// PLUMB — Adapter Unit Tests
|
|
2
|
+
// parseLine fixtures for all adapters. No live CLIs needed — pure parse validation.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'bun:test';
|
|
5
|
+
import { EchoAdapter } from '../src/adapters/echo.ts';
|
|
6
|
+
import { PiAdapter } from '../src/adapters/pi.ts';
|
|
7
|
+
import { ClaudeAdapter } from '../src/adapters/claude.ts';
|
|
8
|
+
import { CursorAdapter } from '../src/adapters/cursor.ts';
|
|
9
|
+
import { OpenCodeAdapter } from '../src/adapters/opencode.ts';
|
|
10
|
+
import { VenomAdapter } from '../src/adapters/venom.ts';
|
|
11
|
+
import { GenericAdapter } from '../src/adapters/generic.ts';
|
|
12
|
+
import { tryParseLine, extractContentText, textDelta, statusEvent, errorEvent } from '../src/adapters/stream-json.ts';
|
|
13
|
+
|
|
14
|
+
// ── Shared stream-json utility tests ──────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe('stream-json utilities', () => {
|
|
17
|
+
it('tryParseLine: empty line → null json, empty raw', () => {
|
|
18
|
+
const result = tryParseLine(' ');
|
|
19
|
+
expect(result.json).toBeNull();
|
|
20
|
+
expect(result.raw).toBe('');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('tryParseLine: non-JSON line → null json, raw preserved', () => {
|
|
24
|
+
const result = tryParseLine('hello world');
|
|
25
|
+
expect(result.json).toBeNull();
|
|
26
|
+
expect(result.raw).toBe('hello world');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('tryParseLine: valid JSON → parsed object', () => {
|
|
30
|
+
const result = tryParseLine('{"type":"text","text":"hi"}');
|
|
31
|
+
expect(result.json).not.toBeNull();
|
|
32
|
+
expect(result.json!.type).toBe('text');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('extractContentText: extracts text blocks from message.content', () => {
|
|
36
|
+
const event = {
|
|
37
|
+
type: 'assistant',
|
|
38
|
+
message: {
|
|
39
|
+
content: [
|
|
40
|
+
{ type: 'text', text: 'Hello ' },
|
|
41
|
+
{ type: 'text', text: 'World' },
|
|
42
|
+
{ type: 'image', url: 'x' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
expect(extractContentText(event)).toBe('Hello \nWorld');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('extractContentText: returns null when no text content', () => {
|
|
50
|
+
const event = {
|
|
51
|
+
type: 'assistant',
|
|
52
|
+
message: { content: [{ type: 'image', url: 'x' }] },
|
|
53
|
+
};
|
|
54
|
+
expect(extractContentText(event)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('extractContentText: returns null when no message', () => {
|
|
58
|
+
expect(extractContentText({ type: 'assistant' })).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('helper functions produce correct events', () => {
|
|
62
|
+
expect(textDelta('hi')).toEqual({ type: 'text-delta', text: 'hi' });
|
|
63
|
+
expect(statusEvent('completed')).toEqual({ type: 'status', state: 'completed' });
|
|
64
|
+
expect(errorEvent('fail')).toEqual({ type: 'error', message: 'fail' });
|
|
65
|
+
expect(errorEvent('fail', 'CODE')).toEqual({ type: 'error', message: 'fail', code: 'CODE' });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── Echo Adapter ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('EchoAdapter', () => {
|
|
72
|
+
const adapter = new EchoAdapter();
|
|
73
|
+
|
|
74
|
+
it('returns text-delta for each non-empty line', () => {
|
|
75
|
+
const events = adapter.parseLine('hello');
|
|
76
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'hello\n' }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('skips empty lines', () => {
|
|
80
|
+
expect(adapter.parseLine(' ')).toEqual([]);
|
|
81
|
+
expect(adapter.parseLine('')).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('formatInput appends newline', () => {
|
|
85
|
+
expect(adapter.formatInput({ id: '1', message: 'test' })).toBe('test\n');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('buildArgs returns empty array', () => {
|
|
89
|
+
expect(adapter.buildArgs({ id: '1', message: 'test' }, { cli: 'cat', port: 3001 })).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Pi Adapter ────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('PiAdapter', () => {
|
|
96
|
+
const adapter = new PiAdapter();
|
|
97
|
+
|
|
98
|
+
it('parses text-delta event', () => {
|
|
99
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'text-delta', text: 'hello' }));
|
|
100
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'hello' }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('parses message_update with text_delta', () => {
|
|
104
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
105
|
+
type: 'message_update',
|
|
106
|
+
assistantMessageEvent: { type: 'text_delta', delta: 'world' },
|
|
107
|
+
}));
|
|
108
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'world' }]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('filters extension_ui_request events', () => {
|
|
112
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'extension_ui_request' }))).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('parses agent_end as completed', () => {
|
|
116
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'agent_end' }));
|
|
117
|
+
expect(events).toEqual([{ type: 'status', state: 'completed' }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('parses error event', () => {
|
|
121
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'error', error: 'test error' }));
|
|
122
|
+
expect(events).toEqual([{ type: 'error', message: 'test error' }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('non-JSON line → raw text delta', () => {
|
|
126
|
+
const events = adapter.parseLine('raw text output');
|
|
127
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'raw text output\n' }]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Claude Adapter ────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe('ClaudeAdapter', () => {
|
|
134
|
+
const adapter = new ClaudeAdapter();
|
|
135
|
+
|
|
136
|
+
it('parses assistant message with text content', () => {
|
|
137
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
138
|
+
type: 'assistant',
|
|
139
|
+
message: { content: [{ type: 'text', text: 'Hello Claude' }] },
|
|
140
|
+
}));
|
|
141
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'Hello Claude' }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('filters system and rate_limit events', () => {
|
|
145
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'system' }))).toEqual([]);
|
|
146
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'rate_limit_event' }))).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('parses successful result as completed', () => {
|
|
150
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'result' }));
|
|
151
|
+
expect(events).toEqual([{ type: 'status', state: 'completed' }]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('parses error result', () => {
|
|
155
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'result', is_error: true, error: 'fail' }));
|
|
156
|
+
expect(events).toEqual([{ type: 'error', message: 'fail' }]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('parses explicit error event', () => {
|
|
160
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'error', error: 'bad' }));
|
|
161
|
+
expect(events).toEqual([{ type: 'error', message: 'bad' }]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── Cursor Adapter ────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('CursorAdapter', () => {
|
|
168
|
+
const adapter = new CursorAdapter();
|
|
169
|
+
|
|
170
|
+
it('parses thinking event as text-delta', () => {
|
|
171
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'thinking', text: 'reasoning...' }));
|
|
172
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'reasoning...' }]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('parses assistant content blocks', () => {
|
|
176
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
177
|
+
type: 'assistant',
|
|
178
|
+
message: { content: [{ type: 'text', text: 'code here' }] },
|
|
179
|
+
}));
|
|
180
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'code here' }]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('filters system and user events', () => {
|
|
184
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'system' }))).toEqual([]);
|
|
185
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'user' }))).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('parses tool_call with shellToolCall', () => {
|
|
189
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
190
|
+
type: 'tool_call',
|
|
191
|
+
tool_call: { shellToolCall: { args: { command: 'ls' } } },
|
|
192
|
+
}));
|
|
193
|
+
expect(events).toEqual([{ type: 'tool-call', tool: 'shell', input: { command: 'ls' } }]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('skips consolidated events when streamPartial=true', () => {
|
|
197
|
+
adapter.streamPartial = true;
|
|
198
|
+
// Consolidated event: no timestamp_ms
|
|
199
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
200
|
+
type: 'assistant',
|
|
201
|
+
message: { content: [{ type: 'text', text: 'should skip' }] },
|
|
202
|
+
}));
|
|
203
|
+
expect(events).toEqual([]);
|
|
204
|
+
// Streaming delta: has timestamp_ms
|
|
205
|
+
const delta = adapter.parseLine(JSON.stringify({
|
|
206
|
+
type: 'assistant',
|
|
207
|
+
message: { content: [{ type: 'text', text: 'should emit' }] },
|
|
208
|
+
timestamp_ms: 1234,
|
|
209
|
+
}));
|
|
210
|
+
expect(delta).toEqual([{ type: 'text-delta', text: 'should emit' }]);
|
|
211
|
+
adapter.streamPartial = false; // reset
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('parses successful result as completed', () => {
|
|
215
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'result' }))).toEqual([{ type: 'status', state: 'completed' }]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('parses error result', () => {
|
|
219
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'result', subtype: 'error', error: 'crash' }))).toEqual([{ type: 'error', message: 'crash' }]);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── VENOM Adapter ──────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe('VenomAdapter', () => {
|
|
226
|
+
const adapter = new VenomAdapter();
|
|
227
|
+
|
|
228
|
+
it('parses assistant content blocks', () => {
|
|
229
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
230
|
+
type: 'assistant',
|
|
231
|
+
message: { content: [{ type: 'text', text: 'venom output' }] },
|
|
232
|
+
timestamp_ms: 100,
|
|
233
|
+
}));
|
|
234
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'venom output' }]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('skips consolidated events (streamPartial=true by default)', () => {
|
|
238
|
+
// No timestamp_ms = consolidated → skip
|
|
239
|
+
expect(adapter.parseLine(JSON.stringify({
|
|
240
|
+
type: 'assistant',
|
|
241
|
+
message: { content: [{ type: 'text', text: 'skip' }] },
|
|
242
|
+
}))).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('parses successful result as completed', () => {
|
|
246
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'result' }))).toEqual([{ type: 'status', state: 'completed' }]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('parses error event', () => {
|
|
250
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'error', error: 'fail' }))).toEqual([{ type: 'error', message: 'fail' }]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('filters system and user events', () => {
|
|
254
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'system' }))).toEqual([]);
|
|
255
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'user' }))).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('parses tool_call with shellToolCall', () => {
|
|
259
|
+
const events = adapter.parseLine(JSON.stringify({
|
|
260
|
+
type: 'tool_call',
|
|
261
|
+
tool_call: { shellToolCall: { args: { cmd: 'cargo build' } } },
|
|
262
|
+
}));
|
|
263
|
+
expect(events).toEqual([{ type: 'tool-call', tool: 'shell', input: { cmd: 'cargo build' } }]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ── OpenCode Adapter ──────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe('OpenCodeAdapter', () => {
|
|
270
|
+
const adapter = new OpenCodeAdapter();
|
|
271
|
+
|
|
272
|
+
it('parses text event', () => {
|
|
273
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'text', text: 'hello' }));
|
|
274
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'hello' }]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('parses content event with text', () => {
|
|
278
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'content', content: 'output' }));
|
|
279
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'output' }]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('parses step_finish with stop reason as completed', () => {
|
|
283
|
+
const events = adapter.parseLine(JSON.stringify({ type: 'step_finish', part: { reason: 'stop' } }));
|
|
284
|
+
expect(events).toEqual([{ type: 'status', state: 'completed' }]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('parses done event as completed', () => {
|
|
288
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'done' }))).toEqual([{ type: 'status', state: 'completed' }]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('parses session.completed as completed', () => {
|
|
292
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'session.completed' }))).toEqual([{ type: 'status', state: 'completed' }]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('parses error event', () => {
|
|
296
|
+
expect(adapter.parseLine(JSON.stringify({ type: 'error', error: 'break' }))).toEqual([{ type: 'error', message: 'break' }]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('non-JSON line → raw text delta', () => {
|
|
300
|
+
const events = adapter.parseLine('plain text');
|
|
301
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'plain text\n' }]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('formatInput wraps in JSON prompt', () => {
|
|
305
|
+
const input = adapter.formatInput({ id: '1', message: 'test prompt' });
|
|
306
|
+
expect(JSON.parse(input)).toEqual({ prompt: 'test prompt' });
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── Generic Adapter ────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
describe('GenericAdapter', () => {
|
|
313
|
+
it('returns text-delta for each non-empty line', () => {
|
|
314
|
+
const adapter = new GenericAdapter('cat');
|
|
315
|
+
const events = adapter.parseLine('anything');
|
|
316
|
+
expect(events).toEqual([{ type: 'text-delta', text: 'anything\n' }]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('skips empty lines', () => {
|
|
320
|
+
const adapter = new GenericAdapter('cat');
|
|
321
|
+
expect(adapter.parseLine(' ')).toEqual([]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('formatInput appends newline', () => {
|
|
325
|
+
const adapter = new GenericAdapter('cat');
|
|
326
|
+
expect(adapter.formatInput({ id: '1', message: 'hello' })).toBe('hello\n');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// PLUMB — PersistentProcess Unit Tests
|
|
2
|
+
// Validates waitUntilReady, ready-frame interception, crash rejection.
|
|
3
|
+
|
|
4
|
+
import { describe, test, expect } from 'bun:test';
|
|
5
|
+
import { PersistentProcess } from '../src/core/process.ts';
|
|
6
|
+
|
|
7
|
+
describe('PersistentProcess', () => {
|
|
8
|
+
test('waitUntilReady resolves on { "type": "ready" } frame', async () => {
|
|
9
|
+
const pp = new PersistentProcess('echo', [], {});
|
|
10
|
+
// Manually inject a ready frame via routeLine (testing the signal path)
|
|
11
|
+
// We can't easily spawn a real process here, so we test the public API contract
|
|
12
|
+
// by verifying waitUntilReady rejects on timeout with no process
|
|
13
|
+
await expect(
|
|
14
|
+
pp.waitUntilReady(100),
|
|
15
|
+
).rejects.toThrow('Timed out waiting for persistent agent ready frame');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('isAlive returns false before ensure', () => {
|
|
19
|
+
const pp = new PersistentProcess('echo', [], {});
|
|
20
|
+
expect(pp.isAlive).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('isAlive returns true after ensure with echo', async () => {
|
|
24
|
+
const pp = new PersistentProcess('echo', [], {});
|
|
25
|
+
await pp.ensure();
|
|
26
|
+
expect(pp.isAlive).toBe(true);
|
|
27
|
+
await pp.kill();
|
|
28
|
+
expect(pp.isAlive).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('routeLine swallows ready frame, does not route to task handler', async () => {
|
|
32
|
+
const pp = new PersistentProcess('echo', [], {});
|
|
33
|
+
let handlerCalled = false;
|
|
34
|
+
pp.setLineHandler('test-task', () => { handlerCalled = true; });
|
|
35
|
+
|
|
36
|
+
// Simulate what routeLine does internally by checking that
|
|
37
|
+
// after ensure + kill cycle, the process was valid
|
|
38
|
+
await pp.ensure();
|
|
39
|
+
await pp.kill();
|
|
40
|
+
// If we could call routeLine directly we'd verify the ready frame is swallowed.
|
|
41
|
+
// Since routeLine is private, we verify via the integration test (conformance).
|
|
42
|
+
// Here we just confirm the process lifecycle works.
|
|
43
|
+
expect(pp.isAlive).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('ensure is idempotent — second call returns immediately', async () => {
|
|
47
|
+
const pp = new PersistentProcess('sleep', ['60'], {});
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
await pp.ensure();
|
|
50
|
+
await pp.ensure(); // should not spawn again
|
|
51
|
+
const elapsed = Date.now() - start;
|
|
52
|
+
expect(elapsed).toBeLessThan(2000);
|
|
53
|
+
expect(pp.isAlive).toBe(true);
|
|
54
|
+
await pp.kill();
|
|
55
|
+
});
|
|
56
|
+
});
|
package/test/rpc.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// PLUMB — RPC Unit Tests
|
|
2
|
+
// Validates sendRpcCommand, response correlation, host tool execution, timeout.
|
|
3
|
+
|
|
4
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
5
|
+
import { PersistentProcess } from '../src/core/process.ts';
|
|
6
|
+
import type { RpcHostToolExecutor } from '../src/types.ts';
|
|
7
|
+
|
|
8
|
+
describe('PersistentProcess RPC', () => {
|
|
9
|
+
let proc: PersistentProcess;
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await proc?.kill();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('sendRpcCommand rejects when process is not alive', async () => {
|
|
16
|
+
proc = new PersistentProcess('echo', [], {});
|
|
17
|
+
expect(proc.isAlive).toBe(false);
|
|
18
|
+
await expect(proc.sendRpcCommand({ type: 'ping' })).rejects.toThrow('Persistent process is not alive');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('sendRpcCommand times out when no response arrives', async () => {
|
|
22
|
+
proc = new PersistentProcess('cat', [], {});
|
|
23
|
+
await proc.ensure();
|
|
24
|
+
const result = await proc.sendRpcCommand({ type: 'ping' }, { timeoutMs: 200 });
|
|
25
|
+
expect(result.success).toBe(false);
|
|
26
|
+
expect(result.error).toContain('RPC correlation timeout');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('sendRpcCommand auto-assigns correlation id', async () => {
|
|
30
|
+
proc = new PersistentProcess('cat', [], {});
|
|
31
|
+
await proc.ensure();
|
|
32
|
+
// We can't easily test full correlation without a real echo agent,
|
|
33
|
+
// but we can verify the method doesn't throw for alive processes
|
|
34
|
+
// and the timeout path works with auto-assigned IDs.
|
|
35
|
+
const result = await proc.sendRpcCommand({ type: 'test' }, { timeoutMs: 100 });
|
|
36
|
+
expect(result.success).toBe(false);
|
|
37
|
+
expect(result.error).toContain('test');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('setHostToolExecutor and setRpcTimeoutMs are no-ops on dead process', () => {
|
|
41
|
+
proc = new PersistentProcess('echo', [], {});
|
|
42
|
+
const executor: RpcHostToolExecutor = async () => ({ content: [{ type: 'text', text: 'ok' }] });
|
|
43
|
+
expect(() => proc.setHostToolExecutor(executor)).not.toThrow();
|
|
44
|
+
expect(() => proc.setRpcTimeoutMs(5000)).not.toThrow();
|
|
45
|
+
expect(() => proc.setRpcTimeoutMs(0)).not.toThrow(); // clamped to 1000
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('host_tool_cancel does not throw without executor', async () => {
|
|
49
|
+
proc = new PersistentProcess('cat', [], {});
|
|
50
|
+
await proc.ensure();
|
|
51
|
+
// Sending a host_tool_cancel line to routeLine without an executor should not crash
|
|
52
|
+
// (routeLine handles it gracefully via dispatchProtocolFrame)
|
|
53
|
+
// We can't call routeLine directly (private), but we verify kill doesn't crash
|
|
54
|
+
await proc.kill();
|
|
55
|
+
expect(proc.isAlive).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { CursorSessionStore } from '../src/core/session-store.ts';
|
|
3
|
+
|
|
4
|
+
describe('CursorSessionStore', () => {
|
|
5
|
+
it('registers a new session and sets lastSession', () => {
|
|
6
|
+
const store = new CursorSessionStore();
|
|
7
|
+
store.register('s1', '/workspace', 'model-a');
|
|
8
|
+
expect(store.lastSession).toBe('s1');
|
|
9
|
+
const s = store.get('s1');
|
|
10
|
+
expect(s).toBeDefined();
|
|
11
|
+
expect(s!.id).toBe('s1');
|
|
12
|
+
expect(s!.workspace).toBe('/workspace');
|
|
13
|
+
expect(s!.model).toBe('model-a');
|
|
14
|
+
expect(s!.turnCount).toBe(1);
|
|
15
|
+
expect(s!.turns).toHaveLength(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('increments turnCount on re-register', () => {
|
|
19
|
+
const store = new CursorSessionStore();
|
|
20
|
+
store.register('s1', '/w', 'm');
|
|
21
|
+
store.register('s1', '/w', 'm');
|
|
22
|
+
expect(store.get('s1')!.turnCount).toBe(2);
|
|
23
|
+
expect(store.lastSession).toBe('s1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('records completed turns with truncation', () => {
|
|
27
|
+
const store = new CursorSessionStore({ recapMaxTurns: 2, recapMaxCharsPerLeg: 20 });
|
|
28
|
+
store.register('s1', '/w', 'm');
|
|
29
|
+
store.recordCompletedTurn('s1', 'hello', 'world');
|
|
30
|
+
store.recordCompletedTurn('s1', 'msg2', 'resp2');
|
|
31
|
+
store.recordCompletedTurn('s1', 'msg3', 'resp3'); // should evict msg1
|
|
32
|
+
const s = store.get('s1')!;
|
|
33
|
+
expect(s.turns).toHaveLength(2);
|
|
34
|
+
expect(s.turns[0]!.user).toBe('msg2');
|
|
35
|
+
expect(s.turns[1]!.user).toBe('msg3');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('expireLastSessionIfStale drops session and queues recap when turns exist', () => {
|
|
39
|
+
const store = new CursorSessionStore({ sessionTtlMs: 60_000 });
|
|
40
|
+
store.register('s1', '/w', 'm');
|
|
41
|
+
store.recordCompletedTurn('s1', 'user says', 'assistant says');
|
|
42
|
+
// Backdate lastUsedAt to 2 minutes ago
|
|
43
|
+
const s = store.get('s1')!;
|
|
44
|
+
s.lastUsedAt = new Date(Date.now() - 120_000);
|
|
45
|
+
store.expireLastSessionIfStale();
|
|
46
|
+
expect(store.lastSession).toBeNull();
|
|
47
|
+
expect(store.get('s1')).toBeUndefined();
|
|
48
|
+
const recap = store.consumeColdRecap();
|
|
49
|
+
expect(recap).toContain('user says');
|
|
50
|
+
expect(recap).toContain('assistant says');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('expireLastSessionIfStale clears stale session without recap when no turns', () => {
|
|
54
|
+
const store = new CursorSessionStore({ sessionTtlMs: 1000 });
|
|
55
|
+
store.register('s1', '/w', 'm');
|
|
56
|
+
const s = store.get('s1')!;
|
|
57
|
+
s.lastUsedAt = new Date(Date.now() - 5000);
|
|
58
|
+
store.expireLastSessionIfStale();
|
|
59
|
+
expect(store.lastSession).toBeNull();
|
|
60
|
+
expect(store.consumeColdRecap()).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('consumeColdRecap returns null on second call', () => {
|
|
64
|
+
const store = new CursorSessionStore({ sessionTtlMs: 1000 });
|
|
65
|
+
store.register('s1', '/w', 'm');
|
|
66
|
+
store.recordCompletedTurn('s1', 'q', 'a');
|
|
67
|
+
const s = store.get('s1')!;
|
|
68
|
+
s.lastUsedAt = new Date(Date.now() - 5000);
|
|
69
|
+
store.expireLastSessionIfStale();
|
|
70
|
+
expect(store.consumeColdRecap()).not.toBeNull();
|
|
71
|
+
expect(store.consumeColdRecap()).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not expire when within TTL', () => {
|
|
75
|
+
const store = new CursorSessionStore({ sessionTtlMs: 300_000 });
|
|
76
|
+
store.register('s1', '/w', 'm');
|
|
77
|
+
store.expireLastSessionIfStale();
|
|
78
|
+
expect(store.lastSession).toBe('s1');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not expire when TTL is null', () => {
|
|
82
|
+
const store = new CursorSessionStore({ sessionTtlMs: null });
|
|
83
|
+
store.register('s1', '/w', 'm');
|
|
84
|
+
const s = store.get('s1')!;
|
|
85
|
+
s.lastUsedAt = new Date(Date.now() - 1_000_000);
|
|
86
|
+
store.expireLastSessionIfStale();
|
|
87
|
+
expect(store.lastSession).toBe('s1');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('clear resets everything', () => {
|
|
91
|
+
const store = new CursorSessionStore({ sessionTtlMs: 1000 });
|
|
92
|
+
store.register('s1', '/w', 'm');
|
|
93
|
+
store.recordCompletedTurn('s1', 'q', 'a');
|
|
94
|
+
const s = store.get('s1')!;
|
|
95
|
+
s.lastUsedAt = new Date(Date.now() - 5000);
|
|
96
|
+
store.expireLastSessionIfStale();
|
|
97
|
+
store.clear();
|
|
98
|
+
expect(store.lastSession).toBeNull();
|
|
99
|
+
expect(store.list()).toHaveLength(0);
|
|
100
|
+
expect(store.consumeColdRecap()).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('list returns sessions sorted by lastUsedAt desc', () => {
|
|
104
|
+
const store = new CursorSessionStore();
|
|
105
|
+
store.register('s1', '/w', 'm');
|
|
106
|
+
// Advance time by ensuring a real delay or manual backdate
|
|
107
|
+
const s1 = store.get('s1')!;
|
|
108
|
+
s1.lastUsedAt = new Date(Date.now() - 1000);
|
|
109
|
+
store.register('s2', '/w2', 'm2');
|
|
110
|
+
const list = store.list();
|
|
111
|
+
expect(list).toHaveLength(2);
|
|
112
|
+
expect(list[0]!.id).toBe('s2'); // most recent first
|
|
113
|
+
expect(list[1]!.id).toBe('s1');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('recordCompletedTurn ignores null sessionId', () => {
|
|
117
|
+
const store = new CursorSessionStore();
|
|
118
|
+
store.register('s1', '/w', 'm');
|
|
119
|
+
// Should not throw
|
|
120
|
+
store.recordCompletedTurn(null, 'q', 'a');
|
|
121
|
+
expect(store.get('s1')!.turns).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('recordCompletedTurn ignores unknown sessionId', () => {
|
|
125
|
+
const store = new CursorSessionStore();
|
|
126
|
+
store.recordCompletedTurn('nonexistent', 'q', 'a');
|
|
127
|
+
expect(store.list()).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// PLUMB — Task Store Unit Tests
|
|
2
|
+
// Validates LRU eviction, TTL cleanup, terminal state tracking.
|
|
3
|
+
|
|
4
|
+
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
5
|
+
import { PlumbTaskStore } from '../src/core/task-store.ts';
|
|
6
|
+
import type { Task } from '@a2a-js/sdk';
|
|
7
|
+
|
|
8
|
+
function makeTask(id: string, state: string): Task {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
contextId: id,
|
|
12
|
+
kind: 'task' as const,
|
|
13
|
+
status: { state, timestamp: new Date().toISOString() },
|
|
14
|
+
history: [],
|
|
15
|
+
artifacts: [],
|
|
16
|
+
} as Task;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('PlumbTaskStore', () => {
|
|
20
|
+
let store: PlumbTaskStore;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
store = new PlumbTaskStore({ maxTasks: 5, completedRetentionMinutes: 0 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('save and load', async () => {
|
|
27
|
+
const task = makeTask('t1', 'working');
|
|
28
|
+
await store.save(task);
|
|
29
|
+
const loaded = await store.load('t1');
|
|
30
|
+
expect(loaded).not.toBeUndefined();
|
|
31
|
+
expect(loaded!.id).toBe('t1');
|
|
32
|
+
expect(loaded!.status.state).toBe('working');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('load missing returns undefined', async () => {
|
|
36
|
+
const result = await store.load('nonexistent');
|
|
37
|
+
expect(result).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('delete removes task', async () => {
|
|
41
|
+
const task = makeTask('t1', 'working');
|
|
42
|
+
await store.save(task);
|
|
43
|
+
await store.delete('t1');
|
|
44
|
+
const loaded = await store.load('t1');
|
|
45
|
+
expect(loaded).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('LRU evicts terminal tasks first', async () => {
|
|
49
|
+
const small = new PlumbTaskStore({ maxTasks: 3 });
|
|
50
|
+
// Fill with terminal tasks
|
|
51
|
+
for (let i = 0; i < 4; i++) {
|
|
52
|
+
await small.save(makeTask(`t${i}`, 'completed'));
|
|
53
|
+
}
|
|
54
|
+
// Oldest terminal should be evicted
|
|
55
|
+
const t0 = await small.load('t0');
|
|
56
|
+
expect(t0).toBeUndefined();
|
|
57
|
+
// Newer tasks still present
|
|
58
|
+
const t3 = await small.load('t3');
|
|
59
|
+
expect(t3).not.toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('LRU prefers evicting terminal over active tasks', async () => {
|
|
63
|
+
const small = new PlumbTaskStore({ maxTasks: 3 });
|
|
64
|
+
await small.save(makeTask('a', 'completed'));
|
|
65
|
+
await small.save(makeTask('b', 'completed'));
|
|
66
|
+
await small.save(makeTask('c', 'working')); // active
|
|
67
|
+
// Force eviction by adding a 4th
|
|
68
|
+
await small.save(makeTask('d', 'completed'));
|
|
69
|
+
// 'a' (terminal) should be evicted, not 'c' (active)
|
|
70
|
+
expect(await small.load('a')).toBeUndefined();
|
|
71
|
+
expect(await small.load('c')).not.toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('cleanupStaleCompleted removes old terminal tasks', () => {
|
|
75
|
+
const storeWithRetention = new PlumbTaskStore({ maxTasks: 5, completedRetentionMinutes: 1 });
|
|
76
|
+
const task = makeTask('old', 'completed');
|
|
77
|
+
storeWithRetention.save(task);
|
|
78
|
+
// Force entry to have old terminal timestamp by manipulating internals
|
|
79
|
+
// @ts-expect-error — accessing private for test
|
|
80
|
+
const entry = storeWithRetention.entries.get('old');
|
|
81
|
+
if (entry) entry.terminalSinceMs = Date.now() - 120_000; // 2 min ago
|
|
82
|
+
storeWithRetention.cleanupStaleCompleted();
|
|
83
|
+
expect(storeWithRetention.size).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('size reflects stored count', async () => {
|
|
87
|
+
expect(store.size).toBe(0);
|
|
88
|
+
await store.save(makeTask('a', 'working'));
|
|
89
|
+
expect(store.size).toBe(1);
|
|
90
|
+
await store.save(makeTask('b', 'completed'));
|
|
91
|
+
expect(store.size).toBe(2);
|
|
92
|
+
await store.delete('a');
|
|
93
|
+
expect(store.size).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
package/tsconfig.json
CHANGED