@sage-protocol/sage-plugin 0.1.4 → 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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * E2E Test: RLM Capture & Suggestion Loop via MCP
3
+ *
4
+ * Validates the full cycle:
5
+ * 1. Start daemon + MCP server (isolated HOME)
6
+ * 2. Baseline rlm_stats (zero state)
7
+ * 3. Inject captures via CLI
8
+ * 4. Run rlm_analyze_captures
9
+ * 5. Query rlm_list_patterns
10
+ * 6. Verify rlm_stats reflects the analysis
11
+ */
12
+
13
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
14
+ import {
15
+ callTool,
16
+ createIsolatedHome,
17
+ injectCapture,
18
+ killProc,
19
+ resolveSageBin,
20
+ spawnSageMcp,
21
+ startDaemon,
22
+ } from "./test-utils.js";
23
+
24
+ const sageBin = resolveSageBin();
25
+ const TIMEOUT = 60_000;
26
+
27
+ describe("RLM E2E: capture -> analyze -> patterns -> stats", () => {
28
+ let daemonProc;
29
+ let mcpProc;
30
+ let client;
31
+ let tmpHome;
32
+
33
+ beforeAll(async () => {
34
+ tmpHome = createIsolatedHome();
35
+
36
+ // Start daemon first (provides RLM service via IPC)
37
+ daemonProc = await startDaemon(sageBin, tmpHome);
38
+
39
+ // Then start MCP server (routes RLM tool calls to daemon)
40
+ const mcp = await spawnSageMcp(sageBin, tmpHome);
41
+ mcpProc = mcp.proc;
42
+ client = mcp.client;
43
+ }, TIMEOUT);
44
+
45
+ afterAll(() => {
46
+ if (mcpProc) killProc(mcpProc);
47
+ if (daemonProc) killProc(daemonProc);
48
+ });
49
+
50
+ it(
51
+ "baseline rlm_stats returns zero state",
52
+ async () => {
53
+ const result = await callTool(client, "rlm_stats");
54
+ if (result.isError) {
55
+ console.error("rlm_stats error:", result.text);
56
+ }
57
+ expect(result.isError).toBe(false);
58
+ expect(result.json).toBeTruthy();
59
+ expect(result.json.total_analyses).toBe(0);
60
+ expect(result.json.patterns_discovered).toBe(0);
61
+ },
62
+ TIMEOUT,
63
+ );
64
+
65
+ it(
66
+ "inject captures via CLI without crashing",
67
+ async () => {
68
+ const prompts = [
69
+ {
70
+ prompt: "How do I optimize database queries in PostgreSQL?",
71
+ response: "Use EXPLAIN ANALYZE, add indexes, avoid SELECT *, use connection pooling.",
72
+ },
73
+ {
74
+ prompt: "What are best practices for REST API design?",
75
+ response: "Use proper HTTP methods, version your API, paginate responses, use HATEOAS.",
76
+ },
77
+ {
78
+ prompt: "How to handle errors in async Rust code?",
79
+ response:
80
+ "Use Result<T, E>, the ? operator, anyhow for applications, thiserror for libraries.",
81
+ },
82
+ {
83
+ prompt: "Explain React useEffect cleanup functions",
84
+ response:
85
+ "Return a cleanup function from useEffect to cancel subscriptions, timers, or listeners.",
86
+ },
87
+ {
88
+ prompt: "How to set up CI/CD with GitHub Actions?",
89
+ response:
90
+ "Create .github/workflows/*.yml, define jobs with steps, use caching for dependencies.",
91
+ },
92
+ ];
93
+
94
+ for (const { prompt, response } of prompts) {
95
+ const result = await injectCapture(sageBin, tmpHome, {
96
+ prompt,
97
+ response,
98
+ });
99
+ expect(result.promptExit).toBeDefined();
100
+ expect(result.responseExit).toBeDefined();
101
+ }
102
+ },
103
+ TIMEOUT,
104
+ );
105
+
106
+ it(
107
+ "rlm_analyze_captures returns analysis result",
108
+ async () => {
109
+ const { text, isError, json } = await callTool(client, "rlm_analyze_captures", {
110
+ goal: "optimize developer workflow",
111
+ });
112
+ expect(isError).toBe(false);
113
+ expect(text.length).toBeGreaterThan(0);
114
+ // Should have structured response
115
+ if (json) {
116
+ expect(json.model_used).toBeDefined();
117
+ expect(json.execution_time_ms).toBeDefined();
118
+ }
119
+ },
120
+ TIMEOUT,
121
+ );
122
+
123
+ it(
124
+ "rlm_list_patterns returns patterns array",
125
+ async () => {
126
+ const { isError, json } = await callTool(client, "rlm_list_patterns", {});
127
+ expect(isError).toBe(false);
128
+ if (json) {
129
+ expect(Array.isArray(json.patterns)).toBe(true);
130
+ expect(typeof json.count).toBe("number");
131
+ }
132
+ },
133
+ TIMEOUT,
134
+ );
135
+
136
+ it(
137
+ "rlm_stats after analysis reflects activity",
138
+ async () => {
139
+ const { isError, json } = await callTool(client, "rlm_stats");
140
+ expect(isError).toBe(false);
141
+ expect(json).toBeTruthy();
142
+ // After running analyze, total_analyses should have incremented
143
+ expect(typeof json.total_analyses).toBe("number");
144
+ expect(typeof json.patterns_discovered).toBe("number");
145
+ expect(typeof json.unique_sessions).toBe("number");
146
+ },
147
+ TIMEOUT,
148
+ );
149
+ });
package/test-utils.js ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Shared test utilities for sage-plugin E2E tests.
3
+ *
4
+ * Provides helpers for spawning an isolated sage daemon + MCP process,
5
+ * communicating via JSON-RPC, and injecting captures.
6
+ */
7
+
8
+ import { mkdtempSync, existsSync } from "node:fs";
9
+
10
+ /** Resolve the sage binary path. */
11
+ export function resolveSageBin() {
12
+ return process.env.SAGE_BIN || new URL("../target/debug/sage", import.meta.url).pathname;
13
+ }
14
+
15
+ /**
16
+ * Create an MCP JSON-RPC client over a Bun subprocess stdio.
17
+ */
18
+ export function createMcpClient(proc) {
19
+ const decoder = new TextDecoder();
20
+ const encoder = new TextEncoder();
21
+ const pending = new Map();
22
+
23
+ let closed = false;
24
+ let closeErr;
25
+ let buf = "";
26
+
27
+ (async () => {
28
+ try {
29
+ for await (const chunk of proc.stdout) {
30
+ buf += decoder.decode(chunk);
31
+ const lines = buf.split("\n");
32
+ buf = lines.pop() ?? "";
33
+
34
+ for (const line of lines) {
35
+ if (!line.trim()) continue;
36
+
37
+ let msg;
38
+ try {
39
+ msg = JSON.parse(line);
40
+ } catch {
41
+ continue;
42
+ }
43
+
44
+ if (msg && msg.id != null) {
45
+ const key = String(msg.id);
46
+ const waiter = pending.get(key);
47
+ if (waiter) {
48
+ pending.delete(key);
49
+ if (msg.error) waiter.reject(new Error(msg.error.message || "MCP error"));
50
+ else waiter.resolve(msg.result);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ closed = true;
56
+ } catch (e) {
57
+ closed = true;
58
+ closeErr = e;
59
+ } finally {
60
+ const stderr = await new Response(proc.stderr).text().catch(() => "");
61
+ for (const { reject } of pending.values()) {
62
+ reject(
63
+ new Error(
64
+ `MCP process ended before response. stderr:\n${stderr || "<empty>"}${closeErr ? `\nstdout reader error: ${closeErr}` : ""}`,
65
+ ),
66
+ );
67
+ }
68
+ pending.clear();
69
+ }
70
+ })();
71
+
72
+ return {
73
+ request(method, params) {
74
+ if (closed) {
75
+ throw new Error("MCP client is closed");
76
+ }
77
+ const id = `${Date.now()}-${Math.random()}`;
78
+ proc.stdin.write(
79
+ encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`),
80
+ );
81
+ return new Promise((resolve, reject) => {
82
+ pending.set(String(id), { resolve, reject });
83
+ });
84
+ },
85
+ notify(method, params) {
86
+ if (closed) return;
87
+ proc.stdin.write(encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`));
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Create a temporary isolated HOME directory for test isolation.
94
+ *
95
+ * Uses a short path under /tmp to avoid exceeding Unix socket path limits
96
+ * (SUN_LEN is typically 104 bytes on macOS, 108 on Linux).
97
+ * The sage daemon socket goes under $HOME/.sage/run/ or XDG_RUNTIME_DIR.
98
+ */
99
+ export function createIsolatedHome() {
100
+ // Short prefix to keep socket paths under SUN_LEN
101
+ return mkdtempSync("/tmp/se-");
102
+ }
103
+
104
+ /**
105
+ * Build an env object that isolates sage state to a temp directory.
106
+ */
107
+ export function isolatedEnv(tmpHome) {
108
+ return {
109
+ ...process.env,
110
+ HOME: tmpHome,
111
+ XDG_CONFIG_HOME: `${tmpHome}/c`,
112
+ XDG_DATA_HOME: `${tmpHome}/d`,
113
+ XDG_RUNTIME_DIR: `${tmpHome}/r`,
114
+ SAGE_HOME: `${tmpHome}/.sage`,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Start the sage daemon in foreground mode.
120
+ * Returns the daemon process. The daemon is ready when IPC socket appears.
121
+ */
122
+ export async function startDaemon(sageBin, tmpHome) {
123
+ const env = isolatedEnv(tmpHome);
124
+
125
+ const daemonProc = Bun.spawn([sageBin, "daemon", "start", "-f"], {
126
+ stdin: "ignore",
127
+ stdout: "pipe",
128
+ stderr: "pipe",
129
+ env,
130
+ });
131
+
132
+ // Wait for daemon to be ready (socket file appears)
133
+ // The daemon creates a socket at ~/.sage/run/sage.sock or under XDG_RUNTIME_DIR
134
+ const maxWait = 15_000;
135
+ const start = Date.now();
136
+ while (Date.now() - start < maxWait) {
137
+ // Check if daemon is still alive
138
+ if (daemonProc.exitCode !== null) {
139
+ const stderr = await new Response(daemonProc.stderr).text().catch(() => "");
140
+ throw new Error(`Daemon exited early (code ${daemonProc.exitCode}): ${stderr}`);
141
+ }
142
+
143
+ // Try to detect readiness via socket file
144
+ const candidates = [
145
+ `${tmpHome}/r/sage/sage.sock`,
146
+ `${tmpHome}/r/sage.sock`,
147
+ `${tmpHome}/.sage/run/sage.sock`,
148
+ `${tmpHome}/.sage/sage.sock`,
149
+ ];
150
+ if (candidates.some((p) => existsSync(p))) {
151
+ break;
152
+ }
153
+
154
+ await new Promise((r) => setTimeout(r, 200));
155
+ }
156
+
157
+ // Give daemon a moment to finish initializing after socket appears
158
+ await new Promise((r) => setTimeout(r, 500));
159
+
160
+ return daemonProc;
161
+ }
162
+
163
+ /**
164
+ * Spawn `sage mcp start` with isolated HOME and return { proc, client }.
165
+ * Performs MCP handshake (initialize + initialized notification).
166
+ *
167
+ * NOTE: A daemon must already be running for RLM tools to work.
168
+ */
169
+ export async function spawnSageMcp(sageBin, tmpHome) {
170
+ const env = isolatedEnv(tmpHome);
171
+
172
+ const proc = Bun.spawn([sageBin, "mcp", "start"], {
173
+ stdin: "pipe",
174
+ stdout: "pipe",
175
+ stderr: "pipe",
176
+ env,
177
+ });
178
+
179
+ const client = createMcpClient(proc);
180
+
181
+ // MCP handshake
182
+ const init = await client.request("initialize", {
183
+ protocolVersion: "2024-11-05",
184
+ capabilities: {},
185
+ clientInfo: { name: "sage-e2e-test", version: "0.0.0" },
186
+ });
187
+ client.notify("notifications/initialized", {});
188
+
189
+ return { proc, client, env, init };
190
+ }
191
+
192
+ /**
193
+ * Call an MCP tool by name and return the result.
194
+ *
195
+ * Returns { raw, text, json, isError } where:
196
+ * - raw: the full MCP result object
197
+ * - text: concatenated text content
198
+ * - json: parsed JSON if text is valid JSON, otherwise null
199
+ * - isError: whether the MCP response flagged an error
200
+ */
201
+ export async function callTool(client, name, args = {}) {
202
+ const result = await client.request("tools/call", {
203
+ name,
204
+ arguments: args,
205
+ });
206
+
207
+ const text =
208
+ result?.content
209
+ ?.filter((c) => c.type === "text")
210
+ .map((c) => c.text)
211
+ .join("\n") ?? "";
212
+
213
+ let json = null;
214
+ try {
215
+ json = JSON.parse(text);
216
+ } catch {
217
+ // not JSON
218
+ }
219
+
220
+ return { raw: result, text, json, isError: result?.isError ?? false };
221
+ }
222
+
223
+ /**
224
+ * Inject a capture (prompt + response) via the sage CLI.
225
+ * Uses `sage capture hook prompt` and `sage capture hook response` subcommands.
226
+ */
227
+ export async function injectCapture(
228
+ sageBin,
229
+ tmpHome,
230
+ {
231
+ prompt = "test prompt",
232
+ response = "test response",
233
+ sessionId = "e2e-session",
234
+ model = "test-model",
235
+ source = "e2e-test",
236
+ tokensInput = "100",
237
+ tokensOutput = "50",
238
+ } = {},
239
+ ) {
240
+ const env = isolatedEnv(tmpHome);
241
+
242
+ // Phase 1: capture prompt
243
+ const promptProc = Bun.spawn([sageBin, "capture", "hook", "prompt"], {
244
+ env: {
245
+ ...env,
246
+ SAGE_SOURCE: source,
247
+ PROMPT: prompt,
248
+ SAGE_SESSION_ID: sessionId,
249
+ SAGE_MODEL: model,
250
+ },
251
+ stdout: "pipe",
252
+ stderr: "pipe",
253
+ });
254
+ await promptProc.exited;
255
+
256
+ // Phase 2: capture response
257
+ const responseProc = Bun.spawn([sageBin, "capture", "hook", "response"], {
258
+ env: {
259
+ ...env,
260
+ SAGE_SOURCE: source,
261
+ LAST_RESPONSE: response,
262
+ TOKENS_INPUT: tokensInput,
263
+ TOKENS_OUTPUT: tokensOutput,
264
+ SAGE_SESSION_ID: sessionId,
265
+ SAGE_MODEL: model,
266
+ },
267
+ stdout: "pipe",
268
+ stderr: "pipe",
269
+ });
270
+ await responseProc.exited;
271
+
272
+ return {
273
+ promptExit: promptProc.exitCode,
274
+ responseExit: responseProc.exitCode,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Kill a process safely.
280
+ */
281
+ export function killProc(proc) {
282
+ try {
283
+ proc.kill("SIGTERM");
284
+ } catch {
285
+ // already dead
286
+ }
287
+ }