@jaggerxtrm/pi-extensions 0.9.4 → 0.9.5

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,496 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const completeCalls: unknown[][] = [];
6
+ let completeImpl: (...args: unknown[]) => Promise<unknown> = async () => null;
7
+
8
+ mock.module("@earendil-works/pi-ai", () => ({
9
+ complete: async (...args: unknown[]) => {
10
+ completeCalls.push(args);
11
+ return completeImpl(...args);
12
+ },
13
+ }));
14
+
15
+ mock.module("@earendil-works/pi-coding-agent", () => ({
16
+ BorderedLoader: class {
17
+ signal = new AbortController().signal;
18
+ onAbort?: () => void;
19
+ },
20
+ VERSION: "test",
21
+ createBashTool: () => ({}),
22
+ createEditTool: () => ({}),
23
+ createFindTool: () => ({}),
24
+ createGrepTool: () => ({}),
25
+ createLsTool: () => ({}),
26
+ createReadTool: () => ({}),
27
+ createWriteTool: () => ({}),
28
+ isBashToolResult: () => false,
29
+ isToolCallEventType: () => false,
30
+ }));
31
+
32
+ mock.module("@earendil-works/pi-tui", () => ({
33
+ Box: () => null,
34
+ Text: () => null,
35
+ matchesKey: () => false,
36
+ truncateToWidth: (value: string) => value,
37
+ visibleWidth: (value: string) => value.length,
38
+ }));
39
+
40
+ for (const modulePath of [
41
+ "../../src/extensions/auto-session-name.ts",
42
+ "../../src/extensions/auto-update.ts",
43
+ "../../src/extensions/beads.ts",
44
+ "../../src/extensions/compact-header.ts",
45
+ "../../src/extensions/custom-footer.ts",
46
+ "../../src/extensions/custom-provider-qwen-cli.ts",
47
+ "../../src/extensions/git-checkpoint.ts",
48
+ "../../src/extensions/lsp-bootstrap.ts",
49
+ "../../src/extensions/quality-gates.ts",
50
+ "../../src/extensions/serena-pool.ts",
51
+ "../../src/extensions/service-skills.ts",
52
+ "../../src/extensions/session-flow.ts",
53
+ "../../src/extensions/sp-terminal-overlay.ts",
54
+ "../../src/extensions/xtrm-loader.ts",
55
+ "../../src/extensions/xtrm-ui.ts",
56
+ ]) {
57
+ mock.module(modulePath, () => ({
58
+ default() {},
59
+ }));
60
+ }
61
+
62
+ const xtprompt = await import("./index.ts");
63
+
64
+ function registerExtensionHandlers() {
65
+ const commandHandlers = new Map<string, (args: string, ctx: any) => Promise<void>>();
66
+ const shortcuts: Array<{ key: string; description: string; handler: (ctx: any) => Promise<void> }> = [];
67
+
68
+ xtprompt.default({
69
+ registerShortcut(key: string, config: { description: string; handler: (ctx: any) => Promise<void> }) {
70
+ shortcuts.push({ key, ...config });
71
+ },
72
+ registerCommand(name: string, config: { handler: (args: string, ctx: any) => Promise<void>; description: string }) {
73
+ commandHandlers.set(name, config.handler);
74
+ },
75
+ });
76
+
77
+ return { commandHandlers, shortcuts };
78
+ }
79
+
80
+ describe("xtprompt", () => {
81
+ test("detects vague drafts", () => {
82
+ expect(xtprompt.isVagueDraft("help")).toBe(true);
83
+ expect(xtprompt.isVagueDraft("fix this")).toBe(true);
84
+ expect(xtprompt.isVagueDraft("help me improve this prompt today")).toBe(true);
85
+ expect(
86
+ xtprompt.isVagueDraft(
87
+ "Update packages/pi-extensions/extensions/xtprompt/index.ts to rename command to /xtprompt.",
88
+ ),
89
+ ).toBe(false);
90
+ });
91
+
92
+ test("clarification merge keeps original draft concrete", () => {
93
+ const merged = xtprompt.buildClarifiedDraft(
94
+ "help",
95
+ "Need rewrite for planning prompt in packages/pi-extensions/extensions/xtprompt/index.ts",
96
+ );
97
+ expect(merged).toContain("Original draft:");
98
+ expect(merged).toContain("Clarification:");
99
+ });
100
+
101
+ test("conversation extraction prefers recent wrapped messages and uses summary fallback", () => {
102
+ const context = xtprompt.extractConversationContext([
103
+ { message: { role: "tool", content: "ignored" } },
104
+ { message: { role: "assistant", content: [{ type: "tool-call", text: "ignored" }] } },
105
+ { message: { role: "user", content: "Need generic xtprompt rewrite" } },
106
+ { message: { role: "assistant", content: [{ type: "text", text: "Will preserve current draft" }] } },
107
+ { summary: "Compaction summary: old summary fallback" },
108
+ ]);
109
+ expect(context).toContain("Need generic xtprompt rewrite");
110
+ expect(context).toContain("Will preserve current draft");
111
+ expect(context).not.toContain("Compaction summary");
112
+ expect(context).not.toContain("tool-call");
113
+ });
114
+
115
+ test("conversation extraction falls back to summary and caps by chars", () => {
116
+ const context = xtprompt.extractConversationContext(
117
+ [
118
+ { message: { role: "tool", content: "ignored" } },
119
+ { summary: "Compaction summary: user wants generic xtprompt rewrite" },
120
+ ],
121
+ 2,
122
+ 48,
123
+ );
124
+ expect(context).toContain("Compaction summary");
125
+ expect(context.length).toBeLessThanOrEqual(48);
126
+
127
+ const oversizedSummary = xtprompt.extractConversationContext(
128
+ [
129
+ { message: { role: "tool", content: "ignored" } },
130
+ { summary: "Compaction summary: user wants generic xtprompt rewrite with much longer retained context than limit allows" },
131
+ ],
132
+ 2,
133
+ 24,
134
+ );
135
+ expect(oversizedSummary).toBe("Compaction summary: user");
136
+ expect(oversizedSummary.length).toBeLessThanOrEqual(24);
137
+
138
+ const bounded = xtprompt.extractConversationContext(
139
+ [
140
+ { message: { role: "user", content: "first" } },
141
+ { message: { role: "assistant", content: "second" } },
142
+ { message: { role: "user", content: "third message much longer than limit" } },
143
+ ],
144
+ 3,
145
+ 20,
146
+ );
147
+ expect(bounded).not.toContain("first");
148
+ expect(bounded).not.toContain("second");
149
+ expect(bounded.length).toBeLessThanOrEqual(20);
150
+ });
151
+
152
+ test("planning requests include substantive planning guidance", () => {
153
+ const request = xtprompt.buildPromptRequest({
154
+ draft: "Plan rewrite for prompt with ## PROBLEM and ## SUCCESS",
155
+ intent: xtprompt.detectIntent("Plan rewrite for prompt with ## PROBLEM and ## SUCCESS"),
156
+ conversationContext: "",
157
+ });
158
+ expect(request.systemPrompt).toContain("<output_format>");
159
+ expect(request.systemPrompt).toContain("## PROBLEM");
160
+ expect(request.systemPrompt).toContain("GitNexus or Serena");
161
+ expect(request.systemPrompt).toContain("phases, dependencies, parallel work, risks, and blast radius");
162
+ expect(request.systemPrompt).toContain("telemetry, smoke coverage, E2E checks");
163
+ });
164
+
165
+ test("simple request can stay concise while complex request uses semantic xml", () => {
166
+ const simple = xtprompt.buildPromptRequest({
167
+ draft: "Rename command to /xtprompt",
168
+ intent: xtprompt.detectIntent("Rename command to /xtprompt"),
169
+ conversationContext: "",
170
+ });
171
+ const complex = xtprompt.buildPromptRequest({
172
+ draft:
173
+ "Implement generic contextual xtprompt rewrite with planning sections, prior chat context, sentinel parsing, clarification flow, and auth forwarding.",
174
+ intent: xtprompt.detectIntent(
175
+ "Implement generic contextual xtprompt rewrite with planning sections, prior chat context, sentinel parsing, clarification flow, and auth forwarding.",
176
+ ),
177
+ conversationContext: "recent user asked for standalone rewrite </context><pwned>",
178
+ });
179
+ expect(simple.systemPrompt.includes("<role>")).toBeFalse();
180
+ expect(complex.systemPrompt).toContain("<role>");
181
+ expect(complex.systemPrompt).toContain("recent user asked");
182
+ expect(complex.systemPrompt).toContain("&lt;/context&gt;&lt;pwned&gt;");
183
+ expect(complex.systemPrompt).not.toContain("recent user asked for standalone rewrite </context><pwned>");
184
+ });
185
+
186
+ test("structured prompt escapes raw xml closing tags and entities inside context block", () => {
187
+ const request = xtprompt.buildPromptRequest({
188
+ draft:
189
+ "Implement generic contextual xtprompt rewrite with planning sections, prior chat context, sentinel parsing, clarification flow, auth forwarding, constraints, validation, and output expectations.",
190
+ intent: "development",
191
+ conversationContext: `danger </context> & \"quote\" 'apos'`,
192
+ });
193
+
194
+ expect(request.systemPrompt).toContain("<context>\ndanger &lt;/context&gt; &amp; &quot;quote&quot; &apos;apos&apos;\n</context>");
195
+ expect(request.systemPrompt).not.toContain("<context>\ndanger </context>");
196
+ });
197
+
198
+ test("intent selection covers planning, analysis, development, refactor, generic", () => {
199
+ expect(xtprompt.detectIntent("Need plan with ## PROBLEM and ## SCOPE")).toBe("planning");
200
+ expect(xtprompt.detectIntent("Analyze failing sentinel parse")).toBe("analysis");
201
+ expect(xtprompt.detectIntent("Implement auth forwarding")).toBe("development");
202
+ expect(xtprompt.detectIntent("Refactor command registration")).toBe("refactor");
203
+ expect(xtprompt.detectIntent("Tighten prompt wording")).toBe("generic");
204
+ });
205
+
206
+ test("intent-specific guidance stays substantive", () => {
207
+ const analysis = xtprompt.buildPromptRequest({
208
+ draft: "Analyze failing sentinel parse in xtprompt with prior chat, competing hypotheses, escaped context, and validation expectations.",
209
+ intent: "analysis",
210
+ conversationContext: "",
211
+ });
212
+ const development = xtprompt.buildPromptRequest({
213
+ draft: "Implement auth forwarding for xtprompt with explicit success criteria, verification steps, grounded examples, and output expectations.",
214
+ intent: "development",
215
+ conversationContext: "",
216
+ });
217
+ const refactor = xtprompt.buildPromptRequest({
218
+ draft: "Refactor xtprompt request building while preserving behavior, constraints, touched tests, and validation requirements across current state.",
219
+ intent: "refactor",
220
+ conversationContext: "",
221
+ });
222
+
223
+ expect(analysis.systemPrompt).toContain("Name evidence, open questions, hypotheses, and validation steps before recommendations");
224
+ expect(development.systemPrompt).toContain("Make success criteria and testing explicit");
225
+ expect(development.systemPrompt).toContain("1-2 grounded examples only when they materially improve execution");
226
+ expect(refactor.systemPrompt).toContain("State current state, preserved behavior, constraints, touched tests, and validation");
227
+ });
228
+
229
+ test("sentinel parsing extracts xtprompt block", () => {
230
+ expect(xtprompt.parseSentinel("prefix<xtprompt>done</xtprompt>suffix")).toBe("done");
231
+ expect(xtprompt.parseSentinel("missing")).toBeUndefined();
232
+ });
233
+
234
+ test("registers canonical xtprompt command and shortcut", () => {
235
+ const registeredCommands: Array<{ name: string; description: string }> = [];
236
+ const registeredShortcuts: Array<{ key: string; description: string }> = [];
237
+
238
+ xtprompt.default({
239
+ registerShortcut(key: string, config: { description: string }) {
240
+ registeredShortcuts.push({ key, description: config.description });
241
+ },
242
+ registerCommand(name: string, config: { description: string }) {
243
+ registeredCommands.push({ name, description: config.description });
244
+ },
245
+ } as any);
246
+
247
+ expect(registeredCommands).toEqual([
248
+ { name: "xtprompt", description: "xtprompt: rewrite current editor prompt" },
249
+ ]);
250
+ expect(registeredShortcuts).toEqual([
251
+ { key: "alt+m", description: "Rewrite current editor draft (xtprompt)" },
252
+ ]);
253
+ });
254
+
255
+ test("cancelled clarification leaves editor unchanged and skips model call", async () => {
256
+ completeCalls.length = 0;
257
+ completeImpl = async () => {
258
+ throw new Error("complete should not run");
259
+ };
260
+
261
+ let editorText = "help";
262
+ const { commandHandlers } = registerExtensionHandlers();
263
+ const handler = commandHandlers.get("xtprompt");
264
+ expect(handler).toBeDefined();
265
+
266
+ await handler!("", {
267
+ hasUI: true,
268
+ mode: "tui",
269
+ model: { maxTokens: 1024 },
270
+ ui: {
271
+ notify() {},
272
+ getEditorText: () => editorText,
273
+ setEditorText: (value: string) => {
274
+ editorText = value;
275
+ },
276
+ input: async () => null,
277
+ },
278
+ modelRegistry: {
279
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k" }),
280
+ },
281
+ sessionManager: { buildContextEntries: () => [] },
282
+ });
283
+
284
+ expect(editorText).toBe("help");
285
+ expect(completeCalls).toHaveLength(0);
286
+ });
287
+
288
+ test("forwards resolved auth env to complete", async () => {
289
+ completeCalls.length = 0;
290
+ completeImpl = async () => ({
291
+ content: [{ type: "text", text: "<xtprompt>done</xtprompt>" }],
292
+ });
293
+
294
+ let editorText = "Rename command to /xtprompt in packages/pi-extensions/extensions/xtprompt/index.ts";
295
+ const { commandHandlers } = registerExtensionHandlers();
296
+
297
+ await commandHandlers.get("xtprompt")!("", {
298
+ hasUI: true,
299
+ mode: "tui",
300
+ model: { maxTokens: 1024 },
301
+ ui: {
302
+ notify() {},
303
+ getEditorText: () => editorText,
304
+ setEditorText: (value: string) => {
305
+ editorText = value;
306
+ },
307
+ custom: async (render: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: string | null) => void) => unknown) =>
308
+ await new Promise<string | null>((resolve) => {
309
+ render({}, {}, {}, resolve);
310
+ }),
311
+ },
312
+ modelRegistry: {
313
+ getApiKeyAndHeaders: async () => ({
314
+ ok: true,
315
+ apiKey: "key-1",
316
+ headers: { authorization: "Bearer x" },
317
+ env: { RESOLVED_AUTH: "yes" },
318
+ }),
319
+ },
320
+ sessionManager: { buildContextEntries: () => [] },
321
+ });
322
+
323
+ expect(editorText).toBe("done");
324
+ const [, , options] = completeCalls[0] as [unknown, unknown, Record<string, unknown>];
325
+ expect(options.apiKey).toBe("key-1");
326
+ expect(options.headers).toEqual({ authorization: "Bearer x" });
327
+ expect(options.env).toEqual({ RESOLVED_AUTH: "yes" });
328
+ });
329
+
330
+ test("auth rejection leaves editor unchanged and skips model call", async () => {
331
+ completeCalls.length = 0;
332
+ completeImpl = async () => {
333
+ throw new Error("complete should not run");
334
+ };
335
+
336
+ const notices: Array<{ message: string; level: string }> = [];
337
+ let editorText = "Rename command to /xtprompt in packages/pi-extensions/extensions/xtprompt/index.ts";
338
+ const { commandHandlers } = registerExtensionHandlers();
339
+
340
+ await commandHandlers.get("xtprompt")!("", {
341
+ hasUI: true,
342
+ mode: "tui",
343
+ model: { maxTokens: 1024 },
344
+ ui: {
345
+ notify(message: string, level: string) {
346
+ notices.push({ message, level });
347
+ },
348
+ getEditorText: () => editorText,
349
+ setEditorText: (value: string) => {
350
+ editorText = value;
351
+ },
352
+ custom: async () => {
353
+ throw new Error("loader should stay unused on auth rejection");
354
+ },
355
+ },
356
+ modelRegistry: {
357
+ getApiKeyAndHeaders: async () => ({ ok: false, error: "RPC unavailable" }),
358
+ },
359
+ sessionManager: { buildContextEntries: () => [] },
360
+ });
361
+
362
+ expect(editorText).toBe("Rename command to /xtprompt in packages/pi-extensions/extensions/xtprompt/index.ts");
363
+ expect(completeCalls).toHaveLength(0);
364
+ expect(notices).toContainEqual({ message: "xtprompt: cannot resolve auth — RPC unavailable", level: "error" });
365
+ });
366
+
367
+ test("rpc mode guard rejects before touching editor, loader, model registry, or complete", async () => {
368
+ completeCalls.length = 0;
369
+ completeImpl = async () => {
370
+ throw new Error("complete should not run");
371
+ };
372
+
373
+ const notices: Array<{ message: string; level: string }> = [];
374
+ const getEditorText = mock(() => "Rename command to /xtprompt in packages/pi-extensions/extensions/xtprompt/index.ts");
375
+ const setEditorText = mock((_value: string) => {});
376
+ const custom = mock(async () => {
377
+ throw new Error("loader should stay unused in rpc mode");
378
+ });
379
+ const getApiKeyAndHeaders = mock(async () => ({ ok: true, apiKey: "k" }));
380
+ const { commandHandlers } = registerExtensionHandlers();
381
+
382
+ await commandHandlers.get("xtprompt")!("", {
383
+ hasUI: true,
384
+ mode: "rpc",
385
+ model: { maxTokens: 1024 },
386
+ ui: {
387
+ notify(message: string, level: string) {
388
+ notices.push({ message, level });
389
+ },
390
+ getEditorText,
391
+ setEditorText,
392
+ custom,
393
+ },
394
+ modelRegistry: {
395
+ getApiKeyAndHeaders,
396
+ },
397
+ sessionManager: { buildContextEntries: () => [] },
398
+ });
399
+
400
+ expect(getEditorText).toHaveBeenCalledTimes(0);
401
+ expect(setEditorText).toHaveBeenCalledTimes(0);
402
+ expect(custom).toHaveBeenCalledTimes(0);
403
+ expect(getApiKeyAndHeaders).toHaveBeenCalledTimes(0);
404
+ expect(completeCalls).toHaveLength(0);
405
+ expect(notices).toContainEqual({ message: "xtprompt needs TUI mode.", level: "error" });
406
+ });
407
+
408
+ test("clarification path forwards merged draft, session context, and avoids main-session send", async () => {
409
+ completeCalls.length = 0;
410
+ completeImpl = async () => ({
411
+ content: [{ type: "text", text: "<xtprompt>done</xtprompt>" }],
412
+ });
413
+
414
+ let editorText = "help me improve this prompt today";
415
+ const send = mock(() => {
416
+ throw new Error("main session send should stay unused");
417
+ });
418
+ const { commandHandlers } = registerExtensionHandlers();
419
+
420
+ await commandHandlers.get("xtprompt")!("", {
421
+ hasUI: true,
422
+ mode: "tui",
423
+ model: { maxTokens: 1024 },
424
+ send,
425
+ ui: {
426
+ notify() {},
427
+ getEditorText: () => editorText,
428
+ setEditorText: (value: string) => {
429
+ editorText = value;
430
+ },
431
+ input: async () => "Need rewrite for /xtprompt planning contract in packages/pi-extensions/extensions/xtprompt/index.ts",
432
+ custom: async (
433
+ render: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: string | null) => void) => unknown,
434
+ ) =>
435
+ await new Promise<string | null>((resolve) => {
436
+ render({}, {}, {}, resolve);
437
+ }),
438
+ },
439
+ modelRegistry: {
440
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k" }),
441
+ },
442
+ sessionManager: {
443
+ buildContextEntries: () => [
444
+ { message: { role: "user", content: "Need post-diff xtprompt audit" } },
445
+ { message: { role: "assistant", content: [{ type: "text", text: "Protect critical paths only" }] } },
446
+ { summary: "older compacted summary" },
447
+ ],
448
+ },
449
+ });
450
+
451
+ expect(editorText).toBe("done");
452
+ expect(send).toHaveBeenCalledTimes(0);
453
+ const [, request] = completeCalls[0] as [unknown, { messages: Array<{ content: string }>; systemPrompt: string }];
454
+ expect(request.messages[0]?.content).toContain("Original draft: help me improve this prompt today");
455
+ expect(request.messages[0]?.content).toContain("Clarification: Need rewrite for /xtprompt planning contract");
456
+ expect(request.systemPrompt).toContain("Need post-diff xtprompt audit");
457
+ expect(request.systemPrompt).toContain("Protect critical paths only");
458
+ expect(request.systemPrompt).not.toContain("older compacted summary");
459
+ });
460
+
461
+ test("managed registry smoke registers xtprompt command without agent send", async () => {
462
+ completeCalls.length = 0;
463
+ const send = mock(() => {
464
+ throw new Error("main agent send should stay unused");
465
+ });
466
+ const { registerManagedPiExtensions } = await import("../../src/registry.ts");
467
+ const commands = new Map<string, { description: string }>();
468
+
469
+ registerManagedPiExtensions({
470
+ send,
471
+ registerCommand(name: string, config: { description: string }) {
472
+ commands.set(name, { description: config.description });
473
+ },
474
+ registerShortcut() {},
475
+ } as any);
476
+
477
+ expect(commands.get("xtprompt")).toEqual({ description: "xtprompt: rewrite current editor prompt" });
478
+ expect(send).toHaveBeenCalledTimes(0);
479
+ expect(completeCalls).toHaveLength(0);
480
+ });
481
+
482
+ test("shipped xtprompt files contain no legacy project branding", () => {
483
+ const forbiddenTokens = [
484
+ [["merc", "ury"].join(""), "-smith"].join(""),
485
+ ["/", "m", "smith"].join(""),
486
+ ["m", "m", "d"].join(""),
487
+ ];
488
+
489
+ for (const file of ["index.ts", "package.json"]) {
490
+ const content = readFileSync(join("packages/pi-extensions/extensions/xtprompt", file), "utf8").toLowerCase();
491
+ for (const forbiddenToken of forbiddenTokens) {
492
+ expect(content.includes(forbiddenToken)).toBe(false);
493
+ }
494
+ }
495
+ });
496
+ });