@animalabs/connectome-host 0.3.10 → 0.4.0
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/CHANGELOG.md +81 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +4 -4
- package/src/commands.ts +50 -4
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +3 -0
- package/src/index.ts +9 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/recipe.ts +58 -1
- package/src/tui.ts +363 -104
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { handleCommand, createBranchState } from '../src/commands.js';
|
|
3
|
+
|
|
4
|
+
// Regression tests for /checkpoint + /restore POSITION semantics. The
|
|
5
|
+
// original bug: checkpoints stored only a branch name, so /restore switched
|
|
6
|
+
// to the branch HEAD — including everything after the checkpoint — rolling
|
|
7
|
+
// back nothing, silently. These tests run over a stub ContextManager that
|
|
8
|
+
// mimics Chronicle's contract: branchAt resolves ids against the CURRENT
|
|
9
|
+
// branch's view of the log and throws for unreachable ids.
|
|
10
|
+
|
|
11
|
+
interface StubMessage { id: string; participant: string; content: unknown[] }
|
|
12
|
+
|
|
13
|
+
function makeStubWorld() {
|
|
14
|
+
const messagesByBranch = new Map<string, StubMessage[]>([['main', []]]);
|
|
15
|
+
let currentName = 'main';
|
|
16
|
+
let msgCounter = 0;
|
|
17
|
+
|
|
18
|
+
const cm = {
|
|
19
|
+
currentBranch: () => ({ id: currentName, name: currentName, head: messagesByBranch.get(currentName)!.length }),
|
|
20
|
+
listBranches: () => [...messagesByBranch.keys()].map(name => ({ id: name, name, head: messagesByBranch.get(name)!.length })),
|
|
21
|
+
queryMessages: (_q: unknown) => ({ messages: messagesByBranch.get(currentName)! }),
|
|
22
|
+
branchAt: (messageId: string, newName: string): string => {
|
|
23
|
+
const msgs = messagesByBranch.get(currentName)!;
|
|
24
|
+
const idx = msgs.findIndex(m => m.id === messageId);
|
|
25
|
+
if (idx === -1) throw new Error(`Message not found: ${messageId}`);
|
|
26
|
+
messagesByBranch.set(newName, msgs.slice(0, idx + 1));
|
|
27
|
+
return newName;
|
|
28
|
+
},
|
|
29
|
+
switchBranch: async (name: string): Promise<void> => {
|
|
30
|
+
if (!messagesByBranch.has(name)) throw new Error(`No such branch: ${name}`);
|
|
31
|
+
currentName = name;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const addMessage = (participant = 'user'): StubMessage => {
|
|
36
|
+
const msg: StubMessage = { id: `m${++msgCounter}`, participant, content: [] };
|
|
37
|
+
messagesByBranch.get(currentName)!.push(msg);
|
|
38
|
+
return msg;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const app = {
|
|
42
|
+
framework: {
|
|
43
|
+
getAgent: () => undefined,
|
|
44
|
+
getAllAgents: () => [{ getContextManager: () => cm }],
|
|
45
|
+
getAllModules: () => [],
|
|
46
|
+
},
|
|
47
|
+
branchState: createBranchState(),
|
|
48
|
+
userMessageCount: 0,
|
|
49
|
+
} as any;
|
|
50
|
+
|
|
51
|
+
return { cm, app, addMessage, branchCount: () => messagesByBranch.size, currentName: () => currentName };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('/checkpoint + /restore position semantics', () => {
|
|
55
|
+
test('checkpoint records the last message id, not just the branch', () => {
|
|
56
|
+
const { app, addMessage } = makeStubWorld();
|
|
57
|
+
addMessage();
|
|
58
|
+
const last = addMessage('agent');
|
|
59
|
+
handleCommand('/checkpoint cp', app);
|
|
60
|
+
const point = app.branchState.checkpoints.get('cp');
|
|
61
|
+
expect(point.branchName).toBe('main');
|
|
62
|
+
expect(point.messageId).toBe(last.id);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('restore rolls back to the checkpoint position, not the branch head', async () => {
|
|
66
|
+
const { app, addMessage, currentName } = makeStubWorld();
|
|
67
|
+
addMessage();
|
|
68
|
+
const cpMsg = addMessage('agent');
|
|
69
|
+
handleCommand('/checkpoint cp', app);
|
|
70
|
+
addMessage();
|
|
71
|
+
addMessage('agent'); // work after the checkpoint that must be rolled back
|
|
72
|
+
|
|
73
|
+
const result = handleCommand('/restore cp', app);
|
|
74
|
+
const outcome = await result.asyncWork!;
|
|
75
|
+
expect(outcome.branchChanged).toBe(true);
|
|
76
|
+
expect(currentName().startsWith('restore-cp-')).toBe(true);
|
|
77
|
+
|
|
78
|
+
const { messages } = (app.framework.getAllAgents()[0].getContextManager() as any).queryMessages({});
|
|
79
|
+
expect(messages[messages.length - 1].id).toBe(cpMsg.id);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('restoring while already at the checkpoint is a no-op — no branch minted', async () => {
|
|
83
|
+
const { app, addMessage, branchCount } = makeStubWorld();
|
|
84
|
+
addMessage();
|
|
85
|
+
addMessage('agent');
|
|
86
|
+
handleCommand('/checkpoint cp', app);
|
|
87
|
+
addMessage();
|
|
88
|
+
|
|
89
|
+
await handleCommand('/restore cp', app).asyncWork!;
|
|
90
|
+
const branchesAfterFirst = branchCount();
|
|
91
|
+
|
|
92
|
+
// Second restore at the same position: the guard must hold even though
|
|
93
|
+
// we're now on restore-cp-… rather than the original branch (the guard
|
|
94
|
+
// used to compare branch names and died after the first restore,
|
|
95
|
+
// minting a sibling branch per repeat).
|
|
96
|
+
const second = handleCommand('/restore cp', app);
|
|
97
|
+
expect(second.asyncWork).toBeUndefined();
|
|
98
|
+
expect(second.lines[0]!.text).toContain('Already at checkpoint');
|
|
99
|
+
expect(branchCount()).toBe(branchesAfterFirst);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('unreachable checkpoint position degrades to the branch head with a note', async () => {
|
|
103
|
+
const { app, cm, addMessage, currentName } = makeStubWorld();
|
|
104
|
+
const early = addMessage();
|
|
105
|
+
addMessage('agent');
|
|
106
|
+
handleCommand('/checkpoint cp', app);
|
|
107
|
+
|
|
108
|
+
// Simulate /undo: branch truncated BEFORE the checkpoint message, so the
|
|
109
|
+
// checkpoint id is unreachable from the current branch's view.
|
|
110
|
+
const undoBranch = cm.branchAt(early.id, 'undo-1');
|
|
111
|
+
await cm.switchBranch(undoBranch);
|
|
112
|
+
|
|
113
|
+
const result = handleCommand('/restore cp', app);
|
|
114
|
+
const outcome = await result.asyncWork!;
|
|
115
|
+
expect(currentName()).toBe('main');
|
|
116
|
+
expect(outcome.lines.some(l => l.text.includes('unreachable'))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -94,7 +94,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
94
94
|
await m.stop();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
test('"off" override
|
|
97
|
+
test('"off" override disables auto-close; counters persist across restart', async () => {
|
|
98
98
|
const m = new SubscriptionGcModule({ defaultLimitChars: 5 });
|
|
99
99
|
const { ctx, toolCalls, getState } = mockCtx();
|
|
100
100
|
await m.start(ctx);
|
|
@@ -136,3 +136,158 @@ describe('SubscriptionGcModule', () => {
|
|
|
136
136
|
await m2.stop();
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
describe('agent_settings extension (channel_idle_limits)', () => {
|
|
141
|
+
test('declares no standalone tools but keeps the old names routable', async () => {
|
|
142
|
+
const mod = new SubscriptionGcModule();
|
|
143
|
+
await mod.start(mockCtx().ctx as unknown as ModuleContext);
|
|
144
|
+
expect(mod.getTools()).toEqual([]);
|
|
145
|
+
// Undeclared ≠ dead: agent muscle memory routes via the old name.
|
|
146
|
+
const result = await mod.handleToolCall({
|
|
147
|
+
id: 't1',
|
|
148
|
+
name: 'set_channel_idle_limit',
|
|
149
|
+
input: { channelId: 'C1', limit: 'off' },
|
|
150
|
+
});
|
|
151
|
+
expect(result.success).toBe(true);
|
|
152
|
+
const ext = mod.getAgentSettingsExtension();
|
|
153
|
+
expect(ext.get('agent').channel_idle_limits).toEqual({ C1: 'off' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('get reports read-only status: default, counters, pins', async () => {
|
|
157
|
+
const mod = new SubscriptionGcModule({ defaultLimitChars: 10 });
|
|
158
|
+
const { ctx } = mockCtx();
|
|
159
|
+
await mod.start(ctx);
|
|
160
|
+
await mod.onProcess(ambient('c1', 'abc'), PS);
|
|
161
|
+
await mod.handleToolCall({
|
|
162
|
+
id: 't1',
|
|
163
|
+
name: 'pin_channel_idle_limit',
|
|
164
|
+
input: { channelId: 'C9', pinned: true },
|
|
165
|
+
});
|
|
166
|
+
const ext = mod.getAgentSettingsExtension();
|
|
167
|
+
expect(ext.get('agent')).toEqual({
|
|
168
|
+
channel_idle_limits: {},
|
|
169
|
+
channel_idle_default: 10,
|
|
170
|
+
channel_idle_counters: { 'discord:g1:c1': 3 },
|
|
171
|
+
channel_idle_pinned: ['C9'],
|
|
172
|
+
});
|
|
173
|
+
await mod.stop();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('update merges per entry: number, off, default/null', async () => {
|
|
177
|
+
const mod = new SubscriptionGcModule();
|
|
178
|
+
await mod.start(mockCtx().ctx as unknown as ModuleContext);
|
|
179
|
+
const ext = mod.getAgentSettingsExtension();
|
|
180
|
+
ext.update('agent', { channel_idle_limits: { A: 5000, B: 'off', C: '12000' } });
|
|
181
|
+
expect(ext.get('agent').channel_idle_limits).toEqual({ A: 5000, B: 'off', C: 12000 });
|
|
182
|
+
// merge: only mentioned entries change; default/null clear
|
|
183
|
+
ext.update('agent', { channel_idle_limits: { A: 'default', B: null } });
|
|
184
|
+
expect(ext.get('agent').channel_idle_limits).toEqual({ C: 12000 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('update rejects junk values with a clear error', async () => {
|
|
188
|
+
const mod = new SubscriptionGcModule();
|
|
189
|
+
await mod.start(mockCtx().ctx as unknown as ModuleContext);
|
|
190
|
+
const ext = mod.getAgentSettingsExtension();
|
|
191
|
+
expect(() => ext.update('agent', { channel_idle_limits: { A: -3 } })).toThrow(/positive/);
|
|
192
|
+
expect(() => ext.update('agent', { channel_idle_limits: 'off' })).toThrow(/object/);
|
|
193
|
+
expect(() => ext.update('agent', { channel_idle_limits: ['A'] })).toThrow(/object/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a partially-invalid patch applies nothing', async () => {
|
|
197
|
+
const mod = new SubscriptionGcModule();
|
|
198
|
+
const { ctx, getState } = mockCtx();
|
|
199
|
+
await mod.start(ctx);
|
|
200
|
+
const ext = mod.getAgentSettingsExtension();
|
|
201
|
+
// A is valid, B is junk: the whole patch must be rejected — per-entry
|
|
202
|
+
// application would leave A live (limitFor reads the map directly) and
|
|
203
|
+
// persisted by the next flush, after the agent was told the update failed.
|
|
204
|
+
expect(() => ext.update('agent', { channel_idle_limits: { A: 5000, B: -3 } })).toThrow(
|
|
205
|
+
/positive/,
|
|
206
|
+
);
|
|
207
|
+
expect(ext.get('agent').channel_idle_limits).toEqual({});
|
|
208
|
+
const persisted = getState() as { overrides: Record<string, unknown> } | null;
|
|
209
|
+
expect(persisted?.overrides ?? {}).toEqual({});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('reset clears all overrides', async () => {
|
|
213
|
+
const mod = new SubscriptionGcModule();
|
|
214
|
+
await mod.start(mockCtx().ctx as unknown as ModuleContext);
|
|
215
|
+
const ext = mod.getAgentSettingsExtension();
|
|
216
|
+
ext.update('agent', { channel_idle_limits: { A: 5000, B: 'off' } });
|
|
217
|
+
expect(ext.reset('agent').channel_idle_limits).toEqual({});
|
|
218
|
+
// keyed reset also clears
|
|
219
|
+
ext.update('agent', { channel_idle_limits: { A: 5000 } });
|
|
220
|
+
expect(ext.reset('agent', ['channel_idle_limits']).channel_idle_limits).toEqual({});
|
|
221
|
+
// reset for other keys leaves ours alone
|
|
222
|
+
ext.update('agent', { channel_idle_limits: { A: 5000 } });
|
|
223
|
+
expect(ext.reset('agent', ['reasoning_enabled']).channel_idle_limits).toEqual({ A: 5000 });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('system pins (pin_channel_idle_limit)', () => {
|
|
228
|
+
test('reset-all clears overrides but not pins; pinned channel stays open', async () => {
|
|
229
|
+
const mod = new SubscriptionGcModule({ defaultLimitChars: 5 });
|
|
230
|
+
const { ctx, toolCalls } = mockCtx();
|
|
231
|
+
await mod.start(ctx);
|
|
232
|
+
// ChannelMode's debounced-mode step 3.
|
|
233
|
+
await mod.handleToolCall({
|
|
234
|
+
id: 't1',
|
|
235
|
+
name: 'pin_channel_idle_limit',
|
|
236
|
+
input: { channelId: 'discord:g1:c1', pinned: true },
|
|
237
|
+
});
|
|
238
|
+
const ext = mod.getAgentSettingsExtension();
|
|
239
|
+
ext.update('agent', { channel_idle_limits: { A: 5000 } });
|
|
240
|
+
// Blanket `agent_settings reset` (e.g. to restore default reasoning).
|
|
241
|
+
const after = ext.reset('agent');
|
|
242
|
+
expect(after.channel_idle_limits).toEqual({});
|
|
243
|
+
expect(after.channel_idle_pinned).toEqual(['discord:g1:c1']);
|
|
244
|
+
// The pinned channel must NOT auto-close after the reset.
|
|
245
|
+
await mod.onProcess(ambient('c1', 'waytoolongambienttraffic'), PS);
|
|
246
|
+
expect(toolCalls.length).toBe(0);
|
|
247
|
+
await mod.stop();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('pin/unpin round-trip preserves an agent override', async () => {
|
|
251
|
+
const mod = new SubscriptionGcModule({ defaultLimitChars: 5 });
|
|
252
|
+
const { ctx, toolCalls } = mockCtx();
|
|
253
|
+
await mod.start(ctx);
|
|
254
|
+
const ext = mod.getAgentSettingsExtension();
|
|
255
|
+
ext.update('agent', { channel_idle_limits: { 'discord:g1:c1': 9 } });
|
|
256
|
+
await mod.handleToolCall({
|
|
257
|
+
id: 't1',
|
|
258
|
+
name: 'pin_channel_idle_limit',
|
|
259
|
+
input: { channelId: 'discord:g1:c1', pinned: true },
|
|
260
|
+
});
|
|
261
|
+
await mod.onProcess(ambient('c1', 'longerthannine'), PS); // pinned → no close
|
|
262
|
+
expect(toolCalls.length).toBe(0);
|
|
263
|
+
await mod.handleToolCall({
|
|
264
|
+
id: 't2',
|
|
265
|
+
name: 'pin_channel_idle_limit',
|
|
266
|
+
input: { channelId: 'discord:g1:c1', pinned: false },
|
|
267
|
+
});
|
|
268
|
+
expect(ext.get('agent').channel_idle_limits).toEqual({ 'discord:g1:c1': 9 });
|
|
269
|
+
// Override (9) is live again: counter is at 14 from the pinned message,
|
|
270
|
+
// so the next ambient char crosses it.
|
|
271
|
+
await mod.onProcess(ambient('c1', 'x'), PS);
|
|
272
|
+
expect(toolCalls.length).toBe(1);
|
|
273
|
+
expect(toolCalls[0].name).toBe('channel_close');
|
|
274
|
+
await mod.stop();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('pin tool validates its input', async () => {
|
|
278
|
+
const mod = new SubscriptionGcModule();
|
|
279
|
+
await mod.start(mockCtx().ctx as unknown as ModuleContext);
|
|
280
|
+
const bad1 = await mod.handleToolCall({
|
|
281
|
+
id: 't1',
|
|
282
|
+
name: 'pin_channel_idle_limit',
|
|
283
|
+
input: { pinned: true },
|
|
284
|
+
});
|
|
285
|
+
expect(bad1.success).toBe(false);
|
|
286
|
+
const bad2 = await mod.handleToolCall({
|
|
287
|
+
id: 't2',
|
|
288
|
+
name: 'pin_channel_idle_limit',
|
|
289
|
+
input: { channelId: 'C1', pinned: 'yes' },
|
|
290
|
+
});
|
|
291
|
+
expect(bad2.success).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { resolveQuitConfirm } from '../src/tui.js';
|
|
3
|
+
|
|
4
|
+
// Pins the quit-prompt semantics. The pre-fix behavior — "anything that
|
|
5
|
+
// isn't n/no/cancel → kill every fleet child and exit" — meant a user who
|
|
6
|
+
// forgot the armed prompt and typed a normal chat message killed the fleet.
|
|
7
|
+
// The default for arbitrary input MUST stay 'cancel-keep-input'; a refactor
|
|
8
|
+
// that flips it back should fail here, loudly.
|
|
9
|
+
describe('resolveQuitConfirm', () => {
|
|
10
|
+
test('only explicit consent kills', () => {
|
|
11
|
+
expect(resolveQuitConfirm('y')).toBe('kill');
|
|
12
|
+
expect(resolveQuitConfirm('yes')).toBe('kill');
|
|
13
|
+
expect(resolveQuitConfirm('YES')).toBe('kill');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('re-typing the quit command confirms, not cancels', () => {
|
|
17
|
+
expect(resolveQuitConfirm('/quit')).toBe('kill');
|
|
18
|
+
expect(resolveQuitConfirm('/q')).toBe('kill');
|
|
19
|
+
expect(resolveQuitConfirm('quit')).toBe('kill');
|
|
20
|
+
expect(resolveQuitConfirm('q')).toBe('kill');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('detach', () => {
|
|
24
|
+
expect(resolveQuitConfirm('d')).toBe('detach');
|
|
25
|
+
expect(resolveQuitConfirm('detach')).toBe('detach');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('explicit and empty cancels (Enter takes the advertised [y/N/d] default)', () => {
|
|
29
|
+
expect(resolveQuitConfirm('')).toBe('cancel');
|
|
30
|
+
expect(resolveQuitConfirm(' ')).toBe('cancel');
|
|
31
|
+
expect(resolveQuitConfirm('n')).toBe('cancel');
|
|
32
|
+
expect(resolveQuitConfirm('no')).toBe('cancel');
|
|
33
|
+
expect(resolveQuitConfirm('cancel')).toBe('cancel');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a real message cancels AND must be restored to the input', () => {
|
|
37
|
+
expect(resolveQuitConfirm('actually, first summarize what miner found')).toBe('cancel-keep-input');
|
|
38
|
+
expect(resolveQuitConfirm('[paste #1: "…" 40000ch, 900L]')).toBe('cancel-keep-input');
|
|
39
|
+
expect(resolveQuitConfirm('/status')).toBe('cancel-keep-input');
|
|
40
|
+
expect(resolveQuitConfirm('yeah')).toBe('cancel-keep-input');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { shortAgentName } from '../src/tui.js';
|
|
3
|
+
|
|
4
|
+
// Guards full↔short agent-name resolution against the naming schemes the
|
|
5
|
+
// SubagentModule actually produces (see its spawn/fork paths). A helper that
|
|
6
|
+
// misses one scheme silently breaks fleet-tree attribution for that agent
|
|
7
|
+
// type — forks slipped through exactly this way once.
|
|
8
|
+
describe('shortAgentName', () => {
|
|
9
|
+
test('spawn names: spawn-{name}-{ts}', () => {
|
|
10
|
+
expect(shortAgentName('spawn-web-1753221234567')).toBe('web');
|
|
11
|
+
expect(shortAgentName('spawn-zulip-reader-1753221234567')).toBe('zulip-reader');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('fork names: {name}-d{depth}-{ts}, no fork- prefix', () => {
|
|
15
|
+
expect(shortAgentName('web-d1-1753221234567')).toBe('web');
|
|
16
|
+
expect(shortAgentName('zulip-reader-d3-1753221234567')).toBe('zulip-reader');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('fork retry names: {name}-d{depth}-retry{n}-{ts}', () => {
|
|
20
|
+
expect(shortAgentName('web-d2-retry1-1753221234567')).toBe('web');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('bare trailing -retryN (historical defensive strip)', () => {
|
|
24
|
+
expect(shortAgentName('web-retry2')).toBe('web');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('names that are substrings of each other stay distinct', () => {
|
|
28
|
+
expect(shortAgentName('websearch-d1-1753221234567')).toBe('websearch');
|
|
29
|
+
expect(shortAgentName('spawn-websearch-1753221234567')).toBe('websearch');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('plain names pass through', () => {
|
|
33
|
+
expect(shortAgentName('miner')).toBe('miner');
|
|
34
|
+
expect(shortAgentName('web')).toBe('web');
|
|
35
|
+
});
|
|
36
|
+
});
|