@tyvm/knowhow 0.0.137 → 0.0.140

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 (173) hide show
  1. package/autodoc/chat-guide.md +1 -0
  2. package/package.json +5 -2
  3. package/scripts/build-for-node.sh +4 -4
  4. package/src/agents/base/base.ts +254 -29
  5. package/src/agents/patcher/patcher.ts +1 -1
  6. package/src/agents/researcher/researcher.ts +2 -2
  7. package/src/agents/tools/agentCall.ts +6 -0
  8. package/src/agents/tools/connectAgent.ts +353 -0
  9. package/src/agents/tools/index.ts +6 -0
  10. package/src/agents/tools/list.ts +48 -0
  11. package/src/agents/tools/observe.ts +245 -0
  12. package/src/agents/tools/replyToParent.ts +122 -0
  13. package/src/agents/tools/runGenerate.ts +209 -0
  14. package/src/agents/tools/sendAgentMessage.ts +138 -0
  15. package/src/agents/tools/startAgentTask.ts +92 -5
  16. package/src/agents/tools/waitForAgentCompleted.ts +269 -0
  17. package/src/ai.ts +1 -0
  18. package/src/chat/modules/AgentModule.ts +89 -27
  19. package/src/chat/modules/SessionsModule.ts +6 -10
  20. package/src/clients/contextLimits.ts +26 -0
  21. package/src/clients/pricing/anthropic.ts +12 -0
  22. package/src/commands/agent.ts +22 -0
  23. package/src/commands/agents.ts +83 -0
  24. package/src/commands/hash.ts +3 -2
  25. package/src/commands/misc.ts +6 -0
  26. package/src/commands/services.ts +14 -0
  27. package/src/generate.ts +25 -3
  28. package/src/services/AgentService.ts +21 -3
  29. package/src/services/AgentSyncFs.ts +56 -0
  30. package/src/services/AgentSyncKnowhowWeb.ts +1 -1
  31. package/src/services/EventService.ts +4 -1
  32. package/src/services/KnowhowClient.ts +7 -0
  33. package/src/services/LazyToolsService.ts +7 -3
  34. package/src/services/MinimalToolsService.ts +15 -7
  35. package/src/services/SyncedAgentWatcher.ts +2 -0
  36. package/src/services/SyncerService.ts +35 -6
  37. package/src/services/Tools.ts +43 -3
  38. package/src/services/modules/index.ts +127 -46
  39. package/src/types.ts +5 -0
  40. package/tests/agents/interrupt.test.ts +11 -18
  41. package/tests/agents/observe-base.test.ts +183 -0
  42. package/tests/agents/tools/connectAgent.test.ts +138 -0
  43. package/tests/agents/tools/observe.test.ts +179 -0
  44. package/tests/agents/tools/replyToParent.test.ts +125 -0
  45. package/tests/agents/tools/sendAgentMessage.test.ts +99 -0
  46. package/tests/agents/tools/waitForAgentCompleted.test.ts +147 -0
  47. package/tests/chat/detach-attach-rendering.test.ts +315 -0
  48. package/tests/clients/contextLimits.test.ts +23 -1
  49. package/tests/clients/pricing.test.ts +20 -1
  50. package/tests/services/Tools.test.ts +172 -9
  51. package/tests/unit/modules/moduleLoading.test.ts +69 -0
  52. package/ts_build/package.json +5 -2
  53. package/ts_build/src/agents/base/base.d.ts +24 -1
  54. package/ts_build/src/agents/base/base.js +111 -16
  55. package/ts_build/src/agents/base/base.js.map +1 -1
  56. package/ts_build/src/agents/patcher/patcher.js +1 -1
  57. package/ts_build/src/agents/patcher/patcher.js.map +1 -1
  58. package/ts_build/src/agents/researcher/researcher.js +2 -2
  59. package/ts_build/src/agents/researcher/researcher.js.map +1 -1
  60. package/ts_build/src/agents/tools/agentCall.js.map +1 -1
  61. package/ts_build/src/agents/tools/connectAgent.d.ts +21 -0
  62. package/ts_build/src/agents/tools/connectAgent.js +284 -0
  63. package/ts_build/src/agents/tools/connectAgent.js.map +1 -0
  64. package/ts_build/src/agents/tools/index.d.ts +6 -0
  65. package/ts_build/src/agents/tools/index.js +6 -0
  66. package/ts_build/src/agents/tools/index.js.map +1 -1
  67. package/ts_build/src/agents/tools/list.js +40 -0
  68. package/ts_build/src/agents/tools/list.js.map +1 -1
  69. package/ts_build/src/agents/tools/observe.d.ts +20 -0
  70. package/ts_build/src/agents/tools/observe.js +144 -0
  71. package/ts_build/src/agents/tools/observe.js.map +1 -0
  72. package/ts_build/src/agents/tools/replyToParent.d.ts +4 -0
  73. package/ts_build/src/agents/tools/replyToParent.js +118 -0
  74. package/ts_build/src/agents/tools/replyToParent.js.map +1 -0
  75. package/ts_build/src/agents/tools/runGenerate.d.ts +19 -0
  76. package/ts_build/src/agents/tools/runGenerate.js +138 -0
  77. package/ts_build/src/agents/tools/runGenerate.js.map +1 -0
  78. package/ts_build/src/agents/tools/sendAgentMessage.d.ts +11 -0
  79. package/ts_build/src/agents/tools/sendAgentMessage.js +118 -0
  80. package/ts_build/src/agents/tools/sendAgentMessage.js.map +1 -0
  81. package/ts_build/src/agents/tools/startAgentTask.d.ts +7 -0
  82. package/ts_build/src/agents/tools/startAgentTask.js +53 -6
  83. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  84. package/ts_build/src/agents/tools/waitForAgentCompleted.d.ts +10 -0
  85. package/ts_build/src/agents/tools/waitForAgentCompleted.js +229 -0
  86. package/ts_build/src/agents/tools/waitForAgentCompleted.js.map +1 -0
  87. package/ts_build/src/ai.d.ts +1 -0
  88. package/ts_build/src/ai.js.map +1 -1
  89. package/ts_build/src/chat/modules/AgentModule.d.ts +3 -1
  90. package/ts_build/src/chat/modules/AgentModule.js +46 -21
  91. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  92. package/ts_build/src/chat/modules/SessionsModule.js +1 -6
  93. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  94. package/ts_build/src/clients/contextLimits.js +21 -0
  95. package/ts_build/src/clients/contextLimits.js.map +1 -1
  96. package/ts_build/src/clients/pricing/anthropic.d.ts +2 -0
  97. package/ts_build/src/clients/pricing/anthropic.js +9 -0
  98. package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
  99. package/ts_build/src/commands/agent.js +11 -0
  100. package/ts_build/src/commands/agent.js.map +1 -1
  101. package/ts_build/src/commands/agents.js +61 -0
  102. package/ts_build/src/commands/agents.js.map +1 -1
  103. package/ts_build/src/commands/hash.js +1 -1
  104. package/ts_build/src/commands/hash.js.map +1 -1
  105. package/ts_build/src/commands/misc.js +2 -0
  106. package/ts_build/src/commands/misc.js.map +1 -1
  107. package/ts_build/src/commands/services.js +10 -0
  108. package/ts_build/src/commands/services.js.map +1 -1
  109. package/ts_build/src/generate.d.ts +2 -0
  110. package/ts_build/src/generate.js +11 -3
  111. package/ts_build/src/generate.js.map +1 -1
  112. package/ts_build/src/services/AgentService.d.ts +4 -1
  113. package/ts_build/src/services/AgentService.js +9 -2
  114. package/ts_build/src/services/AgentService.js.map +1 -1
  115. package/ts_build/src/services/AgentSyncFs.d.ts +2 -0
  116. package/ts_build/src/services/AgentSyncFs.js +35 -0
  117. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  118. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -1
  119. package/ts_build/src/services/EventService.d.ts +4 -1
  120. package/ts_build/src/services/EventService.js.map +1 -1
  121. package/ts_build/src/services/KnowhowClient.js +3 -0
  122. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  123. package/ts_build/src/services/LazyToolsService.d.ts +2 -2
  124. package/ts_build/src/services/LazyToolsService.js +2 -2
  125. package/ts_build/src/services/LazyToolsService.js.map +1 -1
  126. package/ts_build/src/services/MinimalToolsService.d.ts +2 -2
  127. package/ts_build/src/services/MinimalToolsService.js +6 -6
  128. package/ts_build/src/services/MinimalToolsService.js.map +1 -1
  129. package/ts_build/src/services/SyncedAgentWatcher.d.ts +1 -0
  130. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  131. package/ts_build/src/services/SyncerService.d.ts +2 -1
  132. package/ts_build/src/services/SyncerService.js +8 -2
  133. package/ts_build/src/services/SyncerService.js.map +1 -1
  134. package/ts_build/src/services/Tools.d.ts +6 -1
  135. package/ts_build/src/services/Tools.js +9 -3
  136. package/ts_build/src/services/Tools.js.map +1 -1
  137. package/ts_build/src/services/modules/index.d.ts +1 -1
  138. package/ts_build/src/services/modules/index.js +67 -27
  139. package/ts_build/src/services/modules/index.js.map +1 -1
  140. package/ts_build/src/types.d.ts +3 -0
  141. package/ts_build/src/types.js.map +1 -1
  142. package/ts_build/tests/agents/interrupt.test.d.ts +0 -1
  143. package/ts_build/tests/agents/interrupt.test.js +3 -13
  144. package/ts_build/tests/agents/interrupt.test.js.map +1 -1
  145. package/ts_build/tests/agents/observe-base.test.d.ts +1 -0
  146. package/ts_build/tests/agents/observe-base.test.js +149 -0
  147. package/ts_build/tests/agents/observe-base.test.js.map +1 -0
  148. package/ts_build/tests/agents/tools/connectAgent.test.d.ts +1 -0
  149. package/ts_build/tests/agents/tools/connectAgent.test.js +142 -0
  150. package/ts_build/tests/agents/tools/connectAgent.test.js.map +1 -0
  151. package/ts_build/tests/agents/tools/observe.test.d.ts +1 -0
  152. package/ts_build/tests/agents/tools/observe.test.js +148 -0
  153. package/ts_build/tests/agents/tools/observe.test.js.map +1 -0
  154. package/ts_build/tests/agents/tools/replyToParent.test.d.ts +1 -0
  155. package/ts_build/tests/agents/tools/replyToParent.test.js +116 -0
  156. package/ts_build/tests/agents/tools/replyToParent.test.js.map +1 -0
  157. package/ts_build/tests/agents/tools/sendAgentMessage.test.d.ts +1 -0
  158. package/ts_build/tests/agents/tools/sendAgentMessage.test.js +112 -0
  159. package/ts_build/tests/agents/tools/sendAgentMessage.test.js.map +1 -0
  160. package/ts_build/tests/agents/tools/waitForAgentCompleted.test.d.ts +1 -0
  161. package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js +147 -0
  162. package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js.map +1 -0
  163. package/ts_build/tests/chat/detach-attach-rendering.test.d.ts +1 -0
  164. package/ts_build/tests/chat/detach-attach-rendering.test.js +168 -0
  165. package/ts_build/tests/chat/detach-attach-rendering.test.js.map +1 -0
  166. package/ts_build/tests/clients/contextLimits.test.js +11 -1
  167. package/ts_build/tests/clients/contextLimits.test.js.map +1 -1
  168. package/ts_build/tests/clients/pricing.test.js +10 -1
  169. package/ts_build/tests/clients/pricing.test.js.map +1 -1
  170. package/ts_build/tests/services/Tools.test.js +126 -8
  171. package/ts_build/tests/services/Tools.test.js.map +1 -1
  172. package/ts_build/tests/unit/modules/moduleLoading.test.js +84 -0
  173. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
