test-pi-extension.mjs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import assert from 'node:assert/strict';
  2. import { readFile } from 'node:fs/promises';
  3. import { existsSync } from 'node:fs';
  4. import { dirname, resolve } from 'node:path';
  5. import { fileURLToPath, pathToFileURL } from 'node:url';
  6. import test from 'node:test';
  7. const __dirname = dirname(fileURLToPath(import.meta.url));
  8. const repoRoot = resolve(__dirname, '../..');
  9. const packageJsonPath = resolve(repoRoot, 'package.json');
  10. const extensionPath = resolve(repoRoot, '.pi/extensions/superpowers.ts');
  11. const piToolsPath = resolve(repoRoot, 'skills/using-superpowers/references/pi-tools.md');
  12. async function readPackageJson() {
  13. return JSON.parse(await readFile(packageJsonPath, 'utf8'));
  14. }
  15. async function loadExtension() {
  16. const handlers = new Map();
  17. const pi = {
  18. on(event, handler) {
  19. if (!handlers.has(event)) handlers.set(event, []);
  20. handlers.get(event).push(handler);
  21. },
  22. };
  23. const mod = await import(pathToFileURL(extensionPath).href + `?cachebust=${Date.now()}-${Math.random()}`);
  24. mod.default(pi);
  25. return { handlers };
  26. }
  27. function firstHandler(handlers, event) {
  28. const eventHandlers = handlers.get(event) ?? [];
  29. assert.equal(eventHandlers.length, 1, `expected one ${event} handler`);
  30. return eventHandlers[0];
  31. }
  32. function textOf(message) {
  33. if (typeof message.content === 'string') return message.content;
  34. return message.content
  35. .filter((part) => part.type === 'text')
  36. .map((part) => part.text)
  37. .join('\n');
  38. }
  39. test('package.json declares a pi package with skills and extension resources', async () => {
  40. const pkg = await readPackageJson();
  41. assert.equal(pkg.name, 'superpowers');
  42. assert.ok(pkg.keywords.includes('pi-package'));
  43. assert.deepEqual(pkg.pi.skills, ['./skills']);
  44. assert.deepEqual(pkg.pi.extensions, ['./.pi/extensions/superpowers.ts']);
  45. });
  46. test('extension registers lifecycle hooks without pre-compaction injection', async () => {
  47. const { handlers } = await loadExtension();
  48. for (const event of ['resources_discover', 'session_start', 'session_compact', 'context', 'agent_end']) {
  49. assert.equal((handlers.get(event) ?? []).length, 1, `missing ${event} handler`);
  50. }
  51. assert.equal((handlers.get('session_before_compact') ?? []).length, 0);
  52. });
  53. test('resources_discover contributes the bundled skills directory', async () => {
  54. const { handlers } = await loadExtension();
  55. const discover = firstHandler(handlers, 'resources_discover');
  56. const result = await discover({ type: 'resources_discover', cwd: repoRoot, reason: 'startup' }, {});
  57. assert.deepEqual(result.skillPaths, [resolve(repoRoot, 'skills')]);
  58. });
  59. test('startup context injects the bootstrap as one user message until agent_end', async () => {
  60. const { handlers } = await loadExtension();
  61. const sessionStart = firstHandler(handlers, 'session_start');
  62. const context = firstHandler(handlers, 'context');
  63. const agentEnd = firstHandler(handlers, 'agent_end');
  64. await sessionStart({ type: 'session_start', reason: 'startup' }, {});
  65. const originalMessages = [
  66. { role: 'user', content: [{ type: 'text', text: 'Let us make a react todo list' }], timestamp: 1 },
  67. ];
  68. const result = await context({ type: 'context', messages: originalMessages }, {});
  69. assert.equal(result.messages.length, 2);
  70. assert.equal(result.messages[0].role, 'user');
  71. assert.match(textOf(result.messages[0]), /You have superpowers/);
  72. assert.match(textOf(result.messages[0]), /Pi tool mapping/);
  73. assert.equal(result.messages[1], originalMessages[0]);
  74. const repeatedProviderRequest = await context({ type: 'context', messages: originalMessages }, {});
  75. assert.equal(repeatedProviderRequest.messages.length, 2);
  76. assert.match(textOf(repeatedProviderRequest.messages[0]), /You have superpowers/);
  77. const alreadyInjected = await context({ type: 'context', messages: result.messages }, {});
  78. assert.equal(alreadyInjected, undefined, 'bootstrap should not duplicate when already present');
  79. await agentEnd({ type: 'agent_end', messages: [] }, {});
  80. const afterEnd = await context({ type: 'context', messages: originalMessages }, {});
  81. assert.equal(afterEnd, undefined, 'startup bootstrap should clear after agent_end');
  82. });
  83. test('session_compact injects bootstrap after compaction summaries, not before compaction', async () => {
  84. const { handlers } = await loadExtension();
  85. const sessionCompact = firstHandler(handlers, 'session_compact');
  86. const context = firstHandler(handlers, 'context');
  87. await sessionCompact({ type: 'session_compact', compactionEntry: {}, fromExtension: false }, {});
  88. const summary = { role: 'compactionSummary', summary: 'Prior work summary', tokensBefore: 123, timestamp: 1 };
  89. const user = { role: 'user', content: [{ type: 'text', text: 'Continue' }], timestamp: 2 };
  90. const result = await context({ type: 'context', messages: [summary, user] }, {});
  91. assert.equal(result.messages.length, 3);
  92. assert.equal(result.messages[0], summary);
  93. assert.equal(result.messages[1].role, 'user');
  94. assert.match(textOf(result.messages[1]), /You have superpowers/);
  95. assert.equal(result.messages[2], user);
  96. });
  97. test('pi tools reference documents pi-specific mappings', async () => {
  98. assert.equal(existsSync(piToolsPath), true, 'pi-tools.md should exist');
  99. const text = await readFile(piToolsPath, 'utf8');
  100. // Assert against the mapping-table rows only. The surrounding prose mentions
  101. // these same tokens, so matching the whole file would still pass if the table
  102. // were deleted — the exact regression this test exists to catch.
  103. const rows = text.split('\n').filter((line) => line.startsWith('|'));
  104. assert.ok(
  105. rows.some((row) => /subagent/i.test(row)),
  106. 'mapping table documents subagent dispatch',
  107. );
  108. assert.ok(
  109. rows.some((row) => /todo|task/i.test(row)),
  110. 'mapping table documents task tracking',
  111. );
  112. });