@dyyz1993/pi-coding-agent 0.74.24 → 0.74.25

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.
Files changed (145) hide show
  1. package/dist/core/agent-session.d.ts.map +1 -1
  2. package/dist/core/agent-session.js +3 -0
  3. package/dist/core/agent-session.js.map +1 -1
  4. package/dist/extensions/agent-permissions/index.ts +235 -0
  5. package/dist/extensions/ask-tools/index.ts +115 -0
  6. package/dist/extensions/auto-memory/contract.d.ts +51 -0
  7. package/dist/extensions/auto-memory/contract.d.ts.map +1 -0
  8. package/dist/extensions/auto-memory/contract.js +2 -0
  9. package/dist/extensions/auto-memory/contract.js.map +1 -0
  10. package/dist/extensions/auto-memory/contract.ts +56 -0
  11. package/dist/extensions/auto-memory/index.ts +969 -0
  12. package/dist/extensions/auto-memory/prompts.ts +202 -0
  13. package/dist/extensions/auto-memory/skip-rules.ts +297 -0
  14. package/dist/extensions/auto-memory/utils.ts +208 -0
  15. package/dist/extensions/auto-session-title/index.ts +83 -0
  16. package/dist/extensions/bash-ext/contract.d.ts +79 -0
  17. package/dist/extensions/bash-ext/contract.d.ts.map +1 -0
  18. package/dist/extensions/bash-ext/contract.js +2 -0
  19. package/dist/extensions/bash-ext/contract.js.map +1 -0
  20. package/dist/extensions/bash-ext/contract.ts +69 -0
  21. package/dist/extensions/bash-ext/index.ts +858 -0
  22. package/dist/extensions/claude-hooks-compat/config-loader.ts +49 -0
  23. package/dist/extensions/claude-hooks-compat/handler-runner.ts +377 -0
  24. package/dist/extensions/claude-hooks-compat/if-parser.ts +53 -0
  25. package/dist/extensions/claude-hooks-compat/index.ts +178 -0
  26. package/dist/extensions/claude-hooks-compat/matcher.ts +17 -0
  27. package/dist/extensions/claude-hooks-compat/stdin-builder.ts +27 -0
  28. package/dist/extensions/claude-hooks-compat/types.ts +77 -0
  29. package/dist/extensions/compaction-manager/config.ts +47 -0
  30. package/dist/extensions/compaction-manager/context-fold.ts +63 -0
  31. package/dist/extensions/compaction-manager/index.ts +151 -0
  32. package/dist/extensions/compaction-manager/microcompact.ts +49 -0
  33. package/dist/extensions/compaction-manager/reactive.ts +9 -0
  34. package/dist/extensions/compaction-manager/session-memory.ts +48 -0
  35. package/dist/extensions/coordinator/INTEGRATION.md +376 -0
  36. package/dist/extensions/coordinator/handler.test.ts +277 -0
  37. package/dist/extensions/coordinator/handler.ts +189 -0
  38. package/dist/extensions/coordinator/index.ts +261 -0
  39. package/dist/extensions/coordinator/types.d.ts +100 -0
  40. package/dist/extensions/coordinator/types.d.ts.map +1 -0
  41. package/dist/extensions/coordinator/types.js +2 -0
  42. package/dist/extensions/coordinator/types.js.map +1 -0
  43. package/dist/extensions/coordinator/types.ts +72 -0
  44. package/dist/extensions/file-snapshot/index.ts +131 -0
  45. package/dist/extensions/file-time-guard/README.md +133 -0
  46. package/dist/extensions/file-time-guard/config.ts +13 -0
  47. package/dist/extensions/file-time-guard/index.ts +171 -0
  48. package/dist/extensions/hooks-engine/index.ts +117 -0
  49. package/dist/extensions/lsp/lsp/client/file-tracker.ts +70 -0
  50. package/dist/extensions/lsp/lsp/client/registry.ts +305 -0
  51. package/dist/extensions/lsp/lsp/client/runtime.ts +832 -0
  52. package/dist/extensions/lsp/lsp/config/resolver.ts +573 -0
  53. package/dist/extensions/lsp/lsp/contract.d.ts +101 -0
  54. package/dist/extensions/lsp/lsp/contract.d.ts.map +1 -0
  55. package/dist/extensions/lsp/lsp/contract.js +2 -0
  56. package/dist/extensions/lsp/lsp/contract.js.map +1 -0
  57. package/dist/extensions/lsp/lsp/contract.ts +103 -0
  58. package/dist/extensions/lsp/lsp/hooks/agent-end.ts +169 -0
  59. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts +10 -0
  60. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts.map +1 -0
  61. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js +30 -0
  62. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js.map +1 -0
  63. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.ts +41 -0
  64. package/dist/extensions/lsp/lsp/hooks/writethrough.ts +342 -0
  65. package/dist/extensions/lsp/lsp/index.ts +307 -0
  66. package/dist/extensions/lsp/lsp/lsp.test.ts +684 -0
  67. package/dist/extensions/lsp/lsp/monitoring/server-metrics.ts +176 -0
  68. package/dist/extensions/lsp/lsp/tools/lsp-tool.ts +402 -0
  69. package/dist/extensions/lsp/lsp/utils/dependency-resolver.ts +147 -0
  70. package/dist/extensions/lsp/lsp/utils/diagnostics-wait.ts +41 -0
  71. package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts +20 -0
  72. package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts.map +1 -0
  73. package/dist/extensions/lsp/lsp/utils/lsp-helpers.js +64 -0
  74. package/dist/extensions/lsp/lsp/utils/lsp-helpers.js.map +1 -0
  75. package/dist/extensions/lsp/lsp/utils/lsp-helpers.ts +76 -0
  76. package/dist/extensions/message-bridge/GUIDE.md +210 -0
  77. package/dist/extensions/message-bridge/index.ts +222 -0
  78. package/dist/extensions/output-guard/index.ts +384 -0
  79. package/dist/extensions/preview/index.ts +278 -0
  80. package/dist/extensions/rules-engine/MATCH_HISTORY_RECONCILIATION.md +111 -0
  81. package/dist/extensions/rules-engine/RULES-ENGINE-GUIDE.md +470 -0
  82. package/dist/extensions/rules-engine/cache.js +232 -0
  83. package/dist/extensions/rules-engine/cache.ts +38 -0
  84. package/dist/extensions/rules-engine/config.js +63 -0
  85. package/dist/extensions/rules-engine/config.ts +70 -0
  86. package/dist/extensions/rules-engine/index.js +1530 -0
  87. package/dist/extensions/rules-engine/index.ts +552 -0
  88. package/dist/extensions/rules-engine/injector.js +68 -0
  89. package/dist/extensions/rules-engine/injector.ts +74 -0
  90. package/dist/extensions/rules-engine/loader.js +179 -0
  91. package/dist/extensions/rules-engine/loader.ts +205 -0
  92. package/dist/extensions/rules-engine/matcher.js +534 -0
  93. package/dist/extensions/rules-engine/matcher.ts +52 -0
  94. package/dist/extensions/rules-engine/types.d.ts +156 -0
  95. package/dist/extensions/rules-engine/types.d.ts.map +1 -0
  96. package/dist/extensions/rules-engine/types.js +2 -0
  97. package/dist/extensions/rules-engine/types.js.map +1 -0
  98. package/dist/extensions/rules-engine/types.ts +169 -0
  99. package/dist/extensions/session-supervisor/checker.ts +116 -0
  100. package/dist/extensions/session-supervisor/config.ts +45 -0
  101. package/dist/extensions/session-supervisor/index.ts +726 -0
  102. package/dist/extensions/session-supervisor/prompts.ts +132 -0
  103. package/dist/extensions/session-supervisor/scheduler.ts +69 -0
  104. package/dist/extensions/session-supervisor/types.ts +215 -0
  105. package/dist/extensions/subagent/README.md +172 -0
  106. package/dist/extensions/subagent/agents/explorer.md +25 -0
  107. package/dist/extensions/subagent/agents/guide.md +27 -0
  108. package/dist/extensions/subagent/agents/planner.md +37 -0
  109. package/dist/extensions/subagent/agents/reviewer.md +35 -0
  110. package/dist/extensions/subagent/agents/scout.md +50 -0
  111. package/dist/extensions/subagent/agents/verification.md +35 -0
  112. package/dist/extensions/subagent/agents/worker.md +24 -0
  113. package/dist/extensions/subagent/agents.ts +25 -0
  114. package/dist/extensions/subagent/index.ts +987 -0
  115. package/dist/extensions/subagent/prompts/implement-and-review.md +10 -0
  116. package/dist/extensions/subagent/prompts/implement.md +10 -0
  117. package/dist/extensions/subagent/prompts/scout-and-plan.md +9 -0
  118. package/dist/extensions/subagent-ext/contract.d.ts +2 -0
  119. package/dist/extensions/subagent-ext/contract.d.ts.map +1 -0
  120. package/dist/extensions/subagent-ext/contract.js +2 -0
  121. package/dist/extensions/subagent-ext/contract.js.map +1 -0
  122. package/dist/extensions/subagent-ext/contract.ts +1 -0
  123. package/dist/extensions/subagent-ext/index.ts +347 -0
  124. package/dist/extensions/subagent-shared/contract.d.ts +25 -0
  125. package/dist/extensions/subagent-shared/contract.d.ts.map +1 -0
  126. package/dist/extensions/subagent-shared/contract.js +2 -0
  127. package/dist/extensions/subagent-shared/contract.js.map +1 -0
  128. package/dist/extensions/subagent-shared/contract.ts +28 -0
  129. package/dist/extensions/subagent-shared/index.ts +4 -0
  130. package/dist/extensions/subagent-shared/render.ts +166 -0
  131. package/dist/extensions/subagent-shared/types.ts +35 -0
  132. package/dist/extensions/subagent-shared/utils.ts +112 -0
  133. package/dist/extensions/subagent-v2/contract.d.ts +2 -0
  134. package/dist/extensions/subagent-v2/contract.d.ts.map +1 -0
  135. package/dist/extensions/subagent-v2/contract.js +2 -0
  136. package/dist/extensions/subagent-v2/contract.js.map +1 -0
  137. package/dist/extensions/subagent-v2/contract.ts +1 -0
  138. package/dist/extensions/subagent-v2/index.ts +599 -0
  139. package/dist/extensions/todo-ext/contract.d.ts +27 -0
  140. package/dist/extensions/todo-ext/contract.d.ts.map +1 -0
  141. package/dist/extensions/todo-ext/contract.js +2 -0
  142. package/dist/extensions/todo-ext/contract.js.map +1 -0
  143. package/dist/extensions/todo-ext/contract.ts +30 -0
  144. package/dist/extensions/todo-ext/index.ts +419 -0
  145. package/package.json +6 -5