@@ -0,0 +1,315 @@
1
+
2
+ /**
3
+ * Tests for detach / re-attach rendering correctness.
4
+ *
5
+ * Bug: after calling /detach and then /attach again, the agent's tool calls
6
+ * and messages were not rendering to the console.
7
+ *
8
+ * Root cause: both SessionsModule.attachToFsAgent / attachToWebAgent AND
9
+ * AgentModule.attachedAgentChatLoop were calling wireAgentRendering().
10
+ * Since wireAgentRendering uses setListener (keyed, replaces-on-conflict),
11
+ * the second call on the SAME EventService replaced the first — but the
12
+ * watcher's `done` event handler registered by SessionsModule was also
13
+ * removed. More critically: after /detach, unwireAgentRendering() removes
14
+ * the keyed listeners from `_wireAgentEvents` and clears the stored ref.
15
+ * On re-attach a new FsSyncedAgentWatcher is created with a fresh
16
+ * EventService, but the double-wire means the stored `_wireAgentEvents`
17
+ * ref held by the AgentModule was pointing at the WRONG EventService
18
+ * (the one from the second wireAgentRendering call).
19
+ *
20
+ * The fix: SessionsModule no longer calls wireAgentRendering itself —
21
+ * it delegates entirely to attachedAgentChatLoop which already calls it.
22
+ *
23
+ * These tests verify the wireAgentRendering / unwireAgentRendering contract
24
+ * using real EventService instances so we catch regressions in EventService
25
+ * changes too.
26
+ */
27
+
28
+ import { EventService } from "../../src/services/EventService";
29
+
30
+ // ─── Minimal harness that mirrors AgentModule's wire/unwire logic exactly ────
31
+
32
+ const EVENT_TYPES = {
33
+ done: "done",
34
+ toolCall: "tool:pre_call",
35
+ toolUsed: "tool:post_call",
36
+ agentSay: "agent:say",
37
+ threadUpdate: "thread_update",
38
+ };
39
+
40
+ interface RenderEvent {
41
+ type: string;
42
+ taskId: string;
43
+ agentName: string;
44
+ data: any;
45
+ }
46
+
47
+ class RenderingHarness {
48
+ // Mirror of AgentModule's stored wire params
49
+ private _wireAgentEvents: EventService | undefined;
50
+ private _wireTaskId: string | undefined;
51
+ private _wireAgentName: string | undefined;
52
+ private _wireEventTypes:
53
+ | { toolCall?: string; toolUsed?: string; agentSay?: string; done: string }
54
+ | undefined;
55
+
56
+ public rendered: RenderEvent[] = [];
57
+
58
+ /**
59
+ * Mirrors AgentModule.wireAgentRendering exactly (uses real EventService.setListener).
60
+ */
61
+ wireAgentRendering(
62
+ taskId: string,
63
+ agentEvents: EventService,
64
+ eventTypes: { toolCall?: string; toolUsed?: string; agentSay?: string; done: string },
65
+ agentName: string
66
+ ): void {
67
+ if (eventTypes.toolCall) {
68
+ agentEvents.setListener(
69
+ { key: "agentModule:render:toolCall", event: eventTypes.toolCall },
70
+ (data: any) => this.rendered.push({ type: "toolCall", taskId, agentName, data })
71
+ );
72
+ }
73
+ if (eventTypes.toolUsed) {
74
+ agentEvents.setListener(
75
+ { key: "agentModule:render:toolUsed", event: eventTypes.toolUsed },
76
+ (data: any) => this.rendered.push({ type: "toolResult", taskId, agentName, data })
77
+ );
78
+ }
79
+ if (eventTypes.agentSay) {
80
+ agentEvents.setListener(
81
+ { key: "agentModule:render:agentSay", event: eventTypes.agentSay },
82
+ (data: any) => this.rendered.push({ type: "agentMessage", taskId, agentName, data })
83
+ );
84
+ }
85
+ this._wireAgentEvents = agentEvents;
86
+ this._wireTaskId = taskId;
87
+ this._wireAgentName = agentName;
88
+ this._wireEventTypes = eventTypes;
89
+ }
90
+
91
+ /**
92
+ * Mirrors AgentModule.unwireAgentRendering exactly.
93
+ */
94
+ unwireAgentRendering(): void {
95
+ if (this._wireAgentEvents) {
96
+ this._wireAgentEvents.removeManagedListenersByPrefix("agentModule:render:");
97
+ this._wireAgentEvents = undefined;
98
+ }
99
+ this._wireTaskId = undefined;
100
+ this._wireAgentName = undefined;
101
+ this._wireEventTypes = undefined;
102
+ }
103
+
104
+ reset() {
105
+ this.rendered = [];
106
+ }
107
+ }
108
+
109
+ // ─── Tests ────────────────────────────────────────────────────────────────────
110
+
111
+ describe("detach / re-attach rendering", () => {
112
+ let harness: RenderingHarness;
113
+
114
+ beforeEach(() => {
115
+ harness = new RenderingHarness();
116
+ });
117
+
118
+ // 1. Basic: emitted events reach the renderer after wireAgentRendering ────────
119
+
120
+ it("emitted events reach the renderer after wireAgentRendering", () => {
121
+ const events = new EventService();
122
+ harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev");
123
+
124
+ events.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "execCommand" } } });
125
+ events.emit(EVENT_TYPES.agentSay, { message: "hello" });
126
+ events.emit(EVENT_TYPES.toolUsed, { toolCall: {}, functionResp: "output" });
127
+
128
+ expect(harness.rendered).toHaveLength(3);
129
+ expect(harness.rendered[0].type).toBe("toolCall");
130
+ expect(harness.rendered[1].type).toBe("agentMessage");
131
+ expect(harness.rendered[2].type).toBe("toolResult");
132
+ });
133
+
134
+ // 2. After detach (unwire), old EventService no longer triggers renders ───────
135
+
136
+ it("old EventService does NOT render after unwireAgentRendering (detach)", () => {
137
+ const events = new EventService();
138
+ harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev");
139
+
140
+ // Verify wired first
141
+ events.emit(EVENT_TYPES.toolCall, { toolCall: {} });
142
+ expect(harness.rendered).toHaveLength(1);
143
+ harness.reset();
144
+
145
+ // Detach
146
+ harness.unwireAgentRendering();
147
+
148
+ // Emit on the OLD EventService — nothing should render
149
+ events.emit(EVENT_TYPES.toolCall, { toolCall: {} });
150
+ events.emit(EVENT_TYPES.agentSay, { message: "zombie" });
151
+ events.emit(EVENT_TYPES.toolUsed, { toolCall: {}, functionResp: "output" });
152
+
153
+ expect(harness.rendered).toHaveLength(0);
154
+ });
155
+
156
+ // 3. Re-attach on a NEW EventService renders correctly ───────────────────────
157
+
158
+ it("events on a NEW EventService render after re-wiring (re-attach)", () => {
159
+ const oldEvents = new EventService();
160
+ harness.wireAgentRendering("task-1", oldEvents, EVENT_TYPES, "Dev");
161
+ harness.unwireAgentRendering();
162
+
163
+ // Re-attach: new watcher = new EventService
164
+ const newEvents = new EventService();
165
+ harness.wireAgentRendering("task-1", newEvents, EVENT_TYPES, "Dev");
166
+
167
+ newEvents.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "readFile" } } });
168
+ newEvents.emit(EVENT_TYPES.agentSay, { message: "re-attached message" });
169
+
170
+ expect(harness.rendered).toHaveLength(2);
171
+ expect(harness.rendered[0].type).toBe("toolCall");
172
+ expect(harness.rendered[0].data.toolCall.function.name).toBe("readFile");
173
+ expect(harness.rendered[1].type).toBe("agentMessage");
174
+ expect(harness.rendered[1].data.message).toBe("re-attached message");
175
+ });
176
+
177
+ // 4. Old EventService still silent after re-attach on new EventService ────────
178
+
179
+ it("old EventService stays silent after re-attach on new EventService", () => {
180
+ const oldEvents = new EventService();
181
+ harness.wireAgentRendering("task-1", oldEvents, EVENT_TYPES, "Dev");
182
+ harness.unwireAgentRendering();
183
+
184
+ const newEvents = new EventService();
185
+ harness.wireAgentRendering("task-1", newEvents, EVENT_TYPES, "Dev");
186
+
187
+ // New events render
188
+ newEvents.emit(EVENT_TYPES.agentSay, { message: "new" });
189
+ expect(harness.rendered).toHaveLength(1);
190
+
191
+ // Old events still silent
192
+ oldEvents.emit(EVENT_TYPES.toolCall, { toolCall: {} });
193
+ oldEvents.emit(EVENT_TYPES.agentSay, { message: "old" });
194
+ expect(harness.rendered).toHaveLength(1); // unchanged
195
+ });
196
+
197
+ // 5. Double-wire on the SAME EventService does NOT double-render ──────────────
198
+ //
199
+ // This is the bug that existed: SessionsModule.attachToFsAgent called
200
+ // wireAgentRendering, then attachedAgentChatLoop also called it.
201
+ // Since setListener is keyed (replaces), the net result is one listener —
202
+ // but the done handler registered by SessionsModule was lost.
203
+ // The fix removes the extra wireAgentRendering from SessionsModule.
204
+
205
+ it("calling wireAgentRendering twice on same EventService does NOT double-render", () => {
206
+ const events = new EventService();
207
+
208
+ // Simulate the OLD (buggy) behavior: SessionsModule wires, then attachedAgentChatLoop wires again
209
+ harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev"); // first call
210
+ harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev"); // second call (setListener replaces)
211
+
212
+ events.emit(EVENT_TYPES.toolCall, { toolCall: {} });
213
+
214
+ // setListener keyed — only ONE render expected, not two
215
+ expect(harness.rendered).toHaveLength(1);
216
+ });
217
+
218
+ // 6. Full cycle: attach → emit → detach → re-attach → emit all render correctly
219
+
220
+ it("full attach → detach → re-attach cycle: all phases render correctly", () => {
221
+ // ── First attach ──────────────────────────────────────────────────────────
222
+ const watcher1 = new EventService();
223
+ harness.wireAgentRendering("task-A", watcher1, EVENT_TYPES, "Developer");
224
+
225
+ watcher1.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "execCommand" } } });
226
+ watcher1.emit(EVENT_TYPES.agentSay, { message: "initial work" });
227
+ expect(harness.rendered).toHaveLength(2);
228
+ harness.reset();
229
+
230
+ // ── /detach ───────────────────────────────────────────────────────────────
231
+ harness.unwireAgentRendering();
232
+
233
+ // Events from watcher1 after detach should NOT render
234
+ watcher1.emit(EVENT_TYPES.toolCall, { toolCall: {} });
235
+ watcher1.emit(EVENT_TYPES.agentSay, { message: "zombie message" });
236
+ expect(harness.rendered).toHaveLength(0);
237
+
238
+ // ── Re-attach (new FsSyncedAgentWatcher creates a new EventService) ───────
239
+ const watcher2 = new EventService();
240
+ harness.wireAgentRendering("task-A", watcher2, EVENT_TYPES, "Developer");
241
+
242
+ watcher2.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "readFile" } } });
243
+ watcher2.emit(EVENT_TYPES.agentSay, { message: "continuing after re-attach" });
244
+
245
+ // THE CRITICAL ASSERTION: renders must arrive after re-attach
246
+ expect(harness.rendered).toHaveLength(2);
247
+ expect(harness.rendered[0].type).toBe("toolCall");
248
+ expect(harness.rendered[0].data.toolCall.function.name).toBe("readFile");
249
+ expect(harness.rendered[1].type).toBe("agentMessage");
250
+ expect(harness.rendered[1].data.message).toBe("continuing after re-attach");
251
+ });
252
+
253
+ // 7. toolResult renders after re-attach ──────────────────────────────────────
254
+
255
+ it("toolResult events render after re-attach", () => {
256
+ const events1 = new EventService();
257
+ harness.wireAgentRendering("task-1", events1, EVENT_TYPES, "Dev");
258
+ harness.unwireAgentRendering();
259
+
260
+ const events2 = new EventService();
261
+ harness.wireAgentRendering("task-1", events2, EVENT_TYPES, "Dev");
262
+
263
+ events2.emit(EVENT_TYPES.toolUsed, {
264
+ toolCall: { function: { name: "execCommand" } },
265
+ functionResp: "$ echo hello\nhello",
266
+ });
267
+
268
+ expect(harness.rendered).toHaveLength(1);
269
+ expect(harness.rendered[0].type).toBe("toolResult");
270
+ expect(harness.rendered[0].data.functionResp).toBe("$ echo hello\nhello");
271
+ });
272
+
273
+ // 8. Multiple sequential attach/detach cycles all work ───────────────────────
274
+
275
+ it("3 sequential attach/detach cycles all render correctly", () => {
276
+ for (let i = 0; i < 3; i++) {
277
+ harness.reset();
278
+ const events = new EventService();
279
+ harness.wireAgentRendering(`task-${i}`, events, EVENT_TYPES, "Dev");
280
+
281
+ events.emit(EVENT_TYPES.agentSay, { message: `cycle ${i}` });
282
+ expect(harness.rendered).toHaveLength(1);
283
+ expect(harness.rendered[0].data.message).toBe(`cycle ${i}`);
284
+ expect(harness.rendered[0].taskId).toBe(`task-${i}`);
285
+
286
+ // Detach: old events stop rendering
287
+ harness.unwireAgentRendering();
288
+ events.emit(EVENT_TYPES.agentSay, { message: "should not render" });
289
+ expect(harness.rendered).toHaveLength(1); // still 1, not 2
290
+ }
291
+ });
292
+
293
+ // 9. hasManagedListener confirms listener is present/absent at right times ───
294
+
295
+ it("hasManagedListener reflects wired/unwired state correctly", () => {
296
+ const events = new EventService();
297
+
298
+ // Before wiring: no managed render listeners
299
+ expect(events.hasManagedListener("agentModule:render:toolCall")).toBe(false);
300
+ expect(events.hasManagedListener("agentModule:render:agentSay")).toBe(false);
301
+
302
+ harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev");
303
+
304
+ // After wiring: listeners registered
305
+ expect(events.hasManagedListener("agentModule:render:toolCall")).toBe(true);
306
+ expect(events.hasManagedListener("agentModule:render:toolUsed")).toBe(true);
307
+ expect(events.hasManagedListener("agentModule:render:agentSay")).toBe(true);
308
+
309
+ harness.unwireAgentRendering();
310
+
311
+ // After detach: listeners removed
312
+ expect(events.hasManagedListener("agentModule:render:toolCall")).toBe(false);
313
+ expect(events.hasManagedListener("agentModule:render:agentSay")).toBe(false);
314
+ });
315
+ });
@@ -4,6 +4,27 @@
4
4
  */
