@sage-protocol/sage-plugin 0.1.5 → 0.1.6
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/README.md +12 -8
- package/index.js +549 -571
- package/index.test.js +464 -422
- package/mcp.integration.test.js +245 -122
- package/package.json +16 -16
- package/rlm-feedback.e2e.test.js +295 -0
- package/rlm.e2e.test.js +149 -0
- package/test-utils.js +287 -0
package/index.test.js
CHANGED
|
@@ -2,426 +2,468 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
|
|
2
2
|
import SagePlugin from "./index.js";
|
|
3
3
|
|
|
4
4
|
describe("SagePlugin", () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "1";
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const makeClient = () => {
|
|
10
|
+
const appLogCalls = [];
|
|
11
|
+
const promptAppends = [];
|
|
12
|
+
return {
|
|
13
|
+
client: {
|
|
14
|
+
app: {
|
|
15
|
+
log: ({ level, message, extra }) => appLogCalls.push({ level, message, extra }),
|
|
16
|
+
},
|
|
17
|
+
tui: {
|
|
18
|
+
appendPrompt: ({ body }) => promptAppends.push(body?.text ?? ""),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
appLogCalls,
|
|
22
|
+
promptAppends,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const make$ = () => {
|
|
27
|
+
const calls = [];
|
|
28
|
+
const shell = (opts) => {
|
|
29
|
+
return (strings, ...values) => {
|
|
30
|
+
const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
|
|
31
|
+
calls.push({ cmd, env: opts?.env });
|
|
32
|
+
return { stdout: "" };
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
shell.calls = calls;
|
|
36
|
+
return shell;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it("returns event handler and chat.message hook", async () => {
|
|
40
|
+
const { client } = makeClient();
|
|
41
|
+
const plugin = await SagePlugin({
|
|
42
|
+
client,
|
|
43
|
+
$: make$(),
|
|
44
|
+
directory: "/tmp",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(typeof plugin.event).toBe("function");
|
|
48
|
+
expect(typeof plugin["chat.message"]).toBe("function");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("chat.message hook captures prompt with session/model env vars", async () => {
|
|
52
|
+
const { client } = makeClient();
|
|
53
|
+
const $mock = make$();
|
|
54
|
+
const plugin = await SagePlugin({ client, $: $mock, directory: "/tmp" });
|
|
55
|
+
|
|
56
|
+
await plugin["chat.message"](
|
|
57
|
+
{
|
|
58
|
+
sessionID: "sess-abc",
|
|
59
|
+
model: { providerID: "anthropic", modelID: "claude-3" },
|
|
60
|
+
},
|
|
61
|
+
{ message: {}, parts: [{ type: "text", text: "hello world" }] },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// In dry-run mode no command is executed, but state should be set
|
|
65
|
+
// No error means it worked
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("spawns capture hooks with prompt/response env vars", async () => {
|
|
69
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "";
|
|
70
|
+
|
|
71
|
+
const { client } = makeClient();
|
|
72
|
+
const $mock = make$();
|
|
73
|
+
const plugin = await SagePlugin({ client, $: $mock, directory: "/tmp" });
|
|
74
|
+
|
|
75
|
+
await plugin["chat.message"](
|
|
76
|
+
{ sessionID: "s1", model: { providerID: "openai", modelID: "gpt-4" } },
|
|
77
|
+
{ parts: [{ type: "text", text: "hello world" }] },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await plugin.event({
|
|
81
|
+
event: {
|
|
82
|
+
type: "message.part.updated",
|
|
83
|
+
properties: { part: { type: "text", text: "hi there" } },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await plugin.event({
|
|
88
|
+
event: {
|
|
89
|
+
type: "message.updated",
|
|
90
|
+
properties: {
|
|
91
|
+
info: {
|
|
92
|
+
role: "assistant",
|
|
93
|
+
sessionID: "s1",
|
|
94
|
+
modelID: "gpt-4",
|
|
95
|
+
tokens: { input: 10, output: 20 },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const promptCall = $mock.calls.find(
|
|
102
|
+
(c) => c.cmd.includes("capture") && c.cmd.includes("hook") && c.cmd.includes("prompt"),
|
|
103
|
+
);
|
|
104
|
+
expect(promptCall).toBeDefined();
|
|
105
|
+
expect(promptCall.env.SAGE_SOURCE).toBe("opencode");
|
|
106
|
+
expect(promptCall.env.PROMPT).toBe("hello world");
|
|
107
|
+
expect(promptCall.env.SAGE_SESSION_ID).toBe("s1");
|
|
108
|
+
expect(promptCall.env.SAGE_MODEL).toBe("gpt-4");
|
|
109
|
+
|
|
110
|
+
const responseCall = $mock.calls.find(
|
|
111
|
+
(c) => c.cmd.includes("capture") && c.cmd.includes("hook") && c.cmd.includes("response"),
|
|
112
|
+
);
|
|
113
|
+
expect(responseCall).toBeDefined();
|
|
114
|
+
expect(responseCall.env.SAGE_SOURCE).toBe("opencode");
|
|
115
|
+
expect(responseCall.env.SAGE_RESPONSE).toBe("hi there");
|
|
116
|
+
expect(responseCall.env.TOKENS_INPUT).toBe("10");
|
|
117
|
+
expect(responseCall.env.TOKENS_OUTPUT).toBe("20");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("chat.message hook ignores empty parts", async () => {
|
|
121
|
+
const { client } = makeClient();
|
|
122
|
+
const plugin = await SagePlugin({
|
|
123
|
+
client,
|
|
124
|
+
$: make$(),
|
|
125
|
+
directory: "/tmp",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Should not throw or set promptCaptured
|
|
129
|
+
await plugin["chat.message"]({ sessionID: "s1" }, { parts: [{ type: "text", text: " " }] });
|
|
130
|
+
|
|
131
|
+
// Subsequent assistant message.updated should be ignored (no prompt captured)
|
|
132
|
+
await plugin.event({
|
|
133
|
+
event: {
|
|
134
|
+
type: "message.updated",
|
|
135
|
+
properties: { info: { role: "assistant", modelID: "x", tokens: {} } },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("message.part.updated accumulates assistant text parts", async () => {
|
|
141
|
+
const { client } = makeClient();
|
|
142
|
+
const plugin = await SagePlugin({
|
|
143
|
+
client,
|
|
144
|
+
$: make$(),
|
|
145
|
+
directory: "/tmp",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// First capture a prompt via chat.message hook
|
|
149
|
+
await plugin["chat.message"](
|
|
150
|
+
{ sessionID: "s1", model: { modelID: "claude-3" } },
|
|
151
|
+
{ parts: [{ type: "text", text: "explain rust" }] },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Simulate streaming assistant parts
|
|
155
|
+
await plugin.event({
|
|
156
|
+
event: {
|
|
157
|
+
type: "message.part.updated",
|
|
158
|
+
properties: {
|
|
159
|
+
part: {
|
|
160
|
+
type: "text",
|
|
161
|
+
text: "Rust is ",
|
|
162
|
+
sessionID: "s1",
|
|
163
|
+
messageID: "m1",
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
await plugin.event({
|
|
169
|
+
event: {
|
|
170
|
+
type: "message.part.updated",
|
|
171
|
+
properties: {
|
|
172
|
+
part: {
|
|
173
|
+
type: "text",
|
|
174
|
+
text: "a systems language.",
|
|
175
|
+
sessionID: "s1",
|
|
176
|
+
messageID: "m1",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Finalize with message.updated
|
|
183
|
+
await plugin.event({
|
|
184
|
+
event: {
|
|
185
|
+
type: "message.updated",
|
|
186
|
+
properties: {
|
|
187
|
+
info: {
|
|
188
|
+
role: "assistant",
|
|
189
|
+
sessionID: "s1",
|
|
190
|
+
modelID: "claude-3",
|
|
191
|
+
tokens: { input: 10, output: 20 },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// No error means parts were accumulated and flushed correctly
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("message.updated ignores non-assistant roles", async () => {
|
|
201
|
+
const { client } = makeClient();
|
|
202
|
+
const plugin = await SagePlugin({
|
|
203
|
+
client,
|
|
204
|
+
$: make$(),
|
|
205
|
+
directory: "/tmp",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await plugin["chat.message"]({ sessionID: "s1" }, { parts: [{ type: "text", text: "hi" }] });
|
|
209
|
+
|
|
210
|
+
// user role message.updated should not flush
|
|
211
|
+
await plugin.event({
|
|
212
|
+
event: {
|
|
213
|
+
type: "message.updated",
|
|
214
|
+
properties: { info: { role: "user", sessionID: "s1" } },
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// promptCaptured should still be true — assistant parts can still arrive
|
|
219
|
+
// Verify by sending an actual assistant completion
|
|
220
|
+
await plugin.event({
|
|
221
|
+
event: {
|
|
222
|
+
type: "message.part.updated",
|
|
223
|
+
properties: { part: { type: "text", text: "response" } },
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
await plugin.event({
|
|
227
|
+
event: {
|
|
228
|
+
type: "message.updated",
|
|
229
|
+
properties: {
|
|
230
|
+
info: { role: "assistant", tokens: { input: 1, output: 2 } },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("session.created resets state and tracks session ID", async () => {
|
|
237
|
+
const { client, appLogCalls } = makeClient();
|
|
238
|
+
const plugin = await SagePlugin({
|
|
239
|
+
client,
|
|
240
|
+
$: make$(),
|
|
241
|
+
directory: "/tmp",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Capture a prompt first
|
|
245
|
+
await plugin["chat.message"](
|
|
246
|
+
{ sessionID: "old-session" },
|
|
247
|
+
{ parts: [{ type: "text", text: "hello" }] },
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// New session resets everything
|
|
251
|
+
await plugin.event({
|
|
252
|
+
event: {
|
|
253
|
+
type: "session.created",
|
|
254
|
+
properties: {
|
|
255
|
+
info: {
|
|
256
|
+
id: "new-session-123",
|
|
257
|
+
parentID: null,
|
|
258
|
+
directory: "/project",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const sessionLog = appLogCalls.find((c) => c.message === "session created");
|
|
265
|
+
expect(sessionLog).toBeDefined();
|
|
266
|
+
expect(sessionLog.extra.sessionId).toBe("new-session-123");
|
|
267
|
+
expect(sessionLog.extra.isSubagent).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("session.created detects subagent via parentID", async () => {
|
|
271
|
+
const { client, appLogCalls } = makeClient();
|
|
272
|
+
const plugin = await SagePlugin({
|
|
273
|
+
client,
|
|
274
|
+
$: make$(),
|
|
275
|
+
directory: "/tmp",
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await plugin.event({
|
|
279
|
+
event: {
|
|
280
|
+
type: "session.created",
|
|
281
|
+
properties: { info: { id: "child-1", parentID: "parent-1" } },
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const sessionLog = appLogCalls.find((c) => c.message === "session created");
|
|
286
|
+
expect(sessionLog.extra.isSubagent).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("multiple prompt-response cycles work correctly", async () => {
|
|
290
|
+
const { client } = makeClient();
|
|
291
|
+
const plugin = await SagePlugin({
|
|
292
|
+
client,
|
|
293
|
+
$: make$(),
|
|
294
|
+
directory: "/tmp",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Cycle 1
|
|
298
|
+
await plugin["chat.message"](
|
|
299
|
+
{ sessionID: "s1", model: { modelID: "claude-3" } },
|
|
300
|
+
{ parts: [{ type: "text", text: "first question" }] },
|
|
301
|
+
);
|
|
302
|
+
await plugin.event({
|
|
303
|
+
event: {
|
|
304
|
+
type: "message.part.updated",
|
|
305
|
+
properties: { part: { type: "text", text: "first answer" } },
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
await plugin.event({
|
|
309
|
+
event: {
|
|
310
|
+
type: "message.updated",
|
|
311
|
+
properties: {
|
|
312
|
+
info: { role: "assistant", tokens: { input: 5, output: 10 } },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Cycle 2
|
|
318
|
+
await plugin["chat.message"](
|
|
319
|
+
{ sessionID: "s1", model: { modelID: "claude-3" } },
|
|
320
|
+
{ parts: [{ type: "text", text: "second question" }] },
|
|
321
|
+
);
|
|
322
|
+
await plugin.event({
|
|
323
|
+
event: {
|
|
324
|
+
type: "message.part.updated",
|
|
325
|
+
properties: { part: { type: "text", text: "second answer" } },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
await plugin.event({
|
|
329
|
+
event: {
|
|
330
|
+
type: "message.updated",
|
|
331
|
+
properties: {
|
|
332
|
+
info: { role: "assistant", tokens: { input: 8, output: 15 } },
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// No errors means state properly resets between cycles
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("handles missing/null properties gracefully", async () => {
|
|
341
|
+
const { client } = makeClient();
|
|
342
|
+
const plugin = await SagePlugin({
|
|
343
|
+
client,
|
|
344
|
+
$: make$(),
|
|
345
|
+
directory: "/tmp",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// chat.message with null parts
|
|
349
|
+
await plugin["chat.message"](null, null);
|
|
350
|
+
await plugin["chat.message"]({}, { parts: null });
|
|
351
|
+
await plugin["chat.message"]({}, { parts: [] });
|
|
352
|
+
|
|
353
|
+
// Events with missing properties
|
|
354
|
+
await plugin.event({
|
|
355
|
+
event: { type: "message.part.updated", properties: {} },
|
|
356
|
+
});
|
|
357
|
+
await plugin.event({ event: { type: "message.updated", properties: {} } });
|
|
358
|
+
await plugin.event({ event: { type: "session.created", properties: {} } });
|
|
359
|
+
await plugin.event({ event: { type: "unknown.event", properties: {} } });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("schedules suggest on tui.prompt.append", async () => {
|
|
363
|
+
const { client } = makeClient();
|
|
364
|
+
const plugin = await SagePlugin({
|
|
365
|
+
client,
|
|
366
|
+
$: make$(),
|
|
367
|
+
directory: "/tmp",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await plugin.event({
|
|
371
|
+
event: {
|
|
372
|
+
type: "tui.prompt.append",
|
|
373
|
+
properties: { text: "build an MCP server" },
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Suggest is debounced, so no immediate effect to assert
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("RLM feedback calls 'suggest feedback' (not 'prompts append-feedback')", async () => {
|
|
381
|
+
// Disable dry-run so exec sage actually invokes $
|
|
382
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "";
|
|
383
|
+
process.env.SAGE_RLM_FEEDBACK = "1";
|
|
384
|
+
|
|
385
|
+
const { client } = makeClient();
|
|
386
|
+
const $mock = make$();
|
|
387
|
+
const plugin = await SagePlugin({ client, $: $mock, directory: "/tmp" });
|
|
388
|
+
|
|
389
|
+
// 1. Capture a prompt
|
|
390
|
+
await plugin["chat.message"](
|
|
391
|
+
{ sessionID: "s1", model: { modelID: "claude-3" } },
|
|
392
|
+
{ parts: [{ type: "text", text: "explain rust" }] },
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// 2. Simulate a suggestion being injected (tui.prompt.append triggers suggest)
|
|
396
|
+
// We need to trigger the internal suggest flow which sets lastSuggestionPromptKey.
|
|
397
|
+
// The simplest path: simulate the full prompt→suggest→response→feedback cycle.
|
|
398
|
+
// Since suggest is debounced and async, instead we directly drive message.updated
|
|
399
|
+
// which triggers the RLM feedback path when a suggestion was correlated.
|
|
400
|
+
|
|
401
|
+
// 3. Simulate assistant response with tool-use of suggest command
|
|
402
|
+
await plugin.event({
|
|
403
|
+
event: {
|
|
404
|
+
type: "message.part.updated",
|
|
405
|
+
properties: {
|
|
406
|
+
part: { type: "text", text: "Rust is a systems language." },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
await plugin.event({
|
|
411
|
+
event: {
|
|
412
|
+
type: "message.updated",
|
|
413
|
+
properties: {
|
|
414
|
+
info: { role: "assistant", tokens: { input: 10, output: 20 } },
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Check that any calls to $ containing "feedback" use "suggest feedback", not "prompts append-feedback"
|
|
420
|
+
const feedbackCalls = $mock.calls.filter((c) => c.cmd.includes("feedback"));
|
|
421
|
+
for (const call of feedbackCalls) {
|
|
422
|
+
expect(call.cmd).toContain("suggest");
|
|
423
|
+
expect(call.cmd).not.toContain("append-feedback");
|
|
424
|
+
expect(call.cmd).not.toContain("prompts");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Restore dry-run for other tests
|
|
428
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "1";
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("non-text parts in message.part.updated are ignored", async () => {
|
|
432
|
+
const { client } = makeClient();
|
|
433
|
+
const plugin = await SagePlugin({
|
|
434
|
+
client,
|
|
435
|
+
$: make$(),
|
|
436
|
+
directory: "/tmp",
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await plugin["chat.message"](
|
|
440
|
+
{ sessionID: "s1" },
|
|
441
|
+
{ parts: [{ type: "text", text: "question" }] },
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Tool-use part should be ignored
|
|
445
|
+
await plugin.event({
|
|
446
|
+
event: {
|
|
447
|
+
type: "message.part.updated",
|
|
448
|
+
properties: { part: { type: "tool-use", name: "bash", input: {} } },
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Only text parts should be accumulated — tool-use ignored
|
|
453
|
+
await plugin.event({
|
|
454
|
+
event: {
|
|
455
|
+
type: "message.part.updated",
|
|
456
|
+
properties: { part: { type: "text", text: "actual response" } },
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await plugin.event({
|
|
461
|
+
event: {
|
|
462
|
+
type: "message.updated",
|
|
463
|
+
properties: {
|
|
464
|
+
info: { role: "assistant", tokens: { input: 1, output: 1 } },
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
});
|
|
427
469
|
});
|