@@ -0,0 +1,684 @@
1
+ import { tmpdir } from "node:os";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { ExtensionAPI } from "@dyyz1993/pi-coding-agent";
6
+ import { createFileTracker } from "./client/file-tracker.js";
7
+ import { createDiagnosticsMode, type DiagnosticsModeName } from "./hooks/diagnostics-mode.js";
8
+ import { createDependencyResolver } from "./utils/dependency-resolver.js";
9
+ import lspExtensionDefault, { type LspChannelEvent } from "./index.js";
10
+
11
+ function createMockPi() {
12
+ const handlers: Record<string, Array<(event: any, ctx: any) => any>> = {};
13
+ const registeredTools = new Map<string, any>();
14
+ const channelSendFn = vi.fn();
15
+ const registerCommandFn = vi.fn();
16
+ let channelOnReceiveHandler: ((data: unknown) => void) | null = null;
17
+ let currentChannel: {
18
+ name: string;
19
+ send: (data: unknown) => void;
20
+ onReceive: (handler: (data: unknown) => void) => () => void;
21
+ invoke: (data: unknown, timeoutMs?: number) => Promise<unknown>;
22
+ call: (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown>;
23
+ } | null = null;
24
+
25
+ const pi = {
26
+ on: vi.fn((event: string, handler: any) => {
27
+ if (!handlers[event]) handlers[event] = [];
28
+ handlers[event].push(handler);
29
+ }),
30
+ callLLM: vi.fn(async () => "{}"),
31
+ callLLMStructured: vi.fn(async () => ({})),
32
+ forkAgent: vi.fn(async () => ({ text: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 } })),
33
+ once: vi.fn(),
34
+ emit: vi.fn(),
35
+ setStatus: vi.fn(),
36
+ registerProvider: vi.fn(),
37
+ unregisterProvider: vi.fn(),
38
+ events: { on: vi.fn(), off: vi.fn(), emit: vi.fn(), once: vi.fn() },
39
+ registerChannel: vi.fn(() => {
40
+ currentChannel = {
41
+ name: "lsp",
42
+ send: channelSendFn,
43
+ onReceive: vi.fn((handler: (data: unknown) => void) => {
44
+ channelOnReceiveHandler = handler;
45
+ return () => { channelOnReceiveHandler = null; };
46
+ }),
47
+ invoke: vi.fn(async (data: unknown) => {
48
+ if (!channelOnReceiveHandler) return {};
49
+ const msg = data as Record<string, unknown>;
50
+ const invokeId = msg.__invokeId as string;
51
+ return new Promise((resolve) => {
52
+ const orig = channelSendFn.getMockImplementation() ?? channelSendFn;
53
+ channelSendFn.mockImplementation((response: unknown) => {
54
+ const resp = response as Record<string, unknown>;
55
+ if (resp?.invokeId === invokeId) {
56
+ channelSendFn.mockImplementation(orig as any);
57
+ resolve(response);
58
+ }
59
+ });
60
+ channelOnReceiveHandler!(data);
61
+ });
62
+ }),
63
+ call: vi.fn(async (method: string, params: Record<string, unknown>, _timeoutMs?: number) => {
64
+ if (!channelOnReceiveHandler) return {};
65
+ const invokeId = `invoke_${method}_${Date.now()}`;
66
+ return new Promise((resolve) => {
67
+ const orig = channelSendFn.getMockImplementation() ?? channelSendFn;
68
+ channelSendFn.mockImplementation((response: unknown) => {
69
+ const resp = response as Record<string, unknown>;
70
+ if (resp?.invokeId === invokeId) {
71
+ channelSendFn.mockImplementation(orig as any);
72
+ resolve(response);
73
+ }
74
+ });
75
+ channelOnReceiveHandler!({ __call: method, invokeId, ...params });
76
+ });
77
+ }),
78
+ };
79
+ return currentChannel;
80
+ }),
81
+ registerTool: vi.fn((tool: any) => {
82
+ registeredTools.set(tool.name, tool);
83
+ }),
84
+ registerCommand: registerCommandFn,
85
+ appendEntry: vi.fn(),
86
+ sendMessage: vi.fn(),
87
+ off: vi.fn(),
88
+ } as unknown as ExtensionAPI;
89
+
90
+ return {
91
+ pi,
92
+ handlers,
93
+ registeredTools,
94
+ channelSend: channelSendFn,
95
+ registerCommandFn,
96
+ getCurrentChannel: () => currentChannel,
97
+ };
98
+ }
99
+
100
+ async function fireSessionStart(
101
+ mock: ReturnType<typeof createMockPi>,
102
+ ctxOverrides?: Record<string, unknown>,
103
+ ): Promise<void> {
104
+ for (const h of mock.handlers.session_start ?? []) {
105
+ await h(
106
+ {},
107
+ {
108
+ sessionManager: { getBranch: () => [] },
109
+ hasUI: false,
110
+ ui: { notify: vi.fn() },
111
+ cwd: tmpdir(),
112
+ isIdle: () => true,
113
+ signal: undefined,
114
+ abort: () => {},
115
+ hasPendingMessages: () => false,
116
+ shutdown: () => {},
117
+ getContextUsage: () => undefined,
118
+ compact: () => {},
119
+ getSystemPrompt: () => "",
120
+ model: undefined,
121
+ ...ctxOverrides,
122
+ },
123
+ );
124
+ }
125
+ }
126
+
127
+ async function fireSessionShutdown(mock: ReturnType<typeof createMockPi>): Promise<void> {
128
+ for (const h of mock.handlers.session_shutdown ?? []) {
129
+ await h({}, {});
130
+ }
131
+ }
132
+
133
+ async function fireAgentEnd(mock: ReturnType<typeof createMockPi>): Promise<void> {
134
+ for (const h of mock.handlers.agent_end ?? []) {
135
+ await h({}, { cwd: tmpdir(), ui: { notify: vi.fn() } });
136
+ }
137
+ }
138
+
139
+ describe("lsp extension", () => {
140
+ describe("registration", () => {
141
+ it("registers lsp and lsp_health tools", () => {
142
+ const mock = createMockPi();
143
+ lspExtensionDefault(mock.pi);
144
+ expect(mock.registeredTools.has("lsp")).toBe(true);
145
+ expect(mock.registeredTools.has("lsp_health")).toBe(true);
146
+ });
147
+
148
+ it("registers /lsp-status and /lsp commands", () => {
149
+ const mock = createMockPi();
150
+ lspExtensionDefault(mock.pi);
151
+ expect(mock.registerCommandFn).toHaveBeenCalledWith("lsp-status", expect.objectContaining({}));
152
+ expect(mock.registerCommandFn).toHaveBeenCalledWith("lsp", expect.objectContaining({}));
153
+ });
154
+
155
+ it("registers event handlers for session_start, session_shutdown, agent_end", () => {
156
+ const mock = createMockPi();
157
+ lspExtensionDefault(mock.pi);
158
+ expect(mock.handlers.session_start?.length).toBeGreaterThanOrEqual(1);
159
+ expect(mock.handlers.session_shutdown?.length).toBeGreaterThanOrEqual(1);
160
+ expect(mock.handlers.agent_end?.length).toBeGreaterThanOrEqual(1);
161
+ });
162
+
163
+ it("lsp tool has correct parameter schema with action field", () => {
164
+ const mock = createMockPi();
165
+ lspExtensionDefault(mock.pi);
166
+ const tool = mock.registeredTools.get("lsp")!;
167
+ expect(tool.parameters.properties.action).toBeDefined();
168
+ });
169
+ });
170
+
171
+ describe("session lifecycle", () => {
172
+ it("registers channel on session_start", async () => {
173
+ const mock = createMockPi();
174
+ lspExtensionDefault(mock.pi);
175
+ await fireSessionStart(mock);
176
+ expect(mock.pi.registerChannel).toHaveBeenCalledWith("lsp");
177
+ });
178
+
179
+ it("pushes status_changed event on session_start", async () => {
180
+ const mock = createMockPi();
181
+ lspExtensionDefault(mock.pi);
182
+ await fireSessionStart(mock);
183
+ expect(mock.channelSend).toHaveBeenCalledWith(
184
+ expect.objectContaining({
185
+ event: "status_changed",
186
+ }),
187
+ );
188
+ });
189
+
190
+ it("clears channel on session_shutdown", async () => {
191
+ const mock = createMockPi();
192
+ lspExtensionDefault(mock.pi);
193
+ await fireSessionStart(mock);
194
+ expect(mock.getCurrentChannel()).not.toBeNull();
195
+ await fireSessionShutdown(mock);
196
+ expect(mock.getCurrentChannel()).not.toBeNull();
197
+ });
198
+
199
+ it("sets up idle cleanup timer on agent_end", async () => {
200
+ const mock = createMockPi();
201
+ lspExtensionDefault(mock.pi);
202
+ await fireSessionStart(mock);
203
+ await fireAgentEnd(mock);
204
+ });
205
+ });
206
+
207
+ describe("channel push events", () => {
208
+ it("pushes startup_begin event on session_start", async () => {
209
+ const mock = createMockPi();
210
+ lspExtensionDefault(mock.pi);
211
+ await fireSessionStart(mock);
212
+ expect(mock.channelSend).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ event: "startup_begin",
215
+ totalServers: expect.any(Number),
216
+ }),
217
+ );
218
+ });
219
+
220
+ it("pushes per-server ready/error events on session_start", async () => {
221
+ const mock = createMockPi();
222
+ lspExtensionDefault(mock.pi);
223
+ await fireSessionStart(mock);
224
+ const serverEvents = mock.channelSend.mock.calls.filter((c: any) => c[0]?.event?.startsWith("server_"));
225
+ expect(serverEvents.length).toBeGreaterThanOrEqual(1);
226
+ for (const call of serverEvents) {
227
+ const payload = call[0] as LspChannelEvent;
228
+ expect(["server_starting", "server_ready", "server_error"]).toContain(payload.event);
229
+ expect(payload.serverName).toBeDefined();
230
+ expect(payload.timestamp).toBeGreaterThan(0);
231
+ }
232
+ });
233
+
234
+ it("pushes startup_complete event after all servers", async () => {
235
+ const mock = createMockPi();
236
+ lspExtensionDefault(mock.pi);
237
+ await fireSessionStart(mock);
238
+ const completeCall = mock.channelSend.mock.calls.find((c: any) => c[0]?.event === "startup_complete");
239
+ expect(completeCall).toBeDefined();
240
+ const payload = completeCall![0] as LspChannelEvent;
241
+ expect(payload.event).toBe("startup_complete");
242
+ expect(payload.servers).toBeDefined();
243
+ });
244
+
245
+ it("status_changed event includes servers array", async () => {
246
+ const mock = createMockPi();
247
+ lspExtensionDefault(mock.pi);
248
+ await fireSessionStart(mock);
249
+ const call = mock.channelSend.mock.calls.find((c: any) => c[0]?.event === "status_changed");
250
+ expect(call).toBeDefined();
251
+ const payload = call![0] as LspChannelEvent;
252
+ expect(payload.timestamp).toBeGreaterThan(0);
253
+ expect(payload.servers).toBeDefined();
254
+ });
255
+
256
+ it("all channel events include timestamp", async () => {
257
+ const mock = createMockPi();
258
+ lspExtensionDefault(mock.pi);
259
+ await fireSessionStart(mock);
260
+ for (const call of mock.channelSend.mock.calls) {
261
+ const payload = call[0] as LspChannelEvent;
262
+ expect(payload.timestamp).toBeGreaterThan(0);
263
+ }
264
+ });
265
+
266
+ it("pushes language_activated event for ready servers with fileTypes", async () => {
267
+ const mock = createMockPi();
268
+ lspExtensionDefault(mock.pi);
269
+ await fireSessionStart(mock);
270
+ const languageEvents = mock.channelSend.mock.calls.filter(
271
+ (c: any) => c[0]?.event === "language_activated",
272
+ );
273
+ if (languageEvents.length > 0) {
274
+ for (const call of languageEvents) {
275
+ const payload = call[0] as LspChannelEvent;
276
+ expect(payload.event).toBe("language_activated");
277
+ expect(payload.timestamp).toBeGreaterThan(0);
278
+ expect(payload.languages).toBeDefined();
279
+ expect(payload.languages!.length).toBeGreaterThan(0);
280
+ }
281
+ }
282
+ });
283
+ });
284
+
285
+ describe("getActiveLanguages method", () => {
286
+ it("responds to getActiveLanguages channel call", async () => {
287
+ const mock = createMockPi();
288
+ lspExtensionDefault(mock.pi);
289
+ await fireSessionStart(mock);
290
+
291
+ const channel = mock.getCurrentChannel();
292
+ expect(channel).not.toBeNull();
293
+
294
+ const result = await channel!.call("getActiveLanguages", {});
295
+ expect(result).toHaveProperty("languages");
296
+ expect(Array.isArray((result as any).languages)).toBe(true);
297
+ });
298
+
299
+ it("returns languages array from channel call", async () => {
300
+ const mock = createMockPi();
301
+ lspExtensionDefault(mock.pi);
302
+ await fireSessionStart(mock);
303
+
304
+ const channel = mock.getCurrentChannel();
305
+ expect(channel).not.toBeNull();
306
+
307
+ const result = await channel!.call("getActiveLanguages", {});
308
+ expect(result).toHaveProperty("languages");
309
+ expect(Array.isArray((result as any).languages)).toBe(true);
310
+ });
311
+ });
312
+
313
+ describe("lsp command", () => {
314
+ async function getLspCommandHandler(): Promise<{
315
+ handler: (args: string, ctx: any) => Promise<void>;
316
+ notify: ReturnType<typeof vi.fn>;
317
+ }> {
318
+ const mock = createMockPi();
319
+ lspExtensionDefault(mock.pi);
320
+ const lspCommandCalls = mock.registerCommandFn.mock.calls.filter((call: any[]) => call[0] === "lsp");
321
+ expect(lspCommandCalls.length).toBe(1);
322
+ const handler = lspCommandCalls[0][1].handler;
323
+ return { handler, notify: vi.fn() };
324
+ }
325
+
326
+ it("shows current mode when called without args", async () => {
327
+ const { handler, notify } = await getLspCommandHandler();
328
+ await handler("", { ui: { notify } });
329
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("agent_end"), "info");
330
+ });
331
+
332
+ it("switches to valid mode", async () => {
333
+ const { handler, notify } = await getLspCommandHandler();
334
+ await handler("disabled", { ui: { notify } });
335
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("disabled"), "info");
336
+ });
337
+
338
+ it("rejects invalid mode", async () => {
339
+ const { handler, notify } = await getLspCommandHandler();
340
+ await handler("invalid_mode", { ui: { notify } });
341
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("Invalid"), "warning");
342
+ });
343
+
344
+ it("switches through all valid modes", async () => {
345
+ const { handler, notify } = await getLspCommandHandler();
346
+ const modes: DiagnosticsModeName[] = ["agent_end", "edit_write", "disabled"];
347
+ for (const mode of modes) {
348
+ await handler(mode, { ui: { notify } });
349
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining(mode), "info");
350
+ }
351
+ });
352
+ });
353
+
354
+ describe("lsp-status command", () => {
355
+ it("shows registry status information", async () => {
356
+ const mock = createMockPi();
357
+ lspExtensionDefault(mock.pi);
358
+ const statusCommandCalls = mock.registerCommandFn.mock.calls.filter((call: any[]) => call[0] === "lsp-status");
359
+ expect(statusCommandCalls.length).toBe(1);
360
+ const handler = statusCommandCalls[0][1].handler;
361
+ const notify = vi.fn();
362
+ await handler("", { ui: { notify } });
363
+ expect(notify).toHaveBeenCalledTimes(1);
364
+ const message = notify.mock.calls[0][0] as string;
365
+ expect(message).toContain("LSP registry:");
366
+ });
367
+ });
368
+
369
+ describe("lsp tool execution", () => {
370
+ it("status action returns registry status", async () => {
371
+ const mock = createMockPi();
372
+ lspExtensionDefault(mock.pi);
373
+ await fireSessionStart(mock);
374
+ const tool = mock.registeredTools.get("lsp")!;
375
+ const result = await tool.execute("tc_1", { action: "status" }, undefined, undefined, {} as any);
376
+ expect(result.content[0].text).toContain("status");
377
+ expect(result.details.action).toBe("status");
378
+ });
379
+
380
+ it("health shortcut returns status", async () => {
381
+ const mock = createMockPi();
382
+ lspExtensionDefault(mock.pi);
383
+ await fireSessionStart(mock);
384
+ const tool = mock.registeredTools.get("lsp_health")!;
385
+ const result = await tool.execute("tc_1", {}, undefined, undefined, {} as any);
386
+ expect(result.content[0].text).toContain("status");
387
+ expect(result.details.action).toBe("status");
388
+ });
389
+ });
390
+ });
391
+
392
+ describe("diagnostics-mode", () => {
393
+ it("defaults to agent_end", () => {
394
+ const mode = createDiagnosticsMode();
395
+ expect(mode.get()).toBe("agent_end");
396
+ });
397
+
398
+ it("set changes the mode", () => {
399
+ const mode = createDiagnosticsMode();
400
+ mode.set("disabled");
401
+ expect(mode.get()).toBe("disabled");
402
+ mode.set("edit_write");
403
+ expect(mode.get()).toBe("edit_write");
404
+ });
405
+
406
+ it("ignores invalid mode", () => {
407
+ const mode = createDiagnosticsMode();
408
+ mode.set("agent_end");
409
+ mode.set("bogus" as DiagnosticsModeName);
410
+ expect(mode.get()).toBe("agent_end");
411
+ });
412
+
413
+ it("tracks touched files without duplicates", () => {
414
+ const mode = createDiagnosticsMode();
415
+ mode.addTouchedFile("foo.ts");
416
+ mode.addTouchedFile("bar.ts");
417
+ mode.addTouchedFile("foo.ts");
418
+ expect(mode.getTouchedFiles()).toEqual(["foo.ts", "bar.ts"]);
419
+ });
420
+
421
+ it("clearTouchedFiles resets", () => {
422
+ const mode = createDiagnosticsMode();
423
+ mode.addTouchedFile("a.ts");
424
+ mode.clearTouchedFiles();
425
+ expect(mode.getTouchedFiles()).toEqual([]);
426
+ });
427
+
428
+ it("accepts initial mode", () => {
429
+ const mode = createDiagnosticsMode("disabled");
430
+ expect(mode.get()).toBe("disabled");
431
+ });
432
+
433
+ it("ignores invalid initial mode", () => {
434
+ const mode = createDiagnosticsMode("bogus" as DiagnosticsModeName);
435
+ expect(mode.get()).toBe("agent_end");
436
+ });
437
+ });
438
+
439
+ describe("file-tracker", () => {
440
+ it("tracks open files", () => {
441
+ const tracker = createFileTracker({ maxOpenFiles: 3 });
442
+ tracker.open("a.ts", () => {});
443
+ tracker.open("b.ts", () => {});
444
+ expect(tracker.getOpenFiles()).toEqual(["a.ts", "b.ts"]);
445
+ });
446
+
447
+ it("evicts oldest file when exceeding maxOpenFiles", () => {
448
+ const evicted: string[] = [];
449
+ const tracker = createFileTracker({ maxOpenFiles: 2 });
450
+ tracker.open("a.ts", (f) => evicted.push(f));
451
+ tracker.open("b.ts", (f) => evicted.push(f));
452
+ tracker.open("c.ts", (f) => evicted.push(f));
453
+ expect(evicted).toEqual(["a.ts"]);
454
+ expect(tracker.getOpenFiles()).toEqual(["b.ts", "c.ts"]);
455
+ });
456
+
457
+ it("re-access moves file to end", () => {
458
+ const evicted: string[] = [];
459
+ const tracker = createFileTracker({ maxOpenFiles: 2 });
460
+ tracker.open("a.ts", (f) => evicted.push(f));
461
+ tracker.open("b.ts", (f) => evicted.push(f));
462
+ tracker.open("a.ts", (f) => evicted.push(f));
463
+ tracker.open("c.ts", (f) => evicted.push(f));
464
+ expect(evicted).toEqual(["b.ts"]);
465
+ expect(tracker.getOpenFiles()).toEqual(["a.ts", "c.ts"]);
466
+ });
467
+
468
+ it("getIdleFiles returns files not accessed recently", () => {
469
+ let now = 1000;
470
+ const tracker = createFileTracker({
471
+ maxOpenFiles: 10,
472
+ now: () => now,
473
+ });
474
+ tracker.open("a.ts", () => {});
475
+ now = 4000;
476
+ tracker.open("b.ts", () => {});
477
+ now = 5000;
478
+ const idle = tracker.getIdleFiles(2500);
479
+ expect(idle).toEqual(["a.ts"]);
480
+ });
481
+
482
+ it("closeAll invokes callback for every file", () => {
483
+ const closed: string[] = [];
484
+ const tracker = createFileTracker({ maxOpenFiles: 10 });
485
+ tracker.open("x.ts", () => {});
486
+ tracker.open("y.ts", () => {});
487
+ tracker.closeAll((f) => closed.push(f));
488
+ expect(closed).toEqual(["x.ts", "y.ts"]);
489
+ expect(tracker.getOpenFiles()).toEqual([]);
490
+ });
491
+ });
492
+
493
+ describe("dependency-resolver", () => {
494
+ it("finds files that import a touched file", async () => {
495
+ const tmpDir = join(tmpdir(), `lsp-dep-test-${Date.now()}`);
496
+ await mkdir(tmpDir, { recursive: true });
497
+
498
+ await writeFile(
499
+ join(tmpDir, "types.ts"),
500
+ "export interface User { name: string }",
501
+ );
502
+ await writeFile(
503
+ join(tmpDir, "user.ts"),
504
+ `import { User } from "./types";\nconst u: User = { name: "test" };`,
505
+ );
506
+ await writeFile(
507
+ join(tmpDir, "other.ts"),
508
+ `export const x = 1;`,
509
+ );
510
+
511
+ const resolver = createDependencyResolver({ cwd: tmpDir });
512
+ const dependents = await resolver.resolveDependents(["types.ts"]);
513
+
514
+ expect(dependents).toContain("user.ts");
515
+ expect(dependents).not.toContain("other.ts");
516
+ expect(dependents).not.toContain("types.ts");
517
+ });
518
+
519
+ it("returns empty for files with no dependents", async () => {
520
+ const tmpDir = join(tmpdir(), `lsp-dep-empty-${Date.now()}`);
521
+ await mkdir(tmpDir, { recursive: true });
522
+
523
+ await writeFile(join(tmpDir, "isolated.ts"), "export const x = 1;");
524
+
525
+ const resolver = createDependencyResolver({ cwd: tmpDir });
526
+ const dependents = await resolver.resolveDependents(["isolated.ts"]);
527
+
528
+ expect(dependents).toEqual([]);
529
+ });
530
+
531
+ it("handles require-style imports", async () => {
532
+ const tmpDir = join(tmpdir(), `lsp-dep-require-${Date.now()}`);
533
+ await mkdir(tmpDir, { recursive: true });
534
+
535
+ await writeFile(join(tmpDir, "config.js"), "module.exports = {};");
536
+ await writeFile(join(tmpDir, "app.js"), `const config = require("./config");`);
537
+
538
+ const resolver = createDependencyResolver({ cwd: tmpDir });
539
+ const dependents = await resolver.resolveDependents(["config.js"]);
540
+
541
+ expect(dependents).toContain("app.js");
542
+ });
543
+
544
+ it("respects maxDependents limit", async () => {
545
+ const tmpDir = join(tmpdir(), `lsp-dep-max-${Date.now()}`);
546
+ await mkdir(tmpDir, { recursive: true });
547
+
548
+ await writeFile(join(tmpDir, "shared.ts"), "export const shared = 1;");
549
+ for (let i = 0; i < 10; i++) {
550
+ await writeFile(join(tmpDir, `file${i}.ts`), `import { shared } from "./shared";`);
551
+ }
552
+
553
+ const resolver = createDependencyResolver({ cwd: tmpDir, maxDependents: 3 });
554
+ const dependents = await resolver.resolveDependents(["shared.ts"]);
555
+
556
+ expect(dependents.length).toBeLessThanOrEqual(3);
557
+ });
558
+
559
+ it("returns empty for empty input", async () => {
560
+ const resolver = createDependencyResolver();
561
+ const dependents = await resolver.resolveDependents([]);
562
+ expect(dependents).toEqual([]);
563
+ });
564
+
565
+ it("handles subdirectory imports", async () => {
566
+ const tmpDir = join(tmpdir(), `lsp-dep-subdir-${Date.now()}`);
567
+ await mkdir(tmpDir, { recursive: true });
568
+ await mkdir(join(tmpDir, "utils"), { recursive: true });
569
+
570
+ await writeFile(join(tmpDir, "utils", "helpers.ts"), "export const add = (a: number, b: number) => a + b;");
571
+ await writeFile(join(tmpDir, "main.ts"), `import { add } from "./utils/helpers";\nconsole.log(add(1, 2));`);
572
+
573
+ const resolver = createDependencyResolver({ cwd: tmpDir });
574
+ const dependents = await resolver.resolveDependents(["utils/helpers.ts"]);
575
+
576
+ expect(dependents).toContain("main.ts");
577
+ });
578
+
579
+ it("skips node_modules and dot directories", async () => {
580
+ const tmpDir = join(tmpdir(), `lsp-dep-skip-${Date.now()}`);
581
+ await mkdir(tmpDir, { recursive: true });
582
+ await mkdir(join(tmpDir, "node_modules", "pkg"), { recursive: true });
583
+ await mkdir(join(tmpDir, ".hidden"), { recursive: true });
584
+
585
+ await writeFile(join(tmpDir, "core.ts"), "export const x = 1;");
586
+ await writeFile(join(tmpDir, "node_modules", "pkg", "index.ts"), `import { x } from "../../core";`);
587
+ await writeFile(join(tmpDir, ".hidden", "secret.ts"), `import { x } from "../core";`);
588
+
589
+ const resolver = createDependencyResolver({ cwd: tmpDir });
590
+ const dependents = await resolver.resolveDependents(["core.ts"]);
591
+
592
+ expect(dependents).toEqual([]);
593
+ });
594
+ });
595
+
596
+ describe("diagnostics-wait", () => {
597
+ it("returns immediately when diagnostics are already published", async () => {
598
+ const mockRuntime = {
599
+ getPublishedDiagnostics: vi.fn().mockReturnValue([{ message: "err" }]),
600
+ } as any;
601
+
602
+ const { waitForPushDiagnostics } = await import("./utils/diagnostics-wait.js");
603
+ const start = Date.now();
604
+ await waitForPushDiagnostics(mockRuntime, "test.ts", {
605
+ initialDelayMs: 10,
606
+ pollIntervalMs: 10,
607
+ maxWaitMs: 2000,
608
+ });
609
+ const elapsed = Date.now() - start;
610
+ expect(elapsed).toBeLessThan(200);
611
+ });
612
+
613
+ it("waits and finds diagnostics after polling", async () => {
614
+ let callCount = 0;
615
+ const mockRuntime = {
616
+ getPublishedDiagnostics: vi.fn(() => {
617
+ callCount++;
618
+ return callCount >= 3 ? [{ message: "late error" }] : [];
619
+ }),
620
+ } as any;
621
+
622
+ const { waitForPushDiagnostics } = await import("./utils/diagnostics-wait.js");
623
+ await waitForPushDiagnostics(mockRuntime, "test.ts", {
624
+ initialDelayMs: 10,
625
+ pollIntervalMs: 10,
626
+ maxWaitMs: 5000,
627
+ });
628
+ expect(callCount).toBeGreaterThanOrEqual(3);
629
+ });
630
+
631
+ it("gives up after maxWaitMs with no diagnostics", async () => {
632
+ const mockRuntime = {
633
+ getPublishedDiagnostics: vi.fn().mockReturnValue([]),
634
+ } as any;
635
+
636
+ const { waitForPushDiagnostics } = await import("./utils/diagnostics-wait.js");
637
+ const start = Date.now();
638
+ await waitForPushDiagnostics(mockRuntime, "test.ts", {
639
+ initialDelayMs: 10,
640
+ pollIntervalMs: 10,
641
+ maxWaitMs: 100,
642
+ });
643
+ const elapsed = Date.now() - start;
644
+ expect(elapsed).toBeGreaterThanOrEqual(80);
645
+ expect(elapsed).toBeLessThan(500);
646
+ });
647
+ });
648
+
649
+ describe("config maxOpenFiles", () => {
650
+ it("defaults to 30 when not configured", async () => {
651
+ const tmpDir = join(tmpdir(), `lsp-cfg-default-${Date.now()}`);
652
+ await mkdir(join(tmpDir, ".pi"), { recursive: true });
653
+
654
+ const { createLspConfigResolver } = await import("./config/resolver.js");
655
+ const resolver = createLspConfigResolver({ cwd: tmpDir, homeDir: tmpDir });
656
+ const config = resolver.resolve();
657
+
658
+ expect(config.maxOpenFiles).toBe(30);
659
+ });
660
+
661
+ it("reads maxOpenFiles from lsp.json", async () => {
662
+ const tmpDir = join(tmpdir(), `lsp-cfg-max-${Date.now()}`);
663
+ await mkdir(join(tmpDir, ".pi"), { recursive: true });
664
+ await writeFile(join(tmpDir, ".pi", "lsp.json"), JSON.stringify({ maxOpenFiles: 50 }));
665
+
666
+ const { createLspConfigResolver } = await import("./config/resolver.js");
667
+ const resolver = createLspConfigResolver({ cwd: tmpDir, homeDir: tmpDir });
668
+ const config = resolver.resolve();
669
+
670
+ expect(config.maxOpenFiles).toBe(50);
671
+ });
672
+
673
+ it("ignores invalid maxOpenFiles and uses default", async () => {
674
+ const tmpDir = join(tmpdir(), `lsp-cfg-invalid-${Date.now()}`);
675
+ await mkdir(join(tmpDir, ".pi"), { recursive: true });
676
+ await writeFile(join(tmpDir, ".pi", "lsp.json"), JSON.stringify({ maxOpenFiles: -5 }));
677
+
678
+ const { createLspConfigResolver } = await import("./config/resolver.js");
679
+ const resolver = createLspConfigResolver({ cwd: tmpDir, homeDir: tmpDir });
680
+ const config = resolver.resolve();
681
+
682
+ expect(config.maxOpenFiles).toBe(30);
683
+ });
684
+ });