@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
@@ -7,6 +7,37 @@ import { KnowhowModule, ModuleContext } from "./types";
7
7
  import { services } from "../";
8
8
  import { toUniqueArray } from "../../utils";
9
9
 
10
+ /**
11
+ * Module resolution search paths.
12
+ *
13
+ * A module's resolution order depends on WHICH config declared it:
14
+ *
15
+ * - Modules declared in the GLOBAL config should resolve against the global
16
+ * install's node_modules (~/.knowhow/node_modules) — i.e. what's installed
17
+ * alongside the global config. We still fall back to cwd paths as a last
18
+ * resort so a globally-declared module can still be found if it only exists
19
+ * locally.
20
+ *
21
+ * - Modules declared in the LOCAL config should resolve against the local
22
+ * project first (cwd/.knowhow/node_modules), then the global config's
23
+ * node_modules (~/.knowhow/node_modules), then cwd/node_modules.
24
+ */
25
+ function localResolvePaths(): string[] {
26
+ return [
27
+ path.join(process.cwd(), ".knowhow", "node_modules"),
28
+ path.join(os.homedir(), ".knowhow", "node_modules"),
29
+ path.join(process.cwd(), "node_modules"),
30
+ ];
31
+ }
32
+
33
+ function globalResolvePaths(): string[] {
34
+ return [
35
+ path.join(os.homedir(), ".knowhow", "node_modules"),
36
+ path.join(process.cwd(), ".knowhow", "node_modules"),
37
+ path.join(process.cwd(), "node_modules"),
38
+ ];
39
+ }
40
+
10
41
  export class ModulesService {
11
42
  async getDefaultContext() {
12
43
  return { ...services() };
@@ -19,7 +50,8 @@ export class ModulesService {
19
50
 
20
51
  async loadModulesFrom(
21
52
  config: { modules: string[] } & any,
22
- context?: Partial<ModuleContext>
53
+ context?: Partial<ModuleContext>,
54
+ resolvePaths: string[] = localResolvePaths()
23
55
  ) {
24
56
  // If no context provided, fall back to global singletons
25
57
  if (!context) {
@@ -38,54 +70,84 @@ export class ModulesService {
38
70
 
39
71
  const allModulePaths = config.modules;
40
72
 
41
- // Search paths: local .knowhow/node_modules first (where `knowhow modules install`
42
- // puts packages for this project), then global ~/.knowhow/node_modules, and finally
43
- // cwd/node_modules as a last resort. Putting cwd/node_modules last avoids accidentally
44
- // picking up workspace-symlinked dev versions of modules (e.g. when running knowhow
45
- // from within the knowhow monorepo itself) instead of the properly-installed version.
46
- const resolvePaths = [
47
- path.join(process.cwd(), ".knowhow", "node_modules"),
48
- path.join(os.homedir(), ".knowhow", "node_modules"),
49
- path.join(process.cwd(), "node_modules"),
50
- ];
73
+ // `resolvePaths` (passed in by the caller) determines the search-path order.
74
+ // Global-config modules resolve against ~/.knowhow/node_modules first; local-config
75
+ // modules resolve against cwd/.knowhow/node_modules first. See localResolvePaths()
76
+ // and globalResolvePaths() above.
51
77
 
52
78
  for (const modulePath of allModulePaths) {
53
- // Resolve relative paths relative to process.cwd() so that paths like
54
- // "../../packages/knowhow-module-load-webpage" in knowhow.json work
55
- // regardless of where the compiled output lives.
56
- let resolvedPath: string;
79
+ // Build an ordered list of candidate resolutions for this module.
80
+ // Relative paths resolve to a single candidate (relative to cwd), while
81
+ // npm package names get one candidate per search path so we can fall
82
+ // back to the global install if a locally-symlinked (e.g. workspace dev)
83
+ // build fails to load (wrong Node ABI, broken build, etc.).
84
+ const candidates: string[] = [];
57
85
  if (modulePath.startsWith(".")) {
58
- resolvedPath = path.resolve(process.cwd(), modulePath);
86
+ candidates.push(path.resolve(process.cwd(), modulePath));
59
87
  } else {
60
- // For npm package names, try resolving from cwd first so locally-installed
61
- // modules are found even when knowhow is installed globally.
88
+ // For npm package names, resolve against each search path individually
89
+ // (in priority order) so each becomes its own fallback candidate.
90
+ for (const searchPath of resolvePaths) {
91
+ try {
92
+ const resolved = require.resolve(modulePath, {
93
+ paths: [searchPath],
94
+ });
95
+ if (!candidates.includes(resolved)) candidates.push(resolved);
96
+ } catch {
97
+ // this search path doesn't have the module — try the next one
98
+ }
99
+ }
100
+ // Finally, normal require resolution as a last resort.
62
101
  try {
63
- resolvedPath = require.resolve(modulePath, { paths: resolvePaths });
102
+ const resolved = require.resolve(modulePath);
103
+ if (!candidates.includes(resolved)) candidates.push(resolved);
64
104
  } catch {
65
- resolvedPath = modulePath; // fall back to normal require resolution
105
+ // ignore — will fall through to the bare name below
66
106
  }
107
+ if (candidates.length === 0) candidates.push(modulePath);
67
108
  }
68
109
 
110
+ // Try each candidate in order. Only if EVERY candidate fails to load do
111
+ // we surface an error — a failure on one candidate (e.g. a stale local
112
+ // workspace build) should silently fall back to the next (e.g. the
113
+ // global install).
69
114
  let importedModule: KnowhowModule;
70
- try {
71
- const rawModule = require(resolvedPath);
72
- importedModule = (rawModule.default || rawModule) as KnowhowModule;
73
- context.Events?.log(
74
- "ModulesService",
75
- `🔌 Loading module: ${modulePath} (resolved: ${resolvedPath})`
76
- );
77
- await importedModule.init({
78
- config,
79
- cwd: process.cwd(),
80
- context: context as ModuleContext,
81
- });
82
- context.Events?.log(
83
- "ModulesService",
84
- `✅ Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
85
- );
86
- } catch (err: any) {
115
+ let loaded = false;
116
+ const errors: { candidate: string; error: Error }[] = [];
117
+ for (const resolvedPath of candidates) {
118
+ try {
119
+ const rawModule = require(resolvedPath);
120
+ importedModule = (rawModule.default || rawModule) as KnowhowModule;
121
+ context.Events?.log(
122
+ "ModulesService",
123
+ `🔌 Loading module: ${modulePath} (resolved: ${resolvedPath})`
124
+ );
125
+ await importedModule.init({
126
+ config,
127
+ cwd: process.cwd(),
128
+ context: context as ModuleContext,
129
+ });
130
+ context.Events?.log(
131
+ "ModulesService",
132
+ `✅ Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
133
+ );
134
+ loaded = true;
135
+ break;
136
+ } catch (err: any) {
137
+ errors.push({ candidate: resolvedPath, error: err });
138
+ // try the next candidate
139
+ }
140
+ }
141
+
142
+ if (!loaded) {
143
+ // All candidates failed — report the last (most fully-resolved) error.
144
+ const last = errors[errors.length - 1];
145
+ const detail = last
146
+ ? `${last.error.message}`
147
+ : "no resolvable module found";
87
148
  process.stderr.write(
88
- `\n⚠️ Failed to load module "${modulePath}": ${err.message}\n` +
149
+ `\n⚠️ Failed to load module "${modulePath}": ${detail}\n` +
150
+ ` Tried: ${candidates.join(", ") || "(none)"}\n` +
89
151
  ` Run "knowhow modules setup --global" or "knowhow modules install ${modulePath} --global" to fix this.\n\n`
90
152
  );
91
153
  continue;
@@ -130,14 +192,33 @@ export class ModulesService {
130
192
  const config = await getConfig();
131
193
 
132
194
  const globalConfig = await getGlobalConfig();
133
- const allModulePaths = [
134
- ...(globalConfig.modules || []),
135
- ...(config.modules || []),
136
- ];
137
-
138
- return this.loadModulesFrom(
139
- { ...config, modules: toUniqueArray(allModulePaths) },
140
- context
195
+
196
+ const globalModules = toUniqueArray(globalConfig.modules || []);
197
+ // Local modules already loaded globally shouldn't be loaded again (a module
198
+ // that adds a CLI command would throw "already have command"). De-dupe local
199
+ // against global.
200
+ const localModules = toUniqueArray(config.modules || []).filter(
201
+ (m) => !globalModules.includes(m)
141
202
  );
203
+
204
+ // Load global-config modules resolving against the global install's
205
+ // node_modules (~/.knowhow/node_modules) first.
206
+ if (globalModules.length > 0) {
207
+ await this.loadModulesFrom(
208
+ { ...config, modules: globalModules },
209
+ context,
210
+ globalResolvePaths()
211
+ );
212
+ }
213
+
214
+ // Load local-config modules resolving against the local project's
215
+ // node_modules (cwd/.knowhow/node_modules) first.
216
+ if (localModules.length > 0) {
217
+ await this.loadModulesFrom(
218
+ { ...config, modules: localModules },
219
+ context,
220
+ localResolvePaths()
221
+ );
222
+ }
142
223
  }
143
224
  }
package/src/types.ts CHANGED
@@ -35,6 +35,11 @@ export type GenerationSource = {
35
35
  outputName?: string;
36
36
  /** Pass to runAgentTask: enable .knowhow/processes/agents/<taskId> directory */
37
37
  syncFs?: boolean;
38
+ /**
39
+ * Pass to runAgentTask: push the agent's work to a remote Knowhow task
40
+ * (identified by this source's taskId).
41
+ */
42
+ syncRemote?: boolean;
38
43
  /**
39
44
  * Deterministic task ID for this source (used with syncFs).
40
45
  * Defaults to "generate:<sanitized-output>" when syncFs is enabled.
@@ -4,8 +4,10 @@
4
4
  * These verify the fix for the "agent gets stuck after /poke" bug:
5
5
  * 1. A stale (slow) operation that finishes AFTER its window was interrupted
6
6
  * must NOT clobber a newer interruptible window's resolver.
7
- * 2. An interrupt() arriving with no active window must be queued and fire on
8
- * the next makeInterruptible window (not silently dropped).
7
+ * 2. An interrupt() arriving with no active window is a no-op — there is
8
+ * nothing to interrupt, so it must NOT be queued (a queued interrupt would
9
+ * pre-empt the next tool call and drop its result). Any message supplied
10
+ * with the poke is still added as a pending user message.
9
11
  * 3. Normal (non-interrupted) resolution still works.
10
12
  * 4. Errors propagate when not interrupted, and are swallowed when interrupted.
11
13
  */
@@ -20,7 +22,6 @@
20
22
  class InterruptHarness {
21
23
  private _interruptResolve: (() => void) | null = null;
22
24
  private _interruptToken = 0;
23
- private _pendingInterrupt = false;
24
25
  public pendingUserMessages: any[] = [];
25
26
 
26
27
  addPendingUserMessage(m: any) {
@@ -43,12 +44,6 @@ class InterruptHarness {
43
44
 
44
45
  this._interruptResolve = () => finish(() => resolve(interruptValue));
45
46
 
46
- if (this._pendingInterrupt) {
47
- this._pendingInterrupt = false;
48
- finish(() => resolve(interruptValue));
49
- return;
50
- }
51
-
52
47
  promise
53
48
  .then((result) => {
54
49
  finish(() => resolve(result));
@@ -69,9 +64,8 @@ class InterruptHarness {
69
64
  }
70
65
  if (this._interruptResolve) {
71
66
  this._interruptResolve();
72
- } else {
73
- this._pendingInterrupt = true;
74
67
  }
68
+ // No active window -> nothing to interrupt. Do NOT queue it.
75
69
  }
76
70
 
77
71
  get hasActiveResolver() {
@@ -157,19 +151,18 @@ describe("BaseAgent interrupt / makeInterruptible", () => {
157
151
  await expect(w2).resolves.toBe("fresh-result");
158
152
  });
159
153
 
160
- test("interrupt() with no active window is queued and fires next window", async () => {
154
+ test("interrupt() with no active window is a no-op (NOT queued)", async () => {
161
155
  const h = new InterruptHarness();
162
156
  // No active window yet.
163
157
  expect(h.hasActiveResolver).toBe(false);
164
- h.interrupt(); // queued, not dropped
158
+ h.interrupt(); // nothing to interrupt -> no-op
165
159
 
166
- // Next window should fire immediately with the interrupt value.
160
+ // The next window must NOT be pre-empted — it should run its real op and
161
+ // resolve with the real result, not the interrupt value.
167
162
  const op = deferred<string>();
168
163
  const w = h.makeInterruptible(op.promise, "INT");
169
- await expect(w).resolves.toBe("INT");
170
- // Underlying op finishing later must be harmless.
171
- op.resolve("later");
172
- await op.promise;
164
+ op.resolve("real-result");
165
+ await expect(w).resolves.toBe("real-result");
173
166
  });
174
167
 
175
168
  test("errors propagate when not interrupted", async () => {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for BaseAgent.observe() / stopObserving() — the per-agent observation
3
+ * registry that the `observe` tool delegates to.
4
+ *
5
+ * Verifies:
6
+ * 1. A source's emit() is delivered to the agent as a pending message.
7
+ * 2. onlyOnChange dedupes repeated data.
8
+ * 3. maxUpdates auto-stops and runs the source teardown.
9
+ * 4. stopObserving(id) cancels one; stopObserving() cancels all.
10
+ * 5. Observations are scoped to the instance — one agent's stopObserving()
11
+ * with no id does NOT affect another agent's observers.
12
+ * 6. Data emitted after the agent is "done" is not injected.
13
+ */
14
+
15
+ import { BaseAgent, ObservationSource } from "../../src/agents/base/base";
16
+
17
+ // Teardown runs on the microtask queue (Promise.resolve(teardownPromise).then),
18
+ // so flush a couple of ticks before asserting torndown state.
19
+ const flush = () => new Promise((r) => setTimeout(r, 0));
20
+
21
+ // Minimal stubs so BaseAgent can be constructed.
22
+ function makeStubs() {
23
+ const events: any = {
24
+ setListener: () => {},
25
+ emit: () => {},
26
+ on: () => {},
27
+ };
28
+ const tools: any = {
29
+ getContext: () => ({}),
30
+ callTool: async () => "",
31
+ };
32
+ return { events, tools };
33
+ }
34
+
35
+ class TestAgent extends BaseAgent {
36
+ name = "TestAgent";
37
+ description = "test";
38
+ constructor() {
39
+ const { events, tools } = makeStubs();
40
+ super({ Tools: tools, Events: events });
41
+ // Agent must not be "done" for observations to deliver.
42
+ (this as any).status = this.eventTypes.inProgress;
43
+ }
44
+ async getInitialMessages(): Promise<any[]> {
45
+ return [];
46
+ }
47
+ get delivered(): any[] {
48
+ return (this as any).pendingMessages;
49
+ }
50
+ setStatus(s: string) {
51
+ (this as any).status = s;
52
+ }
53
+ }
54
+
55
+ /** A source we can drive manually and that records teardown. */
56
+ function manualSource(label: string) {
57
+ let emitFn: ((d: unknown) => void) | undefined;
58
+ const state = { torndown: false };
59
+ const source: ObservationSource = {
60
+ label,
61
+ start(emit) {
62
+ emitFn = emit;
63
+ return () => {
64
+ state.torndown = true;
65
+ };
66
+ },
67
+ };
68
+ return {
69
+ source,
70
+ push: (d: unknown) => emitFn && emitFn(d),
71
+ state,
72
+ };
73
+ }
74
+
75
+ describe("BaseAgent.observe / stopObserving", () => {
76
+ it("delivers emitted data as pending messages", () => {
77
+ const agent = new TestAgent();
78
+ const { source, push } = manualSource("thing");
79
+ const id = agent.observe(source);
80
+ expect(id).toMatch(/^obs_thing_/);
81
+
82
+ push("hello");
83
+ expect(agent.delivered.length).toBe(1);
84
+ expect(String(agent.delivered[0].content)).toContain("hello");
85
+ expect(String(agent.delivered[0].content)).toContain(`[observe:${id}]`);
86
+ agent.stopObserving();
87
+ });
88
+
89
+ it("dedupes identical consecutive data with onlyOnChange (default)", () => {
90
+ const agent = new TestAgent();
91
+ const { source, push } = manualSource("dup");
92
+ agent.observe(source);
93
+ push("a");
94
+ push("a");
95
+ push("b");
96
+ expect(agent.delivered.length).toBe(2);
97
+ agent.stopObserving();
98
+ });
99
+
100
+ it("delivers every emit when onlyOnChange is false", () => {
101
+ const agent = new TestAgent();
102
+ const { source, push } = manualSource("nochange");
103
+ // dedupe of pendingMessages by content still applies in addPendingMessage,
104
+ // so use distinct data to observe count.
105
+ agent.observe(source, { onlyOnChange: false });
106
+ push("x1");
107
+ push("x2");
108
+ push("x3");
109
+ expect(agent.delivered.length).toBe(3);
110
+ agent.stopObserving();
111
+ });
112
+
113
+ it("auto-stops after maxUpdates and runs teardown", async () => {
114
+ const agent = new TestAgent();
115
+ const { source, push, state } = manualSource("cap");
116
+ agent.observe(source, { maxUpdates: 2 });
117
+ push("1");
118
+ push("2"); // hits cap -> stop
119
+ push("3"); // ignored, observer gone
120
+ expect(agent.delivered.length).toBe(2);
121
+ await flush();
122
+ expect(state.torndown).toBe(true);
123
+ agent.stopObserving();
124
+ });
125
+
126
+ it("stopObserving(id) cancels one observer and tears it down", async () => {
127
+ const agent = new TestAgent();
128
+ const a = manualSource("a");
129
+ const b = manualSource("b");
130
+ const idA = agent.observe(a.source);
131
+ agent.observe(b.source);
132
+ const res = agent.stopObserving(idA);
133
+ expect(res).toContain(`Stopped observer ${idA}`);
134
+ await flush();
135
+ expect(a.state.torndown).toBe(true);
136
+ expect(b.state.torndown).toBe(false);
137
+ // b still delivers
138
+ b.push("still here");
139
+ expect(agent.delivered.some((m) => String(m.content).includes("still here"))).toBe(true);
140
+ agent.stopObserving();
141
+ });
142
+
143
+ it("stopObserving() with no id cancels all", async () => {
144
+ const agent = new TestAgent();
145
+ const a = manualSource("a");
146
+ const b = manualSource("b");
147
+ agent.observe(a.source);
148
+ agent.observe(b.source);
149
+ const res = agent.stopObserving();
150
+ expect(res).toMatch(/Stopped 2 active observer/);
151
+ await flush();
152
+ expect(a.state.torndown).toBe(true);
153
+ expect(b.state.torndown).toBe(true);
154
+ });
155
+
156
+ it("is scoped per-agent — one agent's stopObserving does not affect another", async () => {
157
+ const agentA = new TestAgent();
158
+ const agentB = new TestAgent();
159
+ const sa = manualSource("sa");
160
+ const sb = manualSource("sb");
161
+ agentA.observe(sa.source);
162
+ agentB.observe(sb.source);
163
+
164
+ agentA.stopObserving(); // should only affect A
165
+ await flush();
166
+ expect(sa.state.torndown).toBe(true);
167
+ expect(sb.state.torndown).toBe(false);
168
+
169
+ sb.push("b-data");
170
+ expect(agentB.delivered.some((m) => String(m.content).includes("b-data"))).toBe(true);
171
+ agentB.stopObserving();
172
+ });
173
+
174
+ it("does not inject data once the agent is done", () => {
175
+ const agent = new TestAgent();
176
+ const { source, push } = manualSource("late");
177
+ agent.observe(source);
178
+ agent.setStatus(agent.eventTypes.done);
179
+ push("too late");
180
+ expect(agent.delivered.length).toBe(0);
181
+ agent.stopObserving();
182
+ });
183
+ });
@@ -0,0 +1,138 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import {
5
+ connectAgent,
6
+ stopConnections,
7
+ } from "../../../src/agents/tools/connectAgent";
8
+
9
+ describe("connectAgent / stopConnections", () => {
10
+ let tmpCwd: string;
11
+ let originalCwd: string;
12
+ let agentsDir: string;
13
+
14
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
15
+
16
+ beforeEach(() => {
17
+ originalCwd = process.cwd();
18
+ tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "connect-agent-"));
19
+ process.chdir(tmpCwd);
20
+ agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
21
+ fs.mkdirSync(agentsDir, { recursive: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await stopConnections({});
26
+ process.chdir(originalCwd);
27
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
28
+ });
29
+
30
+ function makeTask(taskId: string, threads: any[] = []): string {
31
+ const dir = path.join(agentsDir, taskId);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ writeThreads(taskId, threads);
34
+ return dir;
35
+ }
36
+
37
+ function writeThreads(taskId: string, threads: any[], extra: any = {}) {
38
+ fs.writeFileSync(
39
+ path.join(agentsDir, taskId, "metadata.json"),
40
+ JSON.stringify({ taskId, threads, ...extra }, null, 2),
41
+ "utf8"
42
+ );
43
+ }
44
+
45
+ function readInput(taskId: string): string {
46
+ const p = path.join(agentsDir, taskId, "input.txt");
47
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
48
+ }
49
+
50
+ it("rejects empty connections array", async () => {
51
+ const res = await connectAgent({ connections: [] });
52
+ expect(res).toMatch(/non-empty array/);
53
+ });
54
+
55
+ it("reports missing task dirs but still wires valid ones", async () => {
56
+ makeTask("a");
57
+ const res = await connectAgent({
58
+ connections: [
59
+ { listener: "a", speaker: "missing" },
60
+ { listener: "a", speaker: "a" }, // self connection skipped
61
+ ],
62
+ });
63
+ expect(res).toMatch(/speaker task dir not found: missing/);
64
+ expect(res).toMatch(/self-connection for a/);
65
+ });
66
+
67
+ it("relays only NEW assistant messages from speaker to listener", async () => {
68
+ // Speaker starts with one existing assistant message (should NOT be relayed)
69
+ makeTask("listener", []);
70
+ makeTask("speaker", [
71
+ { role: "assistant", content: "old message" },
72
+ ]);
73
+
74
+ await connectAgent({
75
+ connections: [{ listener: "listener", speaker: "speaker" }],
76
+ intervalMs: 1000,
77
+ });
78
+
79
+ // Now the speaker produces a new assistant message
80
+ writeThreads("speaker", [
81
+ { role: "assistant", content: "old message" },
82
+ { role: "assistant", content: "new insight" },
83
+ ]);
84
+
85
+ // Wait for a couple of poll cycles
86
+ await delay(1300);
87
+
88
+ const input = readInput("listener");
89
+ expect(input).toContain("[from:speaker] new insight");
90
+ expect(input).not.toContain("old message");
91
+ });
92
+
93
+ it("supports bidirectional in a single call (array of connections)", async () => {
94
+ makeTask("a", []);
95
+ makeTask("b", []);
96
+
97
+ await connectAgent({
98
+ connections: [
99
+ { listener: "a", speaker: "b" },
100
+ { listener: "b", speaker: "a" },
101
+ ],
102
+ intervalMs: 1000,
103
+ });
104
+
105
+ writeThreads("a", [{ role: "assistant", content: "from A" }]);
106
+ writeThreads("b", [{ role: "assistant", content: "from B" }]);
107
+
108
+ await delay(1300);
109
+
110
+ // A hears B, B hears A
111
+ expect(readInput("a")).toContain("[from:b] from B");
112
+ expect(readInput("b")).toContain("[from:a] from A");
113
+ });
114
+
115
+ it("stops a specific connection by id", async () => {
116
+ makeTask("l", []);
117
+ makeTask("s", []);
118
+ const res = await connectAgent({
119
+ connections: [{ listener: "l", speaker: "s" }],
120
+ intervalMs: 1000,
121
+ });
122
+ const idMatch = res.match(/conn_[A-Za-z0-9_]+/);
123
+ expect(idMatch).toBeTruthy();
124
+ const stopRes = await stopConnections({ id: idMatch![0] });
125
+ expect(stopRes).toMatch(/Stopped connection/);
126
+ });
127
+
128
+ it("stopConnections() with no id stops all", async () => {
129
+ makeTask("l2", []);
130
+ makeTask("s2", []);
131
+ await connectAgent({
132
+ connections: [{ listener: "l2", speaker: "s2" }],
133
+ intervalMs: 1000,
134
+ });
135
+ const res = await stopConnections({});
136
+ expect(res).toMatch(/Stopped 1 active connection/);
137
+ });
138
+ });