5
5
  import { Models } from "../../src/types";
6
6
  import { ContextLimits, getModelContextLimit, DEFAULT_CONTEXT_LIMIT } from "../../src/clients/contextLimits";
7
+ import {
8
+ OpenAiTextPricing,
9
+ GeminiTextPricing,
10
+ AnthropicTextPricing,
11
+ XaiTextPricing,
12
+ } from "../../src/clients/pricing";
13
+
14
+ // Model ids explicitly marked deprecated across all pricing tables. Deprecated
15
+ // models are allowed to be absent from ContextLimits — they can be trimmed from
16
+ // Models.* / the context-limit map without breaking anything, since cost
17
+ // tracking reads the *TextPricing tables directly by model id.
18
+ const deprecatedModelIds = new Set<string>(
19
+ Object.entries({
20
+ ...OpenAiTextPricing,
21
+ ...GeminiTextPricing,
22
+ ...AnthropicTextPricing,
23
+ ...XaiTextPricing,
24
+ } as Record<string, { deprecated?: boolean }>)
25
+ .filter(([, p]) => p && p.deprecated === true)
26
+ .map(([id]) => id)
27
+ );
7
28
 
8
29
  describe("ContextLimits", () => {
9
30
  describe("coverage - all Models.* values have a recorded context limit", () => {
@@ -19,7 +40,8 @@ describe("ContextLimits", () => {
19
40
  });
20
41
 
21
42
  for (const [key, modelId] of modelEntries) {
22
- it(`${provider}.${key} (${modelId}) should have a context limit`, () => {
43
+ const testFn = deprecatedModelIds.has(modelId) ? it.skip : it;
44
+ testFn(`${provider}.${key} (${modelId}) should have a context limit`, () => {
23
45
  const limit = ContextLimits[modelId];
24
46
  expect(limit).toBeDefined();
25
47
  expect(typeof limit).toBe("number");
@@ -150,15 +150,34 @@ describe("Model Catalog Coverage", () => {
150
150
  * Every model defined in Models.* and EmbeddingModels.* must have an entry
151
151
  * in ALL_MODEL_CATALOG. This ensures the catalog stays in sync and is the
152
152
  * single source of truth for model metadata and pricing.
153
+ *
154
+ * Exception: models flagged `deprecated` in their pricing entry are allowed to
155
+ * be absent from the catalog. Cost tracking for historical usage still works
156
+ * because calculateCost() reads the *TextPricing tables directly by model id,
157
+ * independently of the catalog — so deprecated models can be trimmed from the
158
+ * catalog/Models.* without breaking cost calculation.
153
159
  */
154
160
  const catalogIds = new Set(ALL_MODEL_CATALOG.map((m) => m.id));
155
161
 
162
+ // Model ids explicitly marked deprecated across all pricing tables.
163
+ const deprecatedModelIds = new Set<string>(
164
+ Object.entries({
165
+ ...OpenAiTextPricing,
166
+ ...GeminiTextPricing,
167
+ ...AnthropicTextPricing,
168
+ ...XaiTextPricing,
169
+ } as Record<string, { deprecated?: boolean }>)
170
+ .filter(([, p]) => p && p.deprecated === true)
171
+ .map(([id]) => id)
172
+ );
173
+
156
174
  describe("All Models.* entries are in the catalog", () => {
157
175
  for (const [provider, providerModels] of Object.entries(Models)) {
158
176
  for (const [modelKey, modelId] of Object.entries(
159
177
  providerModels as Record<string, string>
160
178
  )) {
161
- it(`Models.${provider}.${modelKey} (${modelId}) is in ALL_MODEL_CATALOG`, () => {
179
+ const testFn = deprecatedModelIds.has(modelId) ? it.skip : it;
180
+ testFn(`Models.${provider}.${modelKey} (${modelId}) is in ALL_MODEL_CATALOG`, () => {
162
181
  expect(catalogIds.has(modelId)).toBe(true);
163
182
  });
164
183
  }
@@ -1,4 +1,4 @@
1
- import { ToolsService, ToolContext } from "../../src/services/Tools";
1
+ import { ToolsService, ToolContext, ToolCallContext } from "../../src/services/Tools";
2
2
  import { Tool, ToolCall } from "../../src/clients/types";
3
3
  import { createPatternMatcher } from "../../src/services/types";
4
4
  import { AgentService } from "../../src/services/AgentService";
@@ -268,7 +268,11 @@ describe("ToolsService", () => {
268
268
 
269
269
  const result = await toolsService.callTool(mockToolCall);
270
270
 
271
- expect(mockFunction).toHaveBeenCalledWith({ input: "test value" });
271
+ // Non-positional tools receive `_ctx` merged onto the args object.
272
+ expect(mockFunction).toHaveBeenCalledWith(
273
+ expect.objectContaining({ input: "test value" })
274
+ );
275
+ expect(mockFunction.mock.calls[0][0]._ctx).toBeDefined();
272
276
  expect(result.toolMessages).toHaveLength(1);
273
277
  expect(result.toolMessages[0]).toEqual({
274
278
  tool_call_id: "call_123",
@@ -312,7 +316,12 @@ describe("ToolsService", () => {
312
316
 
313
317
  await toolsService.callTool(positionalCall);
314
318
 
315
- expect(mockFunction).toHaveBeenCalledWith("hello", 42);
319
+ // Positional tools receive `_ctx` as the LAST positional argument.
320
+ expect(mockFunction).toHaveBeenCalledWith(
321
+ "hello",
322
+ 42,
323
+ expect.objectContaining({ Tools: expect.anything() })
324
+ );
316
325
  });
317
326
 
318
327
  it("should handle tool not enabled error", async () => {
@@ -433,7 +442,10 @@ describe("ToolsService", () => {
433
442
 
434
443
  await toolsService.callTool(toolCallWithStringArgs);
435
444
 
436
- expect(mockFunction).toHaveBeenCalledWith({ input: "escaped\nstring" });
445
+ expect(mockFunction).toHaveBeenCalledWith(
446
+ expect.objectContaining({ input: "escaped\nstring" })
447
+ );
448
+ expect(mockFunction.mock.calls[0][0]._ctx).toBeDefined();
437
449
  });
438
450
  });
439
451
  describe("Tool Override System", () => {
@@ -492,8 +504,9 @@ describe("ToolsService", () => {
492
504
 
493
505
  const result = await toolsService.callTool(toolCall);
494
506
 
507
+ // Non-positional args now include `_ctx`; the override receives them in an array.
495
508
  expect(overrideFunction).toHaveBeenCalledWith(
496
- [{ input: "test" }],
509
+ [expect.objectContaining({ input: "test" })],
497
510
  expect.any(Object)
498
511
  );
499
512
  expect(originalFunction).not.toHaveBeenCalled();
@@ -627,7 +640,9 @@ describe("ToolsService", () => {
627
640
  const result = await toolsService.callTool(toolCall);
628
641
 
629
642
  expect(wrapperFunction).toHaveBeenCalled();
630
- expect(originalFunction).toHaveBeenCalledWith({ input: "test" });
643
+ expect(originalFunction).toHaveBeenCalledWith(
644
+ expect.objectContaining({ input: "test" })
645
+ );
631
646
  expect(result.functionResp).toBe("wrapped: original result");
632
647
  });
633
648
 
@@ -841,7 +856,8 @@ describe("ToolsService", () => {
841
856
 
842
857
  const result = await toolsService.callTool(noParamCall);
843
858
 
844
- expect(mockFunction).toHaveBeenCalledWith({});
859
+ expect(mockFunction).toHaveBeenCalledWith(expect.any(Object));
860
+ expect(mockFunction.mock.calls[0][0]._ctx).toBeDefined();
845
861
  expect(result.functionResp).toBe("no param result");
846
862
  });
847
863
 
@@ -897,14 +913,16 @@ describe("ToolsService", () => {
897
913
 
898
914
  const result = await toolsService.callTool(complexCall);
899
915
 
900
- expect(mockFunction).toHaveBeenCalledWith({
916
+ expect(mockFunction).toHaveBeenCalledWith(
917
+ expect.objectContaining({
901
918
  config: {
902
919
  settings: [
903
920
  { key: "timeout", value: 5000 },
904
921
  { key: "retries", value: 3 },
905
922
  ],
906
923
  },
907
- });
924
+ })
925
+ );
908
926
  expect(result.functionResp).toBe("complex result");
909
927
  });
910
928
 
@@ -1570,4 +1588,149 @@ describe("ToolsService", () => {
1570
1588
  expect(foundTools.map(t => t.function.name)).toContain("tool2");
1571
1589
  });
1572
1590
  });
1591
+
1592
+ describe("_ctx per-call context threading", () => {
1593
+ const positionalTool: Tool = {
1594
+ type: "function",
1595
+ function: {
1596
+ name: "posCtxTool",
1597
+ parameters: {
1598
+ type: "object",
1599
+ positional: true,
1600
+ properties: {
1601
+ arg1: { type: "string" },
1602
+ },
1603
+ required: ["arg1"],
1604
+ },
1605
+ },
1606
+ };
1607
+
1608
+ const nonPositionalTool: Tool = {
1609
+ type: "function",
1610
+ function: {
1611
+ name: "nonPosCtxTool",
1612
+ parameters: {
1613
+ type: "object",
1614
+ properties: {
1615
+ input: { type: "string" },
1616
+ },
1617
+ required: ["input"],
1618
+ },
1619
+ },
1620
+ };
1621
+
1622
+ it("passes _ctx as the last positional argument for positional tools", async () => {
1623
+ const fn = jest.fn().mockResolvedValue("ok");
1624
+ toolsService.addTool(positionalTool);
1625
+ toolsService.setFunction("posCtxTool", fn);
1626
+
1627
+ const caller = { name: "TestAgent" };
1628
+ await toolsService.callTool(
1629
+ {
1630
+ id: "c1",
1631
+ type: "function",
1632
+ function: {
1633
+ name: "posCtxTool",
1634
+ arguments: JSON.stringify({ arg1: "hello" }),
1635
+ },
1636
+ },
1637
+ undefined,
1638
+ { caller, taskId: "task-123" }
1639
+ );
1640
+
1641
+ const callArgs = fn.mock.calls[0];
1642
+ expect(callArgs[0]).toBe("hello");
1643
+ const ctx = callArgs[1] as ToolCallContext;
1644
+ expect(ctx).toBeDefined();
1645
+ expect(ctx.caller).toBe(caller);
1646
+ expect(ctx.taskId).toBe("task-123");
1647
+ expect(ctx.Tools).toBe(toolsService);
1648
+ });
1649
+
1650
+ it("merges _ctx onto the args object for non-positional tools", async () => {
1651
+ const fn = jest.fn().mockResolvedValue("ok");
1652
+ toolsService.addTool(nonPositionalTool);
1653
+ toolsService.setFunction("nonPosCtxTool", fn);
1654
+
1655
+ const caller = { name: "TestAgent" };
1656
+ await toolsService.callTool(
1657
+ {
1658
+ id: "c2",
1659
+ type: "function",
1660
+ function: {
1661
+ name: "nonPosCtxTool",
1662
+ arguments: JSON.stringify({ input: "value" }),
1663
+ },
1664
+ },
1665
+ undefined,
1666
+ { caller, taskId: "task-456" }
1667
+ );
1668
+
1669
+ const argObj = fn.mock.calls[0][0];
1670
+ expect(argObj.input).toBe("value");
1671
+ expect(argObj._ctx).toBeDefined();
1672
+ expect(argObj._ctx.caller).toBe(caller);
1673
+ expect(argObj._ctx.taskId).toBe("task-456");
1674
+ expect(argObj._ctx.Tools).toBe(toolsService);
1675
+ });
1676
+
1677
+ it("still provides a _ctx (with Tools) when no callContext is passed", async () => {
1678
+ const fn = jest.fn().mockResolvedValue("ok");
1679
+ toolsService.addTool(nonPositionalTool);
1680
+ toolsService.setFunction("nonPosCtxTool", fn);
1681
+
1682
+ await toolsService.callTool({
1683
+ id: "c3",
1684
+ type: "function",
1685
+ function: {
1686
+ name: "nonPosCtxTool",
1687
+ arguments: JSON.stringify({ input: "value" }),
1688
+ },
1689
+ });
1690
+
1691
+ const argObj = fn.mock.calls[0][0];
1692
+ expect(argObj._ctx).toBeDefined();
1693
+ expect(argObj._ctx.Tools).toBe(toolsService);
1694
+ expect(argObj._ctx.caller).toBeUndefined();
1695
+ });
1696
+
1697
+ it("does not leak _ctx into the multi_tool_use.parallel extraction", async () => {
1698
+ // The parallel path should extract calls from the ORIGINAL args, not the
1699
+ // _ctx-augmented ones.
1700
+ const parallelTool: Tool = {
1701
+ type: "function",
1702
+ function: {
1703
+ name: "multi_tool_use.parallel",
1704
+ parameters: { type: "object", properties: {} },
1705
+ },
1706
+ };
1707
+ const inner = jest
1708
+ .fn()
1709
+ .mockResolvedValue(["r1", "r2"]);
1710
+ toolsService.addTool(parallelTool);
1711
+ toolsService.setFunction("multi_tool_use.parallel", inner);
1712
+
1713
+ const result = await toolsService.callTool(
1714
+ {
1715
+ id: "c4",
1716
+ type: "function",
1717
+ function: {
1718
+ name: "multi_tool_use.parallel",
1719
+ arguments: JSON.stringify([
1720
+ { recipient_name: "functions.a", parameters: {} },
1721
+ { recipient_name: "functions.b", parameters: {} },
1722
+ ]),
1723
+ },
1724
+ },
1725
+ undefined,
1726
+ { caller: {}, taskId: "t" }
1727
+ );
1728
+
1729
+ // Two tool messages, one per parallel call — proves extraction used the
1730
+ // original array shape rather than an _ctx-wrapped object.
1731
+ expect(result.toolMessages).toHaveLength(2);
1732
+ expect(result.toolMessages[0].name).toBe("a");
1733
+ expect(result.toolMessages[1].name).toBe("b");
1734
+ });
1735
+ });
1573
1736
  });