@tyvm/knowhow 0.0.108 → 0.0.109-dev.2b94ba2

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 (236) hide show
  1. package/README.md +45 -0
  2. package/package.json +9 -4
  3. package/scripts/build-for-node.sh +10 -24
  4. package/scripts/publish.sh +86 -0
  5. package/src/agents/base/base.ts +10 -0
  6. package/src/agents/tools/execCommand.ts +49 -6
  7. package/src/agents/tools/index.ts +0 -1
  8. package/src/agents/tools/list.ts +2 -4
  9. package/src/chat/CliChatService.ts +11 -2
  10. package/src/chat/modules/AgentModule.ts +61 -31
  11. package/src/chat/modules/SessionsModule.ts +47 -3
  12. package/src/chat/modules/SystemModule.ts +2 -2
  13. package/src/chat/renderer/CompactRenderer.ts +20 -0
  14. package/src/chat/renderer/ConsoleRenderer.ts +19 -0
  15. package/src/chat/renderer/FancyRenderer.ts +19 -0
  16. package/src/chat/renderer/types.ts +11 -0
  17. package/src/cli.ts +91 -659
  18. package/src/clients/anthropic.ts +18 -17
  19. package/src/clients/index.ts +31 -11
  20. package/src/clients/openai.ts +8 -5
  21. package/src/clients/types.ts +48 -10
  22. package/src/clients/withRetry.ts +89 -0
  23. package/src/cloudWorker.ts +175 -113
  24. package/src/commands/agent.ts +246 -0
  25. package/src/commands/misc.ts +174 -0
  26. package/src/commands/modules.ts +552 -0
  27. package/src/commands/services.ts +77 -0
  28. package/src/commands/workers.ts +168 -0
  29. package/src/config.ts +38 -1
  30. package/src/fileSync.ts +70 -29
  31. package/src/hashes.ts +35 -13
  32. package/src/index.ts +18 -0
  33. package/src/logger.ts +197 -0
  34. package/src/plugins/embedding.ts +11 -6
  35. package/src/plugins/plugins.ts +0 -21
  36. package/src/plugins/vim.ts +5 -16
  37. package/src/processors/JsonCompressor.ts +6 -6
  38. package/src/services/EventService.ts +61 -1
  39. package/src/services/KnowhowClient.ts +34 -4
  40. package/src/services/MediaProcessorService.ts +79 -10
  41. package/src/services/modules/index.ts +102 -53
  42. package/src/services/modules/types.ts +6 -0
  43. package/src/tunnel.ts +216 -0
  44. package/src/types.ts +0 -1
  45. package/src/worker.ts +105 -312
  46. package/src/workers/auth/WsMiddleware.ts +99 -0
  47. package/src/workers/auth/authMiddleware.ts +104 -0
  48. package/src/workers/auth/types.ts +14 -2
  49. package/src/workers/tools/index.ts +2 -0
  50. package/src/workers/tools/reloadConfig.ts +84 -0
  51. package/tests/services/WorkerReloadConfig.test.ts +141 -0
  52. package/tests/unit/clients/AIClient.test.ts +446 -0
  53. package/tests/unit/clients/withRetry.test.ts +319 -0
  54. package/tests/unit/commands/github-credentials.test.ts +210 -0
  55. package/tests/unit/modules/moduleLoading.test.ts +39 -37
  56. package/tests/unit/plugins/pluginLoading.test.ts +0 -85
  57. package/ts_build/package.json +9 -4
  58. package/ts_build/src/agents/base/base.js +11 -0
  59. package/ts_build/src/agents/base/base.js.map +1 -1
  60. package/ts_build/src/agents/tools/execCommand.d.ts +1 -1
  61. package/ts_build/src/agents/tools/execCommand.js +39 -5
  62. package/ts_build/src/agents/tools/execCommand.js.map +1 -1
  63. package/ts_build/src/agents/tools/index.d.ts +0 -1
  64. package/ts_build/src/agents/tools/index.js +0 -1
  65. package/ts_build/src/agents/tools/index.js.map +1 -1
  66. package/ts_build/src/agents/tools/list.js +2 -4
  67. package/ts_build/src/agents/tools/list.js.map +1 -1
  68. package/ts_build/src/chat/CliChatService.js +14 -2
  69. package/ts_build/src/chat/CliChatService.js.map +1 -1
  70. package/ts_build/src/chat/modules/AgentModule.d.ts +1 -1
  71. package/ts_build/src/chat/modules/AgentModule.js +43 -20
  72. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  73. package/ts_build/src/chat/modules/SessionsModule.js +37 -3
  74. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  75. package/ts_build/src/chat/modules/SystemModule.js +2 -2
  76. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  77. package/ts_build/src/chat/renderer/CompactRenderer.d.ts +4 -0
  78. package/ts_build/src/chat/renderer/CompactRenderer.js +16 -0
  79. package/ts_build/src/chat/renderer/CompactRenderer.js.map +1 -1
  80. package/ts_build/src/chat/renderer/ConsoleRenderer.d.ts +4 -0
  81. package/ts_build/src/chat/renderer/ConsoleRenderer.js +16 -0
  82. package/ts_build/src/chat/renderer/ConsoleRenderer.js.map +1 -1
  83. package/ts_build/src/chat/renderer/FancyRenderer.d.ts +4 -0
  84. package/ts_build/src/chat/renderer/FancyRenderer.js +16 -0
  85. package/ts_build/src/chat/renderer/FancyRenderer.js.map +1 -1
  86. package/ts_build/src/chat/renderer/types.d.ts +2 -0
  87. package/ts_build/src/cli.js +47 -519
  88. package/ts_build/src/cli.js.map +1 -1
  89. package/ts_build/src/clients/anthropic.d.ts +5 -5
  90. package/ts_build/src/clients/anthropic.js +18 -17
  91. package/ts_build/src/clients/anthropic.js.map +1 -1
  92. package/ts_build/src/clients/index.js +9 -10
  93. package/ts_build/src/clients/index.js.map +1 -1
  94. package/ts_build/src/clients/openai.js +4 -4
  95. package/ts_build/src/clients/openai.js.map +1 -1
  96. package/ts_build/src/clients/types.d.ts +15 -8
  97. package/ts_build/src/clients/withRetry.d.ts +2 -0
  98. package/ts_build/src/clients/withRetry.js +60 -0
  99. package/ts_build/src/clients/withRetry.js.map +1 -0
  100. package/ts_build/src/cloudWorker.d.ts +14 -0
  101. package/ts_build/src/cloudWorker.js +105 -66
  102. package/ts_build/src/cloudWorker.js.map +1 -1
  103. package/ts_build/src/commands/agent.d.ts +6 -0
  104. package/ts_build/src/commands/agent.js +229 -0
  105. package/ts_build/src/commands/agent.js.map +1 -0
  106. package/ts_build/src/commands/misc.d.ts +10 -0
  107. package/ts_build/src/commands/misc.js +197 -0
  108. package/ts_build/src/commands/misc.js.map +1 -0
  109. package/ts_build/src/commands/modules.d.ts +3 -0
  110. package/ts_build/src/commands/modules.js +487 -0
  111. package/ts_build/src/commands/modules.js.map +1 -0
  112. package/ts_build/src/commands/services.d.ts +5 -0
  113. package/ts_build/src/commands/services.js +87 -0
  114. package/ts_build/src/commands/services.js.map +1 -0
  115. package/ts_build/src/commands/workers.d.ts +6 -0
  116. package/ts_build/src/commands/workers.js +168 -0
  117. package/ts_build/src/commands/workers.js.map +1 -0
  118. package/ts_build/src/config.d.ts +1 -0
  119. package/ts_build/src/config.js +33 -1
  120. package/ts_build/src/config.js.map +1 -1
  121. package/ts_build/src/fileSync.d.ts +6 -0
  122. package/ts_build/src/fileSync.js +50 -23
  123. package/ts_build/src/fileSync.js.map +1 -1
  124. package/ts_build/src/hashes.d.ts +2 -2
  125. package/ts_build/src/hashes.js +35 -9
  126. package/ts_build/src/hashes.js.map +1 -1
  127. package/ts_build/src/index.d.ts +1 -0
  128. package/ts_build/src/index.js +17 -1
  129. package/ts_build/src/index.js.map +1 -1
  130. package/ts_build/src/logger.d.ts +21 -0
  131. package/ts_build/src/logger.js +106 -0
  132. package/ts_build/src/logger.js.map +1 -0
  133. package/ts_build/src/plugins/embedding.js +4 -3
  134. package/ts_build/src/plugins/embedding.js.map +1 -1
  135. package/ts_build/src/plugins/plugins.d.ts +0 -2
  136. package/ts_build/src/plugins/plugins.js +0 -11
  137. package/ts_build/src/plugins/plugins.js.map +1 -1
  138. package/ts_build/src/plugins/vim.js +3 -9
  139. package/ts_build/src/plugins/vim.js.map +1 -1
  140. package/ts_build/src/processors/JsonCompressor.js +4 -4
  141. package/ts_build/src/processors/JsonCompressor.js.map +1 -1
  142. package/ts_build/src/services/EventService.d.ts +6 -1
  143. package/ts_build/src/services/EventService.js +29 -0
  144. package/ts_build/src/services/EventService.js.map +1 -1
  145. package/ts_build/src/services/KnowhowClient.d.ts +13 -1
  146. package/ts_build/src/services/KnowhowClient.js +19 -2
  147. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  148. package/ts_build/src/services/MediaProcessorService.d.ts +5 -4
  149. package/ts_build/src/services/MediaProcessorService.js +53 -8
  150. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  151. package/ts_build/src/services/modules/index.d.ts +33 -0
  152. package/ts_build/src/services/modules/index.js +73 -49
  153. package/ts_build/src/services/modules/index.js.map +1 -1
  154. package/ts_build/src/services/modules/types.d.ts +6 -0
  155. package/ts_build/src/tunnel.d.ts +27 -0
  156. package/ts_build/src/tunnel.js +112 -0
  157. package/ts_build/src/tunnel.js.map +1 -0
  158. package/ts_build/src/types.d.ts +0 -1
  159. package/ts_build/src/types.js.map +1 -1
  160. package/ts_build/src/worker.d.ts +1 -4
  161. package/ts_build/src/worker.js +59 -227
  162. package/ts_build/src/worker.js.map +1 -1
  163. package/ts_build/src/workers/auth/WsMiddleware.d.ts +8 -0
  164. package/ts_build/src/workers/auth/WsMiddleware.js +65 -0
  165. package/ts_build/src/workers/auth/WsMiddleware.js.map +1 -0
  166. package/ts_build/src/workers/auth/authMiddleware.d.ts +3 -0
  167. package/ts_build/src/workers/auth/authMiddleware.js +60 -0
  168. package/ts_build/src/workers/auth/authMiddleware.js.map +1 -0
  169. package/ts_build/src/workers/auth/types.d.ts +8 -1
  170. package/ts_build/src/workers/tools/index.d.ts +2 -0
  171. package/ts_build/src/workers/tools/index.js +4 -1
  172. package/ts_build/src/workers/tools/index.js.map +1 -1
  173. package/ts_build/src/workers/tools/reloadConfig.d.ts +14 -0
  174. package/ts_build/src/workers/tools/reloadConfig.js +48 -0
  175. package/ts_build/src/workers/tools/reloadConfig.js.map +1 -0
  176. package/ts_build/tests/services/WorkerReloadConfig.test.d.ts +1 -0
  177. package/ts_build/tests/services/WorkerReloadConfig.test.js +86 -0
  178. package/ts_build/tests/services/WorkerReloadConfig.test.js.map +1 -0
  179. package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
  180. package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
  181. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
  182. package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
  183. package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
  184. package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
  185. package/ts_build/tests/unit/commands/github-credentials.test.d.ts +1 -0
  186. package/ts_build/tests/unit/commands/github-credentials.test.js +145 -0
  187. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -0
  188. package/ts_build/tests/unit/modules/moduleLoading.test.js +20 -26
  189. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
  190. package/ts_build/tests/unit/plugins/pluginLoading.test.js +0 -65
  191. package/ts_build/tests/unit/plugins/pluginLoading.test.js.map +1 -1
  192. package/src/agents/tools/executeScript/README.md +0 -94
  193. package/src/agents/tools/executeScript/definition.ts +0 -79
  194. package/src/agents/tools/executeScript/examples/dependency-injection-validation.ts +0 -272
  195. package/src/agents/tools/executeScript/examples/quick-test.ts +0 -74
  196. package/src/agents/tools/executeScript/examples/serialization-test.ts +0 -321
  197. package/src/agents/tools/executeScript/examples/test-runner.ts +0 -197
  198. package/src/agents/tools/executeScript/index.ts +0 -98
  199. package/src/services/script-execution/SandboxContext.ts +0 -282
  200. package/src/services/script-execution/ScriptExecutor.ts +0 -441
  201. package/src/services/script-execution/ScriptPolicy.ts +0 -194
  202. package/src/services/script-execution/ScriptTracer.ts +0 -249
  203. package/src/services/script-execution/types.ts +0 -134
  204. package/ts_build/src/agents/tools/executeScript/definition.d.ts +0 -2
  205. package/ts_build/src/agents/tools/executeScript/definition.js +0 -76
  206. package/ts_build/src/agents/tools/executeScript/definition.js.map +0 -1
  207. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.d.ts +0 -18
  208. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.js +0 -192
  209. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.js.map +0 -1
  210. package/ts_build/src/agents/tools/executeScript/examples/quick-test.d.ts +0 -3
  211. package/ts_build/src/agents/tools/executeScript/examples/quick-test.js +0 -64
  212. package/ts_build/src/agents/tools/executeScript/examples/quick-test.js.map +0 -1
  213. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.d.ts +0 -15
  214. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.js +0 -266
  215. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.js.map +0 -1
  216. package/ts_build/src/agents/tools/executeScript/examples/test-runner.d.ts +0 -4
  217. package/ts_build/src/agents/tools/executeScript/examples/test-runner.js +0 -208
  218. package/ts_build/src/agents/tools/executeScript/examples/test-runner.js.map +0 -1
  219. package/ts_build/src/agents/tools/executeScript/index.d.ts +0 -28
  220. package/ts_build/src/agents/tools/executeScript/index.js +0 -72
  221. package/ts_build/src/agents/tools/executeScript/index.js.map +0 -1
  222. package/ts_build/src/services/script-execution/SandboxContext.d.ts +0 -34
  223. package/ts_build/src/services/script-execution/SandboxContext.js +0 -189
  224. package/ts_build/src/services/script-execution/SandboxContext.js.map +0 -1
  225. package/ts_build/src/services/script-execution/ScriptExecutor.d.ts +0 -19
  226. package/ts_build/src/services/script-execution/ScriptExecutor.js +0 -269
  227. package/ts_build/src/services/script-execution/ScriptExecutor.js.map +0 -1
  228. package/ts_build/src/services/script-execution/ScriptPolicy.d.ts +0 -28
  229. package/ts_build/src/services/script-execution/ScriptPolicy.js +0 -115
  230. package/ts_build/src/services/script-execution/ScriptPolicy.js.map +0 -1
  231. package/ts_build/src/services/script-execution/ScriptTracer.d.ts +0 -19
  232. package/ts_build/src/services/script-execution/ScriptTracer.js +0 -186
  233. package/ts_build/src/services/script-execution/ScriptTracer.js.map +0 -1
  234. package/ts_build/src/services/script-execution/types.d.ts +0 -108
  235. package/ts_build/src/services/script-execution/types.js +0 -3
  236. package/ts_build/src/services/script-execution/types.js.map +0 -1
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Unit tests for withRetry — retry, timeout, and AbortSignal behaviour.
3
+ *
4
+ * These tests are isolated from AIClient: they exercise the withRetry helper
5
+ * directly using a mock async function.
6
+ */
7
+ import { withRetry } from "../../../src/clients/withRetry";
8
+
9
+ describe("withRetry", () => {
10
+ afterEach(() => {
11
+ jest.useRealTimers();
12
+ });
13
+
14
+ // ─── Success path ────────────────────────────────────────────────────────
15
+
16
+ describe("success path", () => {
17
+ it("returns the result immediately when fn succeeds on the first attempt", async () => {
18
+ const fn = jest.fn().mockResolvedValue({ answer: 42 });
19
+ const result = await withRetry(fn, { maxRetries: 2 });
20
+ expect(result).toEqual({ answer: 42 });
21
+ expect(fn).toHaveBeenCalledTimes(1);
22
+ });
23
+
24
+ it("passes undefined signal to fn when no timeout or external signal is provided", async () => {
25
+ let receivedSignal: AbortSignal | undefined = "not-called" as any;
26
+ const fn = jest.fn().mockImplementation((signal: any) => {
27
+ receivedSignal = signal;
28
+ return Promise.resolve("ok");
29
+ });
30
+ await withRetry(fn, {});
31
+ expect(receivedSignal).toBeUndefined();
32
+ });
33
+
34
+ it("passes a signal to fn when a timeout is provided", async () => {
35
+ let receivedSignal: AbortSignal | undefined;
36
+ const fn = jest.fn().mockImplementation((signal: any) => {
37
+ receivedSignal = signal;
38
+ return Promise.resolve("ok");
39
+ });
40
+ await withRetry(fn, { timeout: 5000 });
41
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
42
+ expect(receivedSignal!.aborted).toBe(false);
43
+ });
44
+ });
45
+
46
+ // ─── Retry path ──────────────────────────────────────────────────────────
47
+
48
+ describe("retry on retriable errors", () => {
49
+ it("retries on a 429 rate-limit error and eventually succeeds", async () => {
50
+ jest.useFakeTimers();
51
+ const fn = jest
52
+ .fn()
53
+ .mockRejectedValueOnce(new Error("429 Too Many Requests"))
54
+ .mockResolvedValueOnce({ answer: "retried" });
55
+
56
+ const promise = withRetry(fn, { maxRetries: 2, backoffMs: 100 });
57
+ await jest.runAllTimersAsync();
58
+ const result = await promise;
59
+
60
+ expect(result).toEqual({ answer: "retried" });
61
+ expect(fn).toHaveBeenCalledTimes(2);
62
+ });
63
+
64
+ it("retries on a 500 server error and eventually succeeds", async () => {
65
+ jest.useFakeTimers();
66
+ const fn = jest
67
+ .fn()
68
+ .mockRejectedValueOnce(new Error("500 Internal Server Error"))
69
+ .mockResolvedValueOnce({
70
+ choices: [{ message: { role: "assistant", content: "hi" } }],
71
+ });
72
+
73
+ const promise = withRetry(fn, { maxRetries: 2, backoffMs: 100 });
74
+ await jest.runAllTimersAsync();
75
+ const result = await promise;
76
+
77
+ expect(result).toMatchObject({
78
+ choices: [{ message: { content: "hi" } }],
79
+ });
80
+ expect(fn).toHaveBeenCalledTimes(2);
81
+ });
82
+
83
+ it("retries on ECONNRESET and succeeds", async () => {
84
+ jest.useFakeTimers();
85
+ const fn = jest
86
+ .fn()
87
+ .mockRejectedValueOnce(new Error("ECONNRESET"))
88
+ .mockResolvedValueOnce("success");
89
+
90
+ const promise = withRetry(fn, { maxRetries: 2, backoffMs: 100 });
91
+ await jest.runAllTimersAsync();
92
+ const result = await promise;
93
+
94
+ expect(result).toBe("success");
95
+ expect(fn).toHaveBeenCalledTimes(2);
96
+ });
97
+
98
+ it("retries up to maxRetries times then throws", async () => {
99
+ jest.useFakeTimers();
100
+ const fn = jest.fn().mockImplementation(() =>
101
+ Promise.reject(new Error("500 Server Error"))
102
+ );
103
+
104
+ const resultPromise = withRetry(fn, { maxRetries: 2, backoffMs: 10 });
105
+ // Suppress unhandled rejection warning while timers run
106
+ resultPromise.catch(() => {});
107
+ await jest.runAllTimersAsync();
108
+ await expect(resultPromise).rejects.toThrow("500 Server Error");
109
+ // 1 initial attempt + 2 retries = 3 total calls
110
+ expect(fn).toHaveBeenCalledTimes(3);
111
+ });
112
+
113
+ it("does NOT retry on a non-retriable error (e.g. 400 Bad Request)", async () => {
114
+ const fn = jest
115
+ .fn()
116
+ .mockRejectedValue(new Error("400 Bad Request"));
117
+ await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow(
118
+ "400 Bad Request"
119
+ );
120
+ expect(fn).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it("does NOT retry when maxRetries is 0", async () => {
124
+ const fn = jest
125
+ .fn()
126
+ .mockRejectedValue(new Error("500 Server Error"));
127
+ await expect(withRetry(fn, { maxRetries: 0 })).rejects.toThrow(
128
+ "500 Server Error"
129
+ );
130
+ expect(fn).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("uses exponential backoff between retries", async () => {
134
+ jest.useFakeTimers();
135
+
136
+ const error = new Error("500 err");
137
+ const fn = jest
138
+ .fn()
139
+ .mockRejectedValueOnce(error)
140
+ .mockRejectedValueOnce(error)
141
+ .mockResolvedValueOnce("done");
142
+
143
+ // Spy on setTimeout to capture backoff delay values
144
+ const observedDelays: number[] = [];
145
+ const origSetTimeout = global.setTimeout;
146
+ const spy = jest
147
+ .spyOn(global, "setTimeout")
148
+ .mockImplementation((cb: any, delay?: number, ...args: any[]) => {
149
+ if (delay === 100 || delay === 200) {
150
+ observedDelays.push(delay!);
151
+ }
152
+ // Always run immediately so the test doesn't stall
153
+ return origSetTimeout(cb, 0, ...args);
154
+ });
155
+
156
+ const promise = withRetry(fn, { maxRetries: 2, backoffMs: 100 });
157
+ await jest.runAllTimersAsync();
158
+ await promise;
159
+
160
+ spy.mockRestore();
161
+
162
+ // Attempt 0 backoff = 100 * 2^0 = 100ms; attempt 1 backoff = 100 * 2^1 = 200ms
163
+ expect(observedDelays).toContain(100);
164
+ expect(observedDelays).toContain(200);
165
+ });
166
+ });
167
+
168
+ // ─── Timeout path ────────────────────────────────────────────────────────
169
+
170
+ describe("per-attempt timeout", () => {
171
+ it("aborts fn via signal when timeout fires and retries on the next attempt", async () => {
172
+ jest.useFakeTimers();
173
+
174
+ // First call: honours the signal abort (simulates slow network)
175
+ // Second call: resolves immediately
176
+ const fn = jest
177
+ .fn()
178
+ .mockImplementationOnce((signal: AbortSignal) => {
179
+ return new Promise<string>((_, reject) => {
180
+ signal.addEventListener("abort", () => reject(signal.reason));
181
+ });
182
+ })
183
+ .mockResolvedValueOnce("completed after timeout retry");
184
+
185
+ const promise = withRetry(fn, {
186
+ timeout: 1000,
187
+ maxRetries: 2,
188
+ backoffMs: 10,
189
+ });
190
+
191
+ // Advance timers: fires the timeout on the first attempt, then the backoff
192
+ await jest.runAllTimersAsync();
193
+
194
+ const result = await promise;
195
+ expect(result).toBe("completed after timeout retry");
196
+ expect(fn).toHaveBeenCalledTimes(2);
197
+ });
198
+
199
+ it("throws after all retries are exhausted by timeouts", async () => {
200
+ jest.useFakeTimers();
201
+
202
+ // Every call hangs waiting for the abort signal
203
+ const fn = jest.fn().mockImplementation((signal: AbortSignal) => {
204
+ return new Promise<string>((_, reject) => {
205
+ signal.addEventListener("abort", () => reject(signal.reason));
206
+ });
207
+ });
208
+
209
+ const promise = withRetry(fn, {
210
+ timeout: 500,
211
+ maxRetries: 1,
212
+ backoffMs: 10,
213
+ });
214
+ // Suppress unhandled rejection while timers run
215
+ promise.catch(() => {});
216
+ await jest.runAllTimersAsync();
217
+
218
+ await expect(promise).rejects.toMatchObject({ name: "TimeoutError" });
219
+ // 1 initial + 1 retry, both timed out
220
+ expect(fn).toHaveBeenCalledTimes(2);
221
+ });
222
+ });
223
+
224
+ // ─── AbortSignal (external cancel) ───────────────────────────────────────
225
+
226
+ describe("external AbortSignal", () => {
227
+ it("does not call fn when signal is already aborted before invocation", async () => {
228
+ const controller = new AbortController();
229
+ controller.abort();
230
+
231
+ const fn = jest.fn().mockResolvedValue("should not reach");
232
+ await expect(
233
+ withRetry(fn, { signal: controller.signal, maxRetries: 2 })
234
+ ).rejects.toMatchObject({ name: "AbortError" });
235
+
236
+ expect(fn).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("cancels an in-flight request when signal is aborted externally", async () => {
240
+ const controller = new AbortController();
241
+
242
+ const fn = jest.fn().mockImplementation((signal: AbortSignal) => {
243
+ return new Promise<string>((_, reject) => {
244
+ signal.addEventListener("abort", () => reject(signal.reason));
245
+ });
246
+ });
247
+
248
+ const promise = withRetry(fn, {
249
+ signal: controller.signal,
250
+ maxRetries: 2,
251
+ });
252
+
253
+ // Abort from outside while in-flight
254
+ setImmediate(() =>
255
+ controller.abort(new DOMException("User cancelled", "AbortError"))
256
+ );
257
+
258
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
259
+ // Must NOT retry after external abort
260
+ expect(fn).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("does not retry after external abort even if error string looks retriable", async () => {
264
+ const controller = new AbortController();
265
+ controller.abort(new DOMException("Aborted", "AbortError"));
266
+
267
+ const fn = jest.fn().mockResolvedValue("ok");
268
+ await expect(
269
+ withRetry(fn, { signal: controller.signal, maxRetries: 3 })
270
+ ).rejects.toMatchObject({ name: "AbortError" });
271
+
272
+ expect(fn).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("forwards the combined signal to fn and it is not aborted on success", async () => {
276
+ const controller = new AbortController();
277
+ let receivedSignal: AbortSignal | undefined;
278
+
279
+ const fn = jest.fn().mockImplementation((signal: AbortSignal) => {
280
+ receivedSignal = signal;
281
+ return Promise.resolve("ok");
282
+ });
283
+
284
+ await withRetry(fn, { signal: controller.signal, timeout: 5000 });
285
+
286
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
287
+ expect(receivedSignal!.aborted).toBe(false);
288
+ });
289
+ });
290
+
291
+ // ─── Combined timeout + external signal ──────────────────────────────────
292
+
293
+ describe("timeout + external signal combined", () => {
294
+ it("abort via external signal takes priority over pending timeout", async () => {
295
+ const controller = new AbortController();
296
+
297
+ const fn = jest.fn().mockImplementation((signal: AbortSignal) => {
298
+ return new Promise<string>((_, reject) => {
299
+ signal.addEventListener("abort", () => reject(signal.reason));
300
+ });
301
+ });
302
+
303
+ const promise = withRetry(fn, {
304
+ signal: controller.signal,
305
+ timeout: 10_000, // long timeout — won't fire before external abort
306
+ maxRetries: 2,
307
+ });
308
+
309
+ // Abort externally right away
310
+ setImmediate(() =>
311
+ controller.abort(new DOMException("User cancelled", "AbortError"))
312
+ );
313
+
314
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
315
+ // No retry after external abort
316
+ expect(fn).toHaveBeenCalledTimes(1);
317
+ });
318
+ });
319
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Unit tests for the github-credentials command.
3
+ *
4
+ * Key invariant: running `github-credentials` must NEVER write anything other than
5
+ * the credential lines to stdout. Module loading logs, warnings, etc. must be
6
+ * silenced so the git credential helper protocol is not corrupted.
7
+ */
8
+
9
+ // Mock config before any imports that depend on it
10
+ jest.mock("../../../src/config", () => ({
11
+ getConfig: jest.fn().mockResolvedValue({ modules: [] }),
12
+ getGlobalConfig: jest.fn().mockResolvedValue({ modules: [] }),
13
+ getConfigSync: jest.fn().mockReturnValue({}),
14
+ migrateConfig: jest.fn().mockResolvedValue(undefined),
15
+ }));
16
+
17
+ // Mock clients to avoid openai.ts side-effects
18
+ jest.mock("../../../src/clients", () => ({
19
+ AIClient: jest.fn(),
20
+ Clients: { registerClient: jest.fn(), registerModels: jest.fn() },
21
+ }));
22
+
23
+ // Mock KnowhowSimpleClient so we control what getGitCredential returns
24
+ // without needing a real JWT or network connection
25
+ jest.mock("../../../src/services/KnowhowClient", () => ({
26
+ KnowhowSimpleClient: jest.fn().mockImplementation(() => ({
27
+ getGitCredential: jest.fn().mockResolvedValue({
28
+ protocol: "https",
29
+ host: "github.com",
30
+ username: "x-access-token",
31
+ password: "ghu_TESTTOKEN123",
32
+ }),
33
+ })),
34
+ }));
35
+
36
+ // Mock readline so the 'get' action doesn't hang waiting for stdin
37
+ jest.mock("readline", () => ({
38
+ createInterface: jest.fn().mockReturnValue({
39
+ on: jest.fn().mockImplementation(function (event: string, cb: Function) {
40
+ // Immediately fire 'close' so the readline promise resolves
41
+ if (event === "close") {
42
+ setImmediate(() => cb());
43
+ }
44
+ return this;
45
+ }),
46
+ }),
47
+ }));
48
+
49
+ import { Command } from "commander";
50
+ import { addGithubCredentialsCommand } from "../../../src/commands/misc";
51
+ import { logger } from "../../../src/logger";
52
+
53
+ describe("github-credentials command", () => {
54
+ /**
55
+ * This test verifies the EARLY silencing logic in cli.ts main().
56
+ * The problem: modules load BEFORE parseAsync, so any module that emits
57
+ * warnings (e.g. Terminal module: no TunnelHandler) does so before the
58
+ * action's logger.silence() call can stop it.
59
+ *
60
+ * The fix: cli.ts checks process.argv before module loading and silences early.
61
+ * This test simulates that logic directly.
62
+ */
63
+ describe("early silencing (pre-module-load)", () => {
64
+ beforeEach(() => {
65
+ logger.unsilence();
66
+ logger.installConsoleOverload();
67
+ });
68
+
69
+ afterEach(() => {
70
+ logger.unsilence();
71
+ logger.uninstallConsoleOverload();
72
+ });
73
+
74
+ it("silences before module loading when github-credentials is in argv", () => {
75
+ const originalArgv = process.argv;
76
+ process.argv = ["node", "knowhow", "github-credentials", "get"];
77
+
78
+ // Simulate the exact early-detection logic from cli.ts main()
79
+ const rawArgs = process.argv.slice(2);
80
+ const SILENT_COMMANDS = ["github-credentials"];
81
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
82
+ logger.silence();
83
+ }
84
+
85
+ // Now any module-load-time console.log/warn should be suppressed
86
+ const consoleSpy = jest.spyOn(process.stdout, "write");
87
+ console.log("some module loading noise that should be suppressed");
88
+
89
+ expect(consoleSpy).not.toHaveBeenCalled();
90
+ consoleSpy.mockRestore();
91
+ process.argv = originalArgv;
92
+ });
93
+
94
+ it("does NOT silence for other commands", () => {
95
+ const originalArgv = process.argv;
96
+ process.argv = ["node", "knowhow", "chat"];
97
+
98
+ const rawArgs = process.argv.slice(2);
99
+ const SILENT_COMMANDS = ["github-credentials"];
100
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
101
+ logger.silence();
102
+ }
103
+
104
+ expect(logger.isSilenced()).toBe(false);
105
+ process.argv = originalArgv;
106
+ });
107
+ });
108
+
109
+ let program: Command;
110
+ let stdoutSpy: jest.SpyInstance;
111
+ let writtenToStdout: string[];
112
+
113
+ beforeEach(() => {
114
+ jest.clearAllMocks();
115
+
116
+ // Reset logger silence state between tests
117
+ logger.unsilence();
118
+
119
+ // Capture process.stdout.write — this is what the credential helper uses
120
+ writtenToStdout = [];
121
+ stdoutSpy = jest
122
+ .spyOn(process.stdout, "write")
123
+ .mockImplementation((chunk: any) => {
124
+ writtenToStdout.push(typeof chunk === "string" ? chunk : chunk.toString());
125
+ return true;
126
+ });
127
+
128
+ program = new Command();
129
+ program.exitOverride(); // prevent process.exit during tests
130
+ addGithubCredentialsCommand(program);
131
+ });
132
+
133
+ afterEach(() => {
134
+ stdoutSpy.mockRestore();
135
+ logger.unsilence();
136
+ });
137
+
138
+ it("outputs only credential lines to stdout for 'get' action", async () => {
139
+ await program.parseAsync([
140
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
141
+ ]);
142
+
143
+ expect(writtenToStdout).toHaveLength(1);
144
+ expect(writtenToStdout[0]).toBe(
145
+ "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghu_TESTTOKEN123\n"
146
+ );
147
+ });
148
+
149
+ it("silences the logger immediately so module logs don't pollute stdout", async () => {
150
+ await program.parseAsync([
151
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
152
+ ]);
153
+
154
+ // The action must have called logger.silence() — state persists after action
155
+ expect(logger.isSilenced()).toBe(true);
156
+ });
157
+
158
+ it("produces exactly 4 credential field lines and nothing else", async () => {
159
+ await program.parseAsync([
160
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
161
+ ]);
162
+
163
+ const allOutput = writtenToStdout.join("");
164
+ const lines = allOutput.trim().split("\n");
165
+
166
+ expect(lines).toHaveLength(4);
167
+ expect(lines[0]).toMatch(/^protocol=/);
168
+ expect(lines[1]).toMatch(/^host=/);
169
+ expect(lines[2]).toMatch(/^username=/);
170
+ expect(lines[3]).toMatch(/^password=/);
171
+ });
172
+
173
+ it("exits cleanly for 'store' action without writing credentials", async () => {
174
+ let exitCode: number | undefined;
175
+ // Throw to stop execution after exit() is called — otherwise the mock
176
+ // just sets a flag and the action continues to fetch credentials.
177
+ const exitSpy = jest
178
+ .spyOn(process, "exit")
179
+ .mockImplementation(((code?: number) => {
180
+ exitCode = code ?? 0;
181
+ throw new Error(`process.exit(${exitCode})`);
182
+ }) as any);
183
+
184
+ await expect(
185
+ program.parseAsync(["node", "knowhow", "github-credentials", "store"])
186
+ ).rejects.toThrow("process.exit(0)");
187
+
188
+ expect(exitCode).toBe(0);
189
+ expect(writtenToStdout).toHaveLength(0);
190
+ exitSpy.mockRestore();
191
+ });
192
+
193
+ it("exits cleanly for 'erase' action without writing credentials", async () => {
194
+ let exitCode: number | undefined;
195
+ const exitSpy = jest
196
+ .spyOn(process, "exit")
197
+ .mockImplementation(((code?: number) => {
198
+ exitCode = code ?? 0;
199
+ throw new Error(`process.exit(${exitCode})`);
200
+ }) as any);
201
+
202
+ await expect(
203
+ program.parseAsync(["node", "knowhow", "github-credentials", "erase"])
204
+ ).rejects.toThrow("process.exit(0)");
205
+
206
+ expect(exitCode).toBe(0);
207
+ expect(writtenToStdout).toHaveLength(0);
208
+ exitSpy.mockRestore();
209
+ });
210
+ });
@@ -24,20 +24,27 @@ jest.mock("../../../src/services", () => ({
24
24
  }));
25
25
 
26
26
  import { ModulesService } from "../../../src/services/modules";
27
- import { ModuleContext, KnowhowModule } from "../../../src/services/modules/types";
27
+ import {
28
+ ModuleContext,
29
+ KnowhowModule,
30
+ } from "../../../src/services/modules/types";
28
31
  import { getConfig, getGlobalConfig } from "../../../src/config";
29
32
 
30
33
  const mockGetConfig = getConfig as jest.MockedFunction<typeof getConfig>;
31
- const mockGetGlobalConfig = getGlobalConfig as jest.MockedFunction<typeof getGlobalConfig>;
34
+ const mockGetGlobalConfig = getGlobalConfig as jest.MockedFunction<
35
+ typeof getGlobalConfig
36
+ >;
32
37
 
33
38
  function makeContext(overrides?: Partial<ModuleContext>): ModuleContext {
34
39
  return {
40
+ Events: {
41
+ log: jest.fn(),
42
+ } as any,
35
43
  Agents: {
36
44
  registerAgent: jest.fn(),
37
45
  } as any,
38
46
  Plugins: {
39
47
  registerPlugin: jest.fn(),
40
- loadPluginsFromConfig: jest.fn().mockResolvedValue(undefined),
41
48
  } as any,
42
49
  Clients: {
43
50
  registerClient: jest.fn(),
@@ -87,7 +94,8 @@ describe("ModulesService.loadModulesFromConfig", () => {
87
94
  const context = makeContext();
88
95
 
89
96
  // Mock require used inside loadModulesFromConfig
90
- const requireSpy = jest.spyOn(service as any, "loadModulesFromConfig")
97
+ const requireSpy = jest
98
+ .spyOn(service as any, "loadModulesFromConfig")
91
99
  .mockImplementation(async (ctx?: ModuleContext) => {
92
100
  const resolvedCtx = ctx || context;
93
101
  await mockModule.init({ config: {} as Config, cwd: process.cwd() });
@@ -113,26 +121,35 @@ describe("ModulesService.loadModulesFromConfig", () => {
113
121
  };
114
122
  const mockToolHandler = jest.fn();
115
123
  const mockModule = makeModule({
116
- tools: [{ name: "myTool", handler: mockToolHandler, definition: mockToolDef }],
124
+ tools: [
125
+ { name: "myTool", handler: mockToolHandler, definition: mockToolDef },
126
+ ],
117
127
  });
118
128
 
119
129
  const service = new ModulesService();
120
130
  const context = makeContext();
121
131
 
122
- const spy = jest.spyOn(service as any, "loadModulesFromConfig")
132
+ const spy = jest
133
+ .spyOn(service as any, "loadModulesFromConfig")
123
134
  .mockImplementation(async (ctx?: ModuleContext) => {
124
135
  const resolvedCtx = ctx || context;
125
136
  await mockModule.init({ config: {} as Config, cwd: process.cwd() });
126
137
  for (const tool of mockModule.tools) {
127
138
  resolvedCtx.Tools.addTool(tool.definition);
128
- resolvedCtx.Tools.setFunction(tool.definition.function.name, tool.handler);
139
+ resolvedCtx.Tools.setFunction(
140
+ tool.definition.function.name,
141
+ tool.handler
142
+ );
129
143
  }
130
144
  });
131
145
 
132
146
  await service.loadModulesFromConfig(context);
133
147
 
134
148
  expect(context.Tools.addTool).toHaveBeenCalledWith(mockToolDef);
135
- expect(context.Tools.setFunction).toHaveBeenCalledWith("myTool", mockToolHandler);
149
+ expect(context.Tools.setFunction).toHaveBeenCalledWith(
150
+ "myTool",
151
+ mockToolHandler
152
+ );
136
153
  spy.mockRestore();
137
154
  });
138
155
 
@@ -147,7 +164,9 @@ describe("ModulesService.loadModulesFromConfig", () => {
147
164
  embed: () => Promise.resolve([]),
148
165
  };
149
166
  // ModulePlugin expects a constructor (class), not an instance
150
- const MockPluginClass = jest.fn().mockImplementation(() => mockPluginInstance);
167
+ const MockPluginClass = jest
168
+ .fn()
169
+ .mockImplementation(() => mockPluginInstance);
151
170
  const mockModule = makeModule({
152
171
  plugins: [{ name: "test-plugin", plugin: MockPluginClass as any }],
153
172
  });
@@ -155,7 +174,8 @@ describe("ModulesService.loadModulesFromConfig", () => {
155
174
  const service = new ModulesService();
156
175
  const context = makeContext();
157
176
 
158
- const spy = jest.spyOn(service as any, "loadModulesFromConfig")
177
+ const spy = jest
178
+ .spyOn(service as any, "loadModulesFromConfig")
159
179
  .mockImplementation(async (ctx?: ModuleContext) => {
160
180
  const resolvedCtx = ctx || context;
161
181
  await mockModule.init({ config: {} as Config, cwd: process.cwd() });
@@ -167,36 +187,17 @@ describe("ModulesService.loadModulesFromConfig", () => {
167
187
 
168
188
  await service.loadModulesFromConfig(context);
169
189
 
170
- expect(context.Plugins.registerPlugin).toHaveBeenCalledWith("test-plugin", mockPluginInstance);
190
+ expect(context.Plugins.registerPlugin).toHaveBeenCalledWith(
191
+ "test-plugin",
192
+ mockPluginInstance
193
+ );
171
194
  spy.mockRestore();
172
195
  });
173
196
 
174
- it("should call loadPluginsFromConfig with both global and local configs", async () => {
175
- const localConfig = {
176
- modules: [],
177
- pluginPackages: { asana: "@knowhow/plugin-asana" },
178
- } as unknown as Config;
179
- const globalConfig = {
180
- modules: [],
181
- pluginPackages: { linear: "@knowhow/plugin-linear" },
182
- } as unknown as Config;
183
-
184
- mockGetConfig.mockResolvedValue(localConfig);
185
- mockGetGlobalConfig.mockResolvedValue(globalConfig);
186
-
187
- const service = new ModulesService();
188
- const context = makeContext();
189
-
190
- await service.loadModulesFromConfig(context);
191
-
192
- // pluginService.loadPluginsFromConfig should be called twice: once for local, once for global
193
- expect(context.Plugins.loadPluginsFromConfig).toHaveBeenCalledTimes(2);
194
- expect(context.Plugins.loadPluginsFromConfig).toHaveBeenCalledWith(localConfig);
195
- expect(context.Plugins.loadPluginsFromConfig).toHaveBeenCalledWith(globalConfig);
196
- });
197
-
198
197
  it("should load modules from both global and local config paths", async () => {
199
- const globalModule = makeModule({ agents: [{ name: "GlobalAgent" } as any] });
198
+ const globalModule = makeModule({
199
+ agents: [{ name: "GlobalAgent" } as any],
200
+ });
200
201
  const localModule = makeModule({ agents: [{ name: "LocalAgent" } as any] });
201
202
 
202
203
  mockGetConfig.mockResolvedValue({
@@ -210,7 +211,8 @@ describe("ModulesService.loadModulesFromConfig", () => {
210
211
  const context = makeContext();
211
212
 
212
213
  const loadedPaths: string[] = [];
213
- const spy = jest.spyOn(service as any, "loadModulesFromConfig")
214
+ const spy = jest
215
+ .spyOn(service as any, "loadModulesFromConfig")
214
216
  .mockImplementation(async (ctx?: ModuleContext) => {
215
217
  const resolvedCtx = ctx || context;
216
218
  for (const [path, mod] of [