@tyvm/knowhow 0.0.138 → 0.0.141

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/misc.ts +6 -0
  25. package/src/commands/modules.ts +159 -7
  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/misc.js +2 -0
  104. package/ts_build/src/commands/misc.js.map +1 -1
  105. package/ts_build/src/commands/modules.js +86 -7
  106. package/ts_build/src/commands/modules.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,99 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { sendAgentMessage } from "../../../src/agents/tools/sendAgentMessage";
5
+
6
+ describe("sendAgentMessage", () => {
7
+ let tmpCwd: string;
8
+ let originalCwd: string;
9
+ let agentsDir: string;
10
+
11
+ beforeEach(() => {
12
+ originalCwd = process.cwd();
13
+ tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "send-msg-"));
14
+ process.chdir(tmpCwd);
15
+ agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
16
+ fs.mkdirSync(agentsDir, { recursive: true });
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.chdir(originalCwd);
21
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
22
+ });
23
+
24
+ function makeTask(taskId: string): string {
25
+ const dir = path.join(agentsDir, taskId);
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ it("requires taskId and message", async () => {
31
+ expect(await sendAgentMessage({ taskId: "", message: "hi" })).toMatch(
32
+ /taskId is required/
33
+ );
34
+ expect(await sendAgentMessage({ taskId: "x", message: "" })).toMatch(
35
+ /message is required/
36
+ );
37
+ });
38
+
39
+ it("errors when the target task dir doesn't exist", async () => {
40
+ const res = await sendAgentMessage({ taskId: "nope", message: "hi" });
41
+ expect(res).toMatch(/target task directory not found/);
42
+ });
43
+
44
+ it("appends an envelope with sender identity from _ctx", async () => {
45
+ makeTask("child-1");
46
+ const res = await sendAgentMessage({
47
+ taskId: "child-1",
48
+ message: "do the thing",
49
+ _ctx: { taskId: "parent-1", caller: { name: "Developer" } },
50
+ });
51
+ expect(res).toMatch(/Sent message to task child-1/);
52
+ const input = fs.readFileSync(
53
+ path.join(agentsDir, "child-1", "input.txt"),
54
+ "utf8"
55
+ );
56
+ expect(input).toContain("[from:parent-1/Developer] do the thing");
57
+ });
58
+
59
+ it("uses explicit from when provided", async () => {
60
+ makeTask("child-2");
61
+ await sendAgentMessage({
62
+ taskId: "child-2",
63
+ message: "msg",
64
+ from: "orchestrator",
65
+ });
66
+ const input = fs.readFileSync(
67
+ path.join(agentsDir, "child-2", "input.txt"),
68
+ "utf8"
69
+ );
70
+ expect(input).toContain("[from:orchestrator] msg");
71
+ });
72
+
73
+ it("delivers as /poke when poke:true (interrupts recipient)", async () => {
74
+ makeTask("child-3");
75
+ await sendAgentMessage({
76
+ taskId: "child-3",
77
+ message: "stop and reconsider",
78
+ from: "boss",
79
+ poke: true,
80
+ });
81
+ const input = fs.readFileSync(
82
+ path.join(agentsDir, "child-3", "input.txt"),
83
+ "utf8"
84
+ );
85
+ expect(input).toMatch(/^\/poke \[from:boss\] stop and reconsider/);
86
+ });
87
+
88
+ it("appends without clobbering existing pending messages", async () => {
89
+ const dir = makeTask("child-4");
90
+ fs.writeFileSync(path.join(dir, "input.txt"), "existing line\n", "utf8");
91
+ await sendAgentMessage({
92
+ taskId: "child-4",
93
+ message: "second",
94
+ from: "a",
95
+ });
96
+ const input = fs.readFileSync(path.join(dir, "input.txt"), "utf8");
97
+ expect(input).toBe("existing line\n[from:a] second\n");
98
+ });
99
+ });
@@ -0,0 +1,147 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { waitForAgentCompleted } from "../../../src/agents/tools/waitForAgentCompleted";
5
+
6
+ describe("waitForAgentCompleted", () => {
7
+ let tmpCwd: string;
8
+ let originalCwd: string;
9
+ let agentsDir: string;
10
+
11
+ beforeEach(() => {
12
+ originalCwd = process.cwd();
13
+ tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "wait-agent-"));
14
+ process.chdir(tmpCwd);
15
+ agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
16
+ fs.mkdirSync(agentsDir, { recursive: true });
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.chdir(originalCwd);
21
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
22
+ });
23
+
24
+ function makeTask(taskId: string, meta: Record<string, any> = {}, status?: string) {
25
+ const dir = path.join(agentsDir, taskId);
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ fs.writeFileSync(
28
+ path.join(dir, "metadata.json"),
29
+ JSON.stringify({ taskId, ...meta }, null, 2),
30
+ "utf8"
31
+ );
32
+ if (status !== undefined) {
33
+ fs.writeFileSync(path.join(dir, "status.txt"), status, "utf8");
34
+ }
35
+ return dir;
36
+ }
37
+
38
+ it("returns error when taskId missing", async () => {
39
+ const res = JSON.parse(await waitForAgentCompleted({ taskId: "" }));
40
+ expect(res.status).toBe("error");
41
+ expect(res.error).toMatch(/taskId is required/);
42
+ });
43
+
44
+ it("returns error when task directory does not exist", async () => {
45
+ const res = JSON.parse(await waitForAgentCompleted({ taskId: "nope" }));
46
+ expect(res.status).toBe("error");
47
+ expect(res.error).toMatch(/No agent task directory/);
48
+ });
49
+
50
+ it("returns immediately with structured result for a completed task", async () => {
51
+ makeTask("done-1", {
52
+ status: "completed",
53
+ totalCostUsd: 1.23,
54
+ result: "the final answer",
55
+ artifacts: ["a.txt"],
56
+ });
57
+
58
+ const res = JSON.parse(await waitForAgentCompleted({ taskId: "done-1" }));
59
+ expect(res.status).toBe("completed");
60
+ expect(res.costUsd).toBe(1.23);
61
+ expect(res.finalAnswer).toBe("the final answer");
62
+ expect(res.artifacts).toEqual(["a.txt"]);
63
+ });
64
+
65
+ it("falls back to last assistant message when no result field", async () => {
66
+ makeTask("done-2", {
67
+ status: "completed",
68
+ totalCostUsd: 0.5,
69
+ threads: [
70
+ [
71
+ { role: "user", content: "do it" },
72
+ { role: "assistant", content: "I did it" },
73
+ ],
74
+ ],
75
+ });
76
+
77
+ const res = JSON.parse(await waitForAgentCompleted({ taskId: "done-2" }));
78
+ expect(res.finalAnswer).toBe("I did it");
79
+ });
80
+
81
+ it("treats inProgress:false with no status as completed", async () => {
82
+ makeTask("done-3", { inProgress: false, totalCostUsd: 0.1, result: "ok" });
83
+ const res = JSON.parse(await waitForAgentCompleted({ taskId: "done-3" }));
84
+ expect(res.status).toBe("completed");
85
+ });
86
+
87
+ it("times out on a still-running task", async () => {
88
+ makeTask("running-1", { status: "running", totalCostUsd: 0.2 });
89
+ const res = JSON.parse(
90
+ await waitForAgentCompleted({
91
+ taskId: "running-1",
92
+ timeoutMs: 300,
93
+ pollIntervalMs: 100,
94
+ })
95
+ );
96
+ expect(res.timedOut).toBe(true);
97
+ expect(res.status).toBe("running");
98
+ expect(res.costUsd).toBe(0.2);
99
+ });
100
+
101
+ it("resolves when a running task transitions to completed mid-poll", async () => {
102
+ const dir = makeTask("running-2", { status: "running", totalCostUsd: 0 });
103
+
104
+ // Flip to completed shortly after we start waiting.
105
+ setTimeout(() => {
106
+ fs.writeFileSync(
107
+ path.join(dir, "metadata.json"),
108
+ JSON.stringify(
109
+ {
110
+ taskId: "running-2",
111
+ status: "completed",
112
+ totalCostUsd: 2.0,
113
+ result: "eventually done",
114
+ },
115
+ null,
116
+ 2
117
+ ),
118
+ "utf8"
119
+ );
120
+ }, 150);
121
+
122
+ const res = JSON.parse(
123
+ await waitForAgentCompleted({
124
+ taskId: "running-2",
125
+ timeoutMs: 3000,
126
+ pollIntervalMs: 100,
127
+ })
128
+ );
129
+ expect(res.status).toBe("completed");
130
+ expect(res.finalAnswer).toBe("eventually done");
131
+ expect(res.costUsd).toBe(2.0);
132
+ });
133
+
134
+ it("fails a task whose pid is dead without a terminal status", async () => {
135
+ // Use a pid that is virtually guaranteed not to exist.
136
+ makeTask("crashed-1", { status: "running", totalCostUsd: 0.3, pid: 999999 });
137
+ const res = JSON.parse(
138
+ await waitForAgentCompleted({
139
+ taskId: "crashed-1",
140
+ timeoutMs: 5000,
141
+ pollIntervalMs: 100,
142
+ })
143
+ );
144
+ expect(res.status).toBe("failed");
145
+ expect(res.error).toMatch(/exited without writing a terminal status/);
146
+ });
147
+ });
@@ -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
  }