@animalabs/connectome-host 0.3.1
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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postmortem 2026-05-28 P3 #8: scope `peek` and the `gatherContext` HUD to
|
|
3
|
+
* the caller's descendants only. Today both surfaces show the global roster:
|
|
4
|
+
*
|
|
5
|
+
* - `peek()` (no arg) returns snapshots for every running subagent in the
|
|
6
|
+
* fleet, including peers and cousins of the caller.
|
|
7
|
+
* - `peek(name)` accepts any name — there's no descendant check.
|
|
8
|
+
* - `gatherContext` iterates `activeSubagents` directly with no parent
|
|
9
|
+
* filter, so when the HUD is on the caller's context is injected with
|
|
10
|
+
* the full fleet roster every turn.
|
|
11
|
+
*
|
|
12
|
+
* This invites a "coordinate with peers" anti-pattern (visible in the
|
|
13
|
+
* production miner's narration: scouts gossiping about siblings being
|
|
14
|
+
* zombied) and lets one slow scout's status saturate the orchestrator's
|
|
15
|
+
* context and trigger retry-storm spawn behavior.
|
|
16
|
+
*
|
|
17
|
+
* The fix uses `parentMap` (already maintained for cancelChildren) to walk
|
|
18
|
+
* descendants. A caller sees only its own subtree — peers and ancestors are
|
|
19
|
+
* filtered out.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, test, expect } from 'bun:test';
|
|
22
|
+
import {
|
|
23
|
+
SubagentModule,
|
|
24
|
+
type ActiveSubagent,
|
|
25
|
+
} from '../src/modules/subagent-module.js';
|
|
26
|
+
|
|
27
|
+
interface FakeLiveState {
|
|
28
|
+
frameworkAgentName: string;
|
|
29
|
+
displayName: string;
|
|
30
|
+
systemPrompt: string;
|
|
31
|
+
contextManager: { compile: () => Promise<{ messages: unknown[] }> };
|
|
32
|
+
currentStream: string;
|
|
33
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
34
|
+
activeCallIds: Set<string>;
|
|
35
|
+
requestInFlightSince?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build a peek-ready SubagentModule with a tree of subagents wired up:
|
|
39
|
+
*
|
|
40
|
+
* root ('top')
|
|
41
|
+
* ├── child-a (parent: root)
|
|
42
|
+
* │ └── grandchild-a1 (parent: child-a)
|
|
43
|
+
* ├── child-b (parent: root)
|
|
44
|
+
* └── peer (parent: 'other-root') — NOT in the tree
|
|
45
|
+
*
|
|
46
|
+
* Caller framework agent names: root = 'fw-top', child-a = 'fw-a',
|
|
47
|
+
* child-b = 'fw-b', grandchild-a1 = 'fw-a1', peer = 'fw-peer'.
|
|
48
|
+
*/
|
|
49
|
+
function makeTree(): SubagentModule {
|
|
50
|
+
const mod = new SubagentModule();
|
|
51
|
+
const privateView = mod as unknown as {
|
|
52
|
+
liveSubagents: Map<string, FakeLiveState>;
|
|
53
|
+
frameworkNameIndex: Map<string, string>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const installOne = (displayName: string, frameworkAgentName: string, parent?: string): void => {
|
|
57
|
+
const live: FakeLiveState = {
|
|
58
|
+
frameworkAgentName,
|
|
59
|
+
displayName,
|
|
60
|
+
systemPrompt: 'test',
|
|
61
|
+
contextManager: { compile: async () => ({ messages: [] }) },
|
|
62
|
+
currentStream: '',
|
|
63
|
+
pendingToolCalls: [],
|
|
64
|
+
activeCallIds: new Set(),
|
|
65
|
+
};
|
|
66
|
+
privateView.liveSubagents.set(displayName, live);
|
|
67
|
+
privateView.frameworkNameIndex.set(frameworkAgentName, displayName);
|
|
68
|
+
const entry: ActiveSubagent = {
|
|
69
|
+
name: displayName,
|
|
70
|
+
type: 'spawn',
|
|
71
|
+
task: `task-${displayName}`,
|
|
72
|
+
status: 'running',
|
|
73
|
+
startedAt: Date.now(),
|
|
74
|
+
lastActivityAt: Date.now(),
|
|
75
|
+
toolCallsCount: 0,
|
|
76
|
+
findingsCount: 0,
|
|
77
|
+
};
|
|
78
|
+
mod.activeSubagents.set(`entry-${displayName}`, entry);
|
|
79
|
+
if (parent !== undefined) mod.parentMap.set(displayName, parent);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
installOne('child-a', 'fw-a', 'fw-top');
|
|
83
|
+
installOne('child-b', 'fw-b', 'fw-top');
|
|
84
|
+
installOne('grandchild-a1', 'fw-a1', 'fw-a');
|
|
85
|
+
installOne('peer', 'fw-peer', 'fw-other-root');
|
|
86
|
+
|
|
87
|
+
return mod;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('SubagentModule peek + HUD descendant scoping (P3 #8)', () => {
|
|
91
|
+
test('peek() with no name and a caller returns only descendants', async () => {
|
|
92
|
+
const mod = makeTree();
|
|
93
|
+
// The top-level caller ('fw-top') owns child-a, child-b, grandchild-a1
|
|
94
|
+
// (transitively). It does NOT own peer.
|
|
95
|
+
const callerView = mod as unknown as {
|
|
96
|
+
handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
|
|
97
|
+
};
|
|
98
|
+
const result = await callerView.handlePeek({}, 'fw-top');
|
|
99
|
+
const data = result.data as Array<{ name: string }>;
|
|
100
|
+
const names = new Set(data.map(s => s.name));
|
|
101
|
+
expect(names.has('child-a')).toBe(true);
|
|
102
|
+
expect(names.has('child-b')).toBe(true);
|
|
103
|
+
expect(names.has('grandchild-a1')).toBe(true);
|
|
104
|
+
expect(names.has('peer')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('peek() called by a mid-tree caller scopes to that subtree', async () => {
|
|
108
|
+
const mod = makeTree();
|
|
109
|
+
const callerView = mod as unknown as {
|
|
110
|
+
handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
|
|
111
|
+
};
|
|
112
|
+
// child-a's subtree is just grandchild-a1 — siblings (child-b) and
|
|
113
|
+
// cousins (peer) and ancestors (root) must be invisible.
|
|
114
|
+
const result = await callerView.handlePeek({}, 'fw-a');
|
|
115
|
+
const data = result.data as Array<{ name: string }>;
|
|
116
|
+
const names = new Set(data.map(s => s.name));
|
|
117
|
+
expect(names.has('grandchild-a1')).toBe(true);
|
|
118
|
+
expect(names.has('child-b')).toBe(false);
|
|
119
|
+
expect(names.has('peer')).toBe(false);
|
|
120
|
+
expect(names.has('child-a')).toBe(false); // caller doesn't see itself
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('peek(name) on a non-descendant returns empty even if the name exists', async () => {
|
|
124
|
+
const mod = makeTree();
|
|
125
|
+
const callerView = mod as unknown as {
|
|
126
|
+
handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
|
|
127
|
+
};
|
|
128
|
+
// child-a tries to peek peer (which exists fleet-wide but is not a
|
|
129
|
+
// descendant of child-a). Should return "no such subagent" from
|
|
130
|
+
// child-a's perspective.
|
|
131
|
+
const result = await callerView.handlePeek({ name: 'peer' }, 'fw-a');
|
|
132
|
+
expect(result.data).toEqual({ message: "No running subagent named 'peer'" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('peek(name) on a descendant works as before', async () => {
|
|
136
|
+
const mod = makeTree();
|
|
137
|
+
const callerView = mod as unknown as {
|
|
138
|
+
handlePeek: (input: { name?: string }, caller?: string) => Promise<{ success: boolean; data: unknown }>;
|
|
139
|
+
};
|
|
140
|
+
const result = await callerView.handlePeek({ name: 'grandchild-a1' }, 'fw-a');
|
|
141
|
+
const data = result.data as Array<{ name: string }>;
|
|
142
|
+
expect(data.length).toBe(1);
|
|
143
|
+
expect(data[0].name).toBe('grandchild-a1');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('peek() without a caller (e.g. internal call) still sees everything', async () => {
|
|
147
|
+
// Backward compat: not all callers thread an identity through (e.g.
|
|
148
|
+
// internal observability paths, tests). When no caller is provided, the
|
|
149
|
+
// descendant filter is bypassed.
|
|
150
|
+
const mod = makeTree();
|
|
151
|
+
const all = await mod.peek();
|
|
152
|
+
expect(all.length).toBe(4); // child-a + child-b + grandchild-a1 + peer
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('gatherContext HUD is scoped to descendants of the calling agent', async () => {
|
|
156
|
+
const mod = makeTree();
|
|
157
|
+
// Force the HUD on by directly setting the persisted flag's source — the
|
|
158
|
+
// module checks ctx?.getState().hudEnabled, but in this minimal test
|
|
159
|
+
// there's no ctx, so the early return at the top of gatherContext would
|
|
160
|
+
// bail. Pre-fix the function returned [] without ctx; post-fix the
|
|
161
|
+
// descendant filter must still be exercised. To keep the test focused on
|
|
162
|
+
// scoping (not on persistence wiring), stub ctx with a hudEnabled=true
|
|
163
|
+
// state-bag and an empty setState.
|
|
164
|
+
(mod as unknown as { ctx: unknown }).ctx = {
|
|
165
|
+
getState: () => ({ hudEnabled: true }),
|
|
166
|
+
setState: () => {},
|
|
167
|
+
};
|
|
168
|
+
const injections = await mod.gatherContext('fw-a');
|
|
169
|
+
expect(injections.length).toBeGreaterThan(0);
|
|
170
|
+
const text = (injections[0].content[0] as { text: string }).text;
|
|
171
|
+
// Each HUD line begins with two spaces then the subagent name and ' [type]'.
|
|
172
|
+
// Anchor on that to avoid substring collisions (grandchild-a1 contains
|
|
173
|
+
// the literal 'child-a' as a substring, which would make a naïve
|
|
174
|
+
// includes() check false-positive).
|
|
175
|
+
expect(text).toMatch(/^\s*grandchild-a1 \[/m);
|
|
176
|
+
expect(text).not.toMatch(/^\s*child-b \[/m);
|
|
177
|
+
expect(text).not.toMatch(/^\s*peer \[/m);
|
|
178
|
+
expect(text).not.toMatch(/^\s*child-a \[/m); // caller's own entry not surfaced
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postmortem 2026-05-28 F2: dual-clock zombie bug in SubagentModule.
|
|
3
|
+
*
|
|
4
|
+
* The reaper (reclaimZombieSlots) was correctly updated to use `lastActivityAt`
|
|
5
|
+
* — the merciful clock that distinguishes "long-running but progressing" from
|
|
6
|
+
* "silently stuck." The peek observability surface (peekOne / isZombie field)
|
|
7
|
+
* still uses `startedAt`, which flags ANY subagent older than 30s as a zombie
|
|
8
|
+
* the moment it's between tokens.
|
|
9
|
+
*
|
|
10
|
+
* This false positive saturates orchestrator context: scouts narrate about
|
|
11
|
+
* peer zombies, the orchestrator re-spawns them as -retryN, abandoned
|
|
12
|
+
* originals get aborted, the admin UI paints them red. The reap doesn't even
|
|
13
|
+
* have to happen for the symptom to manifest.
|
|
14
|
+
*
|
|
15
|
+
* These tests assert the merciful (lastActivityAt-based) semantics from peek's
|
|
16
|
+
* point of view. Each test seeds the module's private maps directly to keep
|
|
17
|
+
* the surface small — `peek()` does not need a full framework / context-manager
|
|
18
|
+
* stack to exercise the predicate.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, test, expect } from 'bun:test';
|
|
21
|
+
import { SubagentModule, type ActiveSubagent } from '../src/modules/subagent-module.js';
|
|
22
|
+
|
|
23
|
+
interface FakeLiveState {
|
|
24
|
+
frameworkAgentName: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
systemPrompt: string;
|
|
27
|
+
contextManager: { compile: () => Promise<{ messages: unknown[] }> };
|
|
28
|
+
currentStream: string;
|
|
29
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
30
|
+
activeCallIds: Set<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build a peek-ready SubagentModule with one synthetic subagent installed. */
|
|
34
|
+
function makeModuleWithSubagent(opts: {
|
|
35
|
+
displayName: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
lastActivityAt: number;
|
|
38
|
+
currentStream?: string;
|
|
39
|
+
pendingToolCalls?: Array<{ name: string; input?: unknown }>;
|
|
40
|
+
status?: 'running' | 'completed' | 'failed';
|
|
41
|
+
}): SubagentModule {
|
|
42
|
+
const mod = new SubagentModule();
|
|
43
|
+
const live: FakeLiveState = {
|
|
44
|
+
frameworkAgentName: `fw-${opts.displayName}`,
|
|
45
|
+
displayName: opts.displayName,
|
|
46
|
+
systemPrompt: 'test',
|
|
47
|
+
// Minimal stub — peekOne wraps compile() in try/catch and treats failures as
|
|
48
|
+
// "context manager mid-modification, return what we have," so this is fine.
|
|
49
|
+
contextManager: { compile: async () => ({ messages: [] }) },
|
|
50
|
+
currentStream: opts.currentStream ?? '',
|
|
51
|
+
pendingToolCalls: opts.pendingToolCalls ?? [],
|
|
52
|
+
activeCallIds: new Set(),
|
|
53
|
+
};
|
|
54
|
+
const entry: ActiveSubagent = {
|
|
55
|
+
name: opts.displayName,
|
|
56
|
+
type: 'spawn',
|
|
57
|
+
task: 'test-task',
|
|
58
|
+
status: opts.status ?? 'running',
|
|
59
|
+
startedAt: opts.startedAt,
|
|
60
|
+
lastActivityAt: opts.lastActivityAt,
|
|
61
|
+
toolCallsCount: 0,
|
|
62
|
+
findingsCount: 0,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// `liveSubagents` is private — go around the visibility modifier. The map is
|
|
66
|
+
// the canonical source for peek; we keep the test's surface minimal by
|
|
67
|
+
// skipping the spawn/fork wiring.
|
|
68
|
+
(mod as unknown as { liveSubagents: Map<string, FakeLiveState> }).liveSubagents.set(
|
|
69
|
+
opts.displayName,
|
|
70
|
+
live,
|
|
71
|
+
);
|
|
72
|
+
// activeSubagents is `readonly` (re-binding the Map is what's forbidden), so
|
|
73
|
+
// mutating it directly is fine.
|
|
74
|
+
mod.activeSubagents.set(`entry-${opts.displayName}`, entry);
|
|
75
|
+
return mod;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('SubagentModule.peek — F2 dual-clock zombie predicate', () => {
|
|
79
|
+
test('healthy long-running scout is NOT zombie (lastActivityAt fresh)', async () => {
|
|
80
|
+
// Productive scout 5 minutes in, last bumped activity 5s ago, currently
|
|
81
|
+
// between an inference round and its first token. Pre-fix: peek reports
|
|
82
|
+
// isZombie=true because elapsedMs from startedAt is 5min > 30s. Post-fix:
|
|
83
|
+
// peek consults lastActivityAt, sees fresh activity, reports isZombie=false.
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const mod = makeModuleWithSubagent({
|
|
86
|
+
displayName: 'scout',
|
|
87
|
+
startedAt: now - 5 * 60_000,
|
|
88
|
+
lastActivityAt: now - 5_000,
|
|
89
|
+
});
|
|
90
|
+
const [snap] = await mod.peek('scout');
|
|
91
|
+
expect(snap).toBeDefined();
|
|
92
|
+
expect(snap!.isZombie).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('genuinely stuck subagent IS zombie (lastActivityAt stale, no stream/tools)', async () => {
|
|
96
|
+
// Activity stopped 5 minutes ago with no current stream and no pending
|
|
97
|
+
// tools — the case the reaper exists to catch. Silence picked well above
|
|
98
|
+
// both 30s (old) and 120s (post-F3) thresholds so the test stays valid
|
|
99
|
+
// regardless of future threshold tuning.
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const mod = makeModuleWithSubagent({
|
|
102
|
+
displayName: 'stuck',
|
|
103
|
+
startedAt: now - 10 * 60_000,
|
|
104
|
+
lastActivityAt: now - 5 * 60_000,
|
|
105
|
+
});
|
|
106
|
+
const [snap] = await mod.peek('stuck');
|
|
107
|
+
expect(snap!.isZombie).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('subagent currently streaming is NOT zombie even with old lastActivityAt', async () => {
|
|
111
|
+
// currentStream non-empty short-circuits the predicate regardless of clocks.
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const mod = makeModuleWithSubagent({
|
|
114
|
+
displayName: 'streaming',
|
|
115
|
+
startedAt: now - 10 * 60_000,
|
|
116
|
+
lastActivityAt: now - 5 * 60_000,
|
|
117
|
+
currentStream: 'tokens flowing',
|
|
118
|
+
});
|
|
119
|
+
const [snap] = await mod.peek('streaming');
|
|
120
|
+
expect(snap!.isZombie).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('subagent with pending tool calls is NOT zombie', async () => {
|
|
124
|
+
// pendingToolCalls non-empty also short-circuits — agent is awaiting tool
|
|
125
|
+
// results, not stuck.
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const mod = makeModuleWithSubagent({
|
|
128
|
+
displayName: 'awaiting-tools',
|
|
129
|
+
startedAt: now - 10 * 60_000,
|
|
130
|
+
lastActivityAt: now - 5 * 60_000,
|
|
131
|
+
pendingToolCalls: [{ name: 'files--read', input: { path: '/x' } }],
|
|
132
|
+
});
|
|
133
|
+
const [snap] = await mod.peek('awaiting-tools');
|
|
134
|
+
expect(snap!.isZombie).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('completed subagent is never reported as zombie', async () => {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const mod = makeModuleWithSubagent({
|
|
140
|
+
displayName: 'done',
|
|
141
|
+
startedAt: now - 10 * 60_000,
|
|
142
|
+
lastActivityAt: now - 5 * 60_000,
|
|
143
|
+
status: 'completed',
|
|
144
|
+
});
|
|
145
|
+
const [snap] = await mod.peek('done');
|
|
146
|
+
expect(snap!.isZombie).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postmortem 2026-05-28 F3: reaper's guard does not cover the
|
|
3
|
+
* "request in flight, awaiting first token" window.
|
|
4
|
+
*
|
|
5
|
+
* Today's reap guard is `!live.currentStream && pendingToolCalls.length === 0`.
|
|
6
|
+
* At inference:started and inference:stream_resumed, both are cleared. The
|
|
7
|
+
* agent then sits with the guard open while the next LLM request is on the
|
|
8
|
+
* wire. With Opus on 100–165K-token contexts under rate-limited MCP tools,
|
|
9
|
+
* single-round TTFT routinely exceeds 30s, and the periodic reaper executes
|
|
10
|
+
* mid-request even though the agent is genuinely progressing.
|
|
11
|
+
*
|
|
12
|
+
* Forensic signature in production: every reaped scout's last message in the
|
|
13
|
+
* Chronicle store is an unconsumed `tool_result` — i.e., tool results came
|
|
14
|
+
* back, stream_resumed cleared the guards, the next request was dispatched,
|
|
15
|
+
* the reaper struck before the first token.
|
|
16
|
+
*
|
|
17
|
+
* Tests:
|
|
18
|
+
* 1) reaper does NOT cancel an entry with `requestInFlightSince` set, even
|
|
19
|
+
* when lastActivityAt is older than the threshold.
|
|
20
|
+
* 2) reaper DOES cancel a genuinely silent entry (control — proves the
|
|
21
|
+
* threshold path is still alive).
|
|
22
|
+
* 3) Lifecycle: trace events set/clear `requestInFlightSince` on the live
|
|
23
|
+
* subagent state via the module's onTrace handler.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, test, expect } from 'bun:test';
|
|
26
|
+
import {
|
|
27
|
+
SubagentModule,
|
|
28
|
+
type ActiveSubagent,
|
|
29
|
+
} from '../src/modules/subagent-module.js';
|
|
30
|
+
import type { TraceEvent } from '@animalabs/agent-framework';
|
|
31
|
+
|
|
32
|
+
interface FakeLiveState {
|
|
33
|
+
frameworkAgentName: string;
|
|
34
|
+
displayName: string;
|
|
35
|
+
systemPrompt: string;
|
|
36
|
+
contextManager: { compile: () => Promise<{ messages: unknown[] }> };
|
|
37
|
+
currentStream: string;
|
|
38
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
39
|
+
activeCallIds: Set<string>;
|
|
40
|
+
/** Populated when an inference request has been dispatched but no token
|
|
41
|
+
* has arrived yet. The reaper must treat this as a protected state. */
|
|
42
|
+
requestInFlightSince?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function installSubagent(
|
|
46
|
+
mod: SubagentModule,
|
|
47
|
+
opts: {
|
|
48
|
+
displayName: string;
|
|
49
|
+
frameworkAgentName?: string;
|
|
50
|
+
startedAt: number;
|
|
51
|
+
lastActivityAt: number;
|
|
52
|
+
currentStream?: string;
|
|
53
|
+
pendingToolCalls?: Array<{ name: string; input?: unknown }>;
|
|
54
|
+
requestInFlightSince?: number;
|
|
55
|
+
status?: 'running' | 'completed' | 'failed';
|
|
56
|
+
},
|
|
57
|
+
): { live: FakeLiveState; entry: ActiveSubagent } {
|
|
58
|
+
const frameworkAgentName = opts.frameworkAgentName ?? `fw-${opts.displayName}`;
|
|
59
|
+
const live: FakeLiveState = {
|
|
60
|
+
frameworkAgentName,
|
|
61
|
+
displayName: opts.displayName,
|
|
62
|
+
systemPrompt: 'test',
|
|
63
|
+
contextManager: { compile: async () => ({ messages: [] }) },
|
|
64
|
+
currentStream: opts.currentStream ?? '',
|
|
65
|
+
pendingToolCalls: opts.pendingToolCalls ?? [],
|
|
66
|
+
activeCallIds: new Set(),
|
|
67
|
+
requestInFlightSince: opts.requestInFlightSince,
|
|
68
|
+
};
|
|
69
|
+
const entry: ActiveSubagent = {
|
|
70
|
+
name: opts.displayName,
|
|
71
|
+
type: 'spawn',
|
|
72
|
+
task: 'test-task',
|
|
73
|
+
status: opts.status ?? 'running',
|
|
74
|
+
startedAt: opts.startedAt,
|
|
75
|
+
lastActivityAt: opts.lastActivityAt,
|
|
76
|
+
toolCallsCount: 0,
|
|
77
|
+
findingsCount: 0,
|
|
78
|
+
};
|
|
79
|
+
const privateView = mod as unknown as {
|
|
80
|
+
liveSubagents: Map<string, FakeLiveState>;
|
|
81
|
+
frameworkNameIndex: Map<string, string>;
|
|
82
|
+
};
|
|
83
|
+
privateView.liveSubagents.set(opts.displayName, live);
|
|
84
|
+
privateView.frameworkNameIndex.set(frameworkAgentName, opts.displayName);
|
|
85
|
+
mod.activeSubagents.set(`entry-${opts.displayName}`, entry);
|
|
86
|
+
return { live, entry };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Captures the trace handler that SubagentModule.setFramework registers, so
|
|
90
|
+
* the lifecycle test can fire events without spinning up the full framework. */
|
|
91
|
+
function makeTraceCapturingFramework(): {
|
|
92
|
+
framework: { onTrace: (cb: (e: TraceEvent) => void) => void };
|
|
93
|
+
fire: (event: TraceEvent) => void;
|
|
94
|
+
} {
|
|
95
|
+
let captured: ((e: TraceEvent) => void) | null = null;
|
|
96
|
+
return {
|
|
97
|
+
framework: {
|
|
98
|
+
onTrace(cb: (e: TraceEvent) => void) {
|
|
99
|
+
captured = cb;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
fire(event: TraceEvent) {
|
|
103
|
+
if (!captured) throw new Error('onTrace was never called');
|
|
104
|
+
captured(event);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('SubagentModule reaper — F3 request-in-flight protection', () => {
|
|
110
|
+
test('reaper does NOT cancel an agent with a request in flight', () => {
|
|
111
|
+
// Postmortem scenario: stream_resumed fired, currentStream + pendingToolCalls
|
|
112
|
+
// cleared, the next LLM round is on the wire. lastActivityAt is frozen at
|
|
113
|
+
// the moment of stream_resumed dispatch. TTFT is 45s — pre-fix the reaper
|
|
114
|
+
// would strike mid-request because all of (!currentStream, no pending tools,
|
|
115
|
+
// silentMs > threshold) hold. With the in-flight guard, the agent is left
|
|
116
|
+
// alone.
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const mod = new SubagentModule();
|
|
119
|
+
const { entry } = installSubagent(mod, {
|
|
120
|
+
displayName: 'opus-in-flight',
|
|
121
|
+
startedAt: now - 5 * 60_000,
|
|
122
|
+
lastActivityAt: now - 3 * 60_000, // well past any threshold
|
|
123
|
+
requestInFlightSince: now - 3 * 60_000, // request dispatched, no token yet
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const reclaimed = (mod as unknown as { reclaimZombieSlots: () => number })
|
|
127
|
+
.reclaimZombieSlots();
|
|
128
|
+
|
|
129
|
+
expect(reclaimed).toBe(0);
|
|
130
|
+
expect(entry.status).toBe('running');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('reaper DOES cancel a genuinely silent agent (control)', () => {
|
|
134
|
+
// Same staleness, but no request in flight — this is the "stuck" case the
|
|
135
|
+
// reaper exists to clean up. 5 min silence exceeds both the pre- and
|
|
136
|
+
// post-fix thresholds, so this test stays valid if the threshold is
|
|
137
|
+
// tuned in either direction.
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const mod = new SubagentModule();
|
|
140
|
+
const { entry } = installSubagent(mod, {
|
|
141
|
+
displayName: 'genuinely-stuck',
|
|
142
|
+
startedAt: now - 10 * 60_000,
|
|
143
|
+
lastActivityAt: now - 5 * 60_000,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const reclaimed = (mod as unknown as { reclaimZombieSlots: () => number })
|
|
147
|
+
.reclaimZombieSlots();
|
|
148
|
+
|
|
149
|
+
expect(reclaimed).toBe(1);
|
|
150
|
+
// Postmortem 2026-05-28 P1 #4: zombie-reaped subagents land in
|
|
151
|
+
// 'cancelled' (terminal-but-benign), not 'failed'. Aligns the
|
|
152
|
+
// SubagentModule's terminal state with the reducer's split between
|
|
153
|
+
// genuine faults (inference:failed) and benign cancels.
|
|
154
|
+
expect(entry.status).toBe('cancelled');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('peek.isZombie also respects requestInFlightSince', async () => {
|
|
158
|
+
// The peek surface drives the orchestrator's perception. Postmortem F2 was
|
|
159
|
+
// about dual clocks; F3 is the same shape: a healthy in-flight agent must
|
|
160
|
+
// not report isZombie=true.
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
const mod = new SubagentModule();
|
|
163
|
+
installSubagent(mod, {
|
|
164
|
+
displayName: 'opus-in-flight',
|
|
165
|
+
startedAt: now - 5 * 60_000,
|
|
166
|
+
lastActivityAt: now - 3 * 60_000,
|
|
167
|
+
requestInFlightSince: now - 3 * 60_000,
|
|
168
|
+
});
|
|
169
|
+
const [snap] = await mod.peek('opus-in-flight');
|
|
170
|
+
expect(snap!.isZombie).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('lifecycle: inference:started sets requestInFlightSince', () => {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const mod = new SubagentModule();
|
|
176
|
+
const { live } = installSubagent(mod, {
|
|
177
|
+
displayName: 'lc',
|
|
178
|
+
frameworkAgentName: 'fw-lc',
|
|
179
|
+
startedAt: now - 10_000,
|
|
180
|
+
lastActivityAt: now - 10_000,
|
|
181
|
+
});
|
|
182
|
+
expect(live.requestInFlightSince).toBeUndefined();
|
|
183
|
+
|
|
184
|
+
const fake = makeTraceCapturingFramework();
|
|
185
|
+
// setFramework registers the onTrace handler. Cast: our fake satisfies the
|
|
186
|
+
// surface SubagentModule uses at registration time.
|
|
187
|
+
mod.setFramework(fake.framework as never);
|
|
188
|
+
|
|
189
|
+
fake.fire({ type: 'inference:started', agentName: 'fw-lc', timestamp: Date.now() } as never);
|
|
190
|
+
expect(typeof live.requestInFlightSince).toBe('number');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('lifecycle: first inference:tokens clears requestInFlightSince', () => {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const mod = new SubagentModule();
|
|
196
|
+
const { live } = installSubagent(mod, {
|
|
197
|
+
displayName: 'lc2',
|
|
198
|
+
frameworkAgentName: 'fw-lc2',
|
|
199
|
+
startedAt: now - 10_000,
|
|
200
|
+
lastActivityAt: now - 10_000,
|
|
201
|
+
requestInFlightSince: now - 5_000,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const fake = makeTraceCapturingFramework();
|
|
205
|
+
mod.setFramework(fake.framework as never);
|
|
206
|
+
|
|
207
|
+
fake.fire({ type: 'inference:tokens', agentName: 'fw-lc2', content: 'hello', timestamp: Date.now() } as never);
|
|
208
|
+
expect(live.requestInFlightSince).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('lifecycle: inference:stream_resumed sets requestInFlightSince again', () => {
|
|
212
|
+
// After a tool round, the framework emits stream_resumed and dispatches a
|
|
213
|
+
// new request. This is the gap the postmortem traced reaps to.
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const mod = new SubagentModule();
|
|
216
|
+
const { live } = installSubagent(mod, {
|
|
217
|
+
displayName: 'lc3',
|
|
218
|
+
frameworkAgentName: 'fw-lc3',
|
|
219
|
+
startedAt: now - 60_000,
|
|
220
|
+
lastActivityAt: now - 60_000,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const fake = makeTraceCapturingFramework();
|
|
224
|
+
mod.setFramework(fake.framework as never);
|
|
225
|
+
|
|
226
|
+
fake.fire({ type: 'inference:stream_resumed', agentName: 'fw-lc3', timestamp: Date.now() } as never);
|
|
227
|
+
expect(typeof live.requestInFlightSince).toBe('number');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { SubscriptionGcModule } from '../src/modules/subscription-gc-module.js';
|
|
3
|
+
import type { ModuleContext, ProcessEvent, ProcessState } from '@animalabs/agent-framework';
|
|
4
|
+
|
|
5
|
+
function mockCtx() {
|
|
6
|
+
let state: unknown = null;
|
|
7
|
+
const traceListeners: Array<(e: { type: string; agentName?: string }) => void> = [];
|
|
8
|
+
const toolCalls: Array<{ name: string; input: Record<string, unknown> }> = [];
|
|
9
|
+
const ctx = {
|
|
10
|
+
getState: () => state,
|
|
11
|
+
setState: (s: unknown) => {
|
|
12
|
+
state = s;
|
|
13
|
+
},
|
|
14
|
+
onTrace: (l: (e: { type: string }) => void) => {
|
|
15
|
+
traceListeners.push(l);
|
|
16
|
+
return () => {};
|
|
17
|
+
},
|
|
18
|
+
callTool: async (call: { name: string; input: Record<string, unknown> }) => {
|
|
19
|
+
toolCalls.push(call);
|
|
20
|
+
return { success: true, data: {}, isError: false };
|
|
21
|
+
},
|
|
22
|
+
} as unknown as ModuleContext;
|
|
23
|
+
return { ctx, traceListeners, toolCalls, getState: () => state };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ambient(
|
|
27
|
+
channelId: string,
|
|
28
|
+
text: string,
|
|
29
|
+
opts: { isMention?: boolean; isDM?: boolean; serverId?: string } = {},
|
|
30
|
+
): ProcessEvent {
|
|
31
|
+
return {
|
|
32
|
+
type: 'mcpl:push-event',
|
|
33
|
+
serverId: opts.serverId ?? 'discord',
|
|
34
|
+
featureSet: 'discord.messaging',
|
|
35
|
+
eventId: 'e1',
|
|
36
|
+
content: [{ type: 'text', text }],
|
|
37
|
+
origin: {
|
|
38
|
+
source: 'discord',
|
|
39
|
+
channelId,
|
|
40
|
+
isMention: !!opts.isMention,
|
|
41
|
+
isDM: !!opts.isDM,
|
|
42
|
+
},
|
|
43
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
44
|
+
inferenceId: 'i1',
|
|
45
|
+
} as unknown as ProcessEvent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PS = {} as ProcessState;
|
|
49
|
+
|
|
50
|
+
describe('SubscriptionGcModule', () => {
|
|
51
|
+
test('auto-unsubscribes when ambient crosses the limit', async () => {
|
|
52
|
+
const m = new SubscriptionGcModule({ defaultLimitChars: 10, serverId: 'discord' });
|
|
53
|
+
const { ctx, toolCalls } = mockCtx();
|
|
54
|
+
await m.start(ctx);
|
|
55
|
+
|
|
56
|
+
let r = await m.onProcess(ambient('c1', 'abcdef'), PS); // 6 — under
|
|
57
|
+
expect(toolCalls.length).toBe(0);
|
|
58
|
+
expect(r.addMessages).toBeUndefined();
|
|
59
|
+
|
|
60
|
+
r = await m.onProcess(ambient('c1', 'ghijkl'), PS); // +6 = 12 > 10 → unsub
|
|
61
|
+
expect(toolCalls.length).toBe(1);
|
|
62
|
+
expect(toolCalls[0].name).toBe('mcpl--discord--unsubscribe_channel');
|
|
63
|
+
expect(toolCalls[0].input.channelId).toBe('c1');
|
|
64
|
+
expect(r.addMessages?.length).toBe(1);
|
|
65
|
+
|
|
66
|
+
await m.stop();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('does not count mentions, DMs, or other servers', async () => {
|
|
70
|
+
const m = new SubscriptionGcModule({ defaultLimitChars: 5 });
|
|
71
|
+
const { ctx, toolCalls } = mockCtx();
|
|
72
|
+
await m.start(ctx);
|
|
73
|
+
|
|
74
|
+
await m.onProcess(ambient('c1', 'longmention', { isMention: true }), PS);
|
|
75
|
+
await m.onProcess(ambient('c1', 'longdm', { isDM: true }), PS);
|
|
76
|
+
await m.onProcess(ambient('c1', 'otherserver', { serverId: 'slack' }), PS);
|
|
77
|
+
expect(toolCalls.length).toBe(0);
|
|
78
|
+
|
|
79
|
+
await m.stop();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an activation resets all counters', async () => {
|
|
83
|
+
const m = new SubscriptionGcModule({ defaultLimitChars: 10 });
|
|
84
|
+
const { ctx, traceListeners, toolCalls } = mockCtx();
|
|
85
|
+
await m.start(ctx);
|
|
86
|
+
|
|
87
|
+
await m.onProcess(ambient('c1', 'abcdefgh'), PS); // 8 — under
|
|
88
|
+
traceListeners[0]({ type: 'inference:started', agentName: 'lena' }); // reset
|
|
89
|
+
await m.onProcess(ambient('c1', 'abcdefgh'), PS); // 8 again, not 16 → no unsub
|
|
90
|
+
expect(toolCalls.length).toBe(0);
|
|
91
|
+
|
|
92
|
+
await m.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('"off" override pins a channel; counters persist across restart', async () => {
|
|
96
|
+
const m = new SubscriptionGcModule({ defaultLimitChars: 5 });
|
|
97
|
+
const { ctx, toolCalls, getState } = mockCtx();
|
|
98
|
+
await m.start(ctx);
|
|
99
|
+
|
|
100
|
+
await m.handleToolCall({
|
|
101
|
+
id: 't1',
|
|
102
|
+
name: 'set_channel_idle_limit',
|
|
103
|
+
input: { channelId: 'c1', limit: 'off' },
|
|
104
|
+
});
|
|
105
|
+
await m.onProcess(ambient('c1', 'waytoolongambient'), PS);
|
|
106
|
+
expect(toolCalls.length).toBe(0); // pinned → never unsubscribed
|
|
107
|
+
|
|
108
|
+
// A different channel still accrues, and state is persisted.
|
|
109
|
+
await m.onProcess(ambient('c2', 'abc'), PS);
|
|
110
|
+
const persisted = getState() as { overrides: Record<string, unknown>; counters: Record<string, number> };
|
|
111
|
+
expect(persisted.overrides.c1).toBe('off');
|
|
112
|
+
|
|
113
|
+
// Simulate restart: a new module loads the persisted state (counters carry
|
|
114
|
+
// across — a restart is not an activation).
|
|
115
|
+
const m2 = new SubscriptionGcModule({ defaultLimitChars: 5 });
|
|
116
|
+
const restart = mockCtx();
|
|
117
|
+
// Feed the prior persisted state into the "restarted" module.
|
|
118
|
+
const ctx2 = {
|
|
119
|
+
getState: () => persisted,
|
|
120
|
+
setState: () => {},
|
|
121
|
+
onTrace: () => () => {},
|
|
122
|
+
callTool: async (c: { name: string; input: Record<string, unknown> }) => {
|
|
123
|
+
restart.toolCalls.push(c);
|
|
124
|
+
return { success: true, data: {}, isError: false };
|
|
125
|
+
},
|
|
126
|
+
} as unknown as ModuleContext;
|
|
127
|
+
await m2.start(ctx2);
|
|
128
|
+
// c2 was at 3; +3 = 6 > 5 → unsubscribe (counter survived the "restart")
|
|
129
|
+
await m2.onProcess(ambient('c2', 'def'), PS);
|
|
130
|
+
expect(restart.toolCalls.length).toBe(1);
|
|
131
|
+
expect(restart.toolCalls[0].input.channelId).toBe('c2');
|
|
132
|
+
|
|
133
|
+
await m.stop();
|
|
134
|
+
await m2.stop();
|
|
135
|
+
});
|
|
136
|
+
});
|