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