@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,386 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
linearize,
|
|
4
|
+
transformContent,
|
|
5
|
+
parseToggleSpec,
|
|
6
|
+
splitMixedToolMessages,
|
|
7
|
+
} from '../scripts/import-claudeai-export.js';
|
|
8
|
+
import type { ContentBlock } from '@animalabs/membrane';
|
|
9
|
+
|
|
10
|
+
const ROOT = '00000000-0000-4000-8000-000000000000';
|
|
11
|
+
|
|
12
|
+
function mkMsg(opts: {
|
|
13
|
+
uuid: string;
|
|
14
|
+
parent?: string;
|
|
15
|
+
created?: string;
|
|
16
|
+
sender?: 'human' | 'assistant';
|
|
17
|
+
content?: unknown[];
|
|
18
|
+
text?: string;
|
|
19
|
+
}) {
|
|
20
|
+
return {
|
|
21
|
+
uuid: opts.uuid,
|
|
22
|
+
parent_message_uuid: opts.parent ?? ROOT,
|
|
23
|
+
sender: opts.sender ?? 'human',
|
|
24
|
+
text: opts.text ?? '',
|
|
25
|
+
content: (opts.content ?? []) as never[],
|
|
26
|
+
attachments: [],
|
|
27
|
+
files: [],
|
|
28
|
+
created_at: opts.created ?? '2026-01-01T00:00:00.000Z',
|
|
29
|
+
updated_at: opts.created ?? '2026-01-01T00:00:00.000Z',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('linearize', () => {
|
|
34
|
+
test('returns messages in topological order for an unbranched conversation', () => {
|
|
35
|
+
const msgs = [
|
|
36
|
+
mkMsg({ uuid: 'a', parent: ROOT, created: '2026-01-01T00:00:01Z' }),
|
|
37
|
+
mkMsg({ uuid: 'b', parent: 'a', created: '2026-01-01T00:00:02Z' }),
|
|
38
|
+
mkMsg({ uuid: 'c', parent: 'b', created: '2026-01-01T00:00:03Z' }),
|
|
39
|
+
];
|
|
40
|
+
const { path, branched } = linearize(msgs);
|
|
41
|
+
expect(branched).toBe(false);
|
|
42
|
+
expect(path.map((m) => m.uuid)).toEqual(['a', 'b', 'c']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('at a single fork, picks the subtree with the latest-leaf timestamp', () => {
|
|
46
|
+
// Tree: a
|
|
47
|
+
// / \
|
|
48
|
+
// b c
|
|
49
|
+
// | |
|
|
50
|
+
// b2 c2 (c2 is more recent → expect a, c, c2)
|
|
51
|
+
const msgs = [
|
|
52
|
+
mkMsg({ uuid: 'a', parent: ROOT, created: '2026-01-01T00:00:01Z' }),
|
|
53
|
+
mkMsg({ uuid: 'b', parent: 'a', created: '2026-01-01T00:00:02Z' }),
|
|
54
|
+
mkMsg({ uuid: 'b2', parent: 'b', created: '2026-01-01T00:00:03Z' }),
|
|
55
|
+
mkMsg({ uuid: 'c', parent: 'a', created: '2026-01-01T00:00:04Z' }),
|
|
56
|
+
mkMsg({ uuid: 'c2', parent: 'c', created: '2026-01-01T00:00:05Z' }),
|
|
57
|
+
];
|
|
58
|
+
const { path, branched } = linearize(msgs);
|
|
59
|
+
expect(branched).toBe(true);
|
|
60
|
+
expect(path.map((m) => m.uuid)).toEqual(['a', 'c', 'c2']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('with multiple forks at tied depths, picks deterministically by timestamp', () => {
|
|
64
|
+
// a -> {b -> b2 @ T=5, c -> c2 @ T=10}
|
|
65
|
+
// Both subtrees are depth 2; c-side has the later leaf, so we pick c.
|
|
66
|
+
const msgs = [
|
|
67
|
+
mkMsg({ uuid: 'a', parent: ROOT, created: '2026-01-01T00:00:01Z' }),
|
|
68
|
+
mkMsg({ uuid: 'b', parent: 'a', created: '2026-01-01T00:00:02Z' }),
|
|
69
|
+
mkMsg({ uuid: 'b2', parent: 'b', created: '2026-01-01T00:00:05Z' }),
|
|
70
|
+
mkMsg({ uuid: 'c', parent: 'a', created: '2026-01-01T00:00:03Z' }),
|
|
71
|
+
mkMsg({ uuid: 'c2', parent: 'c', created: '2026-01-01T00:00:10Z' }),
|
|
72
|
+
];
|
|
73
|
+
const { path } = linearize(msgs);
|
|
74
|
+
expect(path.map((m) => m.uuid)).toEqual(['a', 'c', 'c2']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('handles empty input', () => {
|
|
78
|
+
const { path, branched } = linearize([]);
|
|
79
|
+
expect(path).toEqual([]);
|
|
80
|
+
expect(branched).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('transformContent', () => {
|
|
85
|
+
test('text block round-trips as TextContent', () => {
|
|
86
|
+
const msg = mkMsg({
|
|
87
|
+
uuid: 'x',
|
|
88
|
+
sender: 'human',
|
|
89
|
+
content: [{ type: 'text', text: 'hello' }],
|
|
90
|
+
});
|
|
91
|
+
const out = transformContent(msg);
|
|
92
|
+
expect(out).toEqual([{ type: 'text', text: 'hello' }]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('thinking block becomes <recovered_thinking>-wrapped text', () => {
|
|
96
|
+
const msg = mkMsg({
|
|
97
|
+
uuid: 'x',
|
|
98
|
+
sender: 'assistant',
|
|
99
|
+
content: [{ type: 'thinking', thinking: 'I was reasoning about X' }],
|
|
100
|
+
});
|
|
101
|
+
const out = transformContent(msg);
|
|
102
|
+
expect(out).toHaveLength(1);
|
|
103
|
+
const t = out[0]! as { type: 'text'; text: string };
|
|
104
|
+
expect(t.type).toBe('text');
|
|
105
|
+
expect(t.text).toContain('<recovered_thinking>');
|
|
106
|
+
expect(t.text).toContain('I was reasoning about X');
|
|
107
|
+
expect(t.text).toContain('</recovered_thinking>');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('tool_use round-trips with id, name, input', () => {
|
|
111
|
+
const msg = mkMsg({
|
|
112
|
+
uuid: 'x',
|
|
113
|
+
sender: 'assistant',
|
|
114
|
+
content: [{ type: 'tool_use', id: 'tu_1', name: 'web_search', input: { q: 'rust' } }],
|
|
115
|
+
});
|
|
116
|
+
const out = transformContent(msg);
|
|
117
|
+
expect(out).toEqual([{ type: 'tool_use', id: 'tu_1', name: 'web_search', input: { q: 'rust' } }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('tool_result round-trips with toolUseId (renamed from tool_use_id)', () => {
|
|
121
|
+
const msg = mkMsg({
|
|
122
|
+
uuid: 'x',
|
|
123
|
+
sender: 'human',
|
|
124
|
+
content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'result text' }],
|
|
125
|
+
});
|
|
126
|
+
const out = transformContent(msg);
|
|
127
|
+
expect(out).toEqual([{ type: 'tool_result', toolUseId: 'tu_1', content: 'result text' }]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('unknown block types are preserved as grep-able placeholder text', () => {
|
|
131
|
+
const msg = mkMsg({
|
|
132
|
+
uuid: 'x',
|
|
133
|
+
sender: 'assistant',
|
|
134
|
+
content: [{ type: 'server_tool_use', some: 'payload' } as never],
|
|
135
|
+
});
|
|
136
|
+
const out = transformContent(msg);
|
|
137
|
+
expect(out).toHaveLength(1);
|
|
138
|
+
const t = out[0]! as { type: 'text'; text: string };
|
|
139
|
+
expect(t.text).toContain('[unknown_block');
|
|
140
|
+
expect(t.text).toContain('"server_tool_use"');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('attachments prepend as <attachment>-wrapped text', () => {
|
|
144
|
+
const msg = {
|
|
145
|
+
...mkMsg({ uuid: 'x', sender: 'human' }),
|
|
146
|
+
attachments: [
|
|
147
|
+
{ file_name: 'notes.txt', file_type: 'txt', file_size: 42, extracted_content: 'NOTES' },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
const out = transformContent(msg);
|
|
151
|
+
const first = out[0]! as { type: 'text'; text: string };
|
|
152
|
+
expect(first.text).toContain('<attachment name="notes.txt"');
|
|
153
|
+
expect(first.text).toContain('size="42"');
|
|
154
|
+
expect(first.text).toContain('NOTES');
|
|
155
|
+
expect(first.text).toContain('</attachment>');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('escapeAttr handles ampersand correctly (canonical order)', () => {
|
|
159
|
+
const msg = {
|
|
160
|
+
...mkMsg({ uuid: 'x', sender: 'human' }),
|
|
161
|
+
attachments: [{ file_name: 'Q&A.txt', extracted_content: 'body' }],
|
|
162
|
+
};
|
|
163
|
+
const out = transformContent(msg);
|
|
164
|
+
const first = out[0]! as { type: 'text'; text: string };
|
|
165
|
+
expect(first.text).toContain('Q&A.txt');
|
|
166
|
+
expect(first.text).not.toContain('Q&A.txt');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('file references (no bytes) trail as placeholder text', () => {
|
|
170
|
+
const msg = {
|
|
171
|
+
...mkMsg({ uuid: 'x', sender: 'human' }),
|
|
172
|
+
files: [{ file_uuid: 'fu_1', file_name: 'image.png' }],
|
|
173
|
+
};
|
|
174
|
+
const out = transformContent(msg);
|
|
175
|
+
const last = out[out.length - 1]! as { type: 'text'; text: string };
|
|
176
|
+
expect(last.text).toContain('image.png');
|
|
177
|
+
expect(last.text).toContain('fu_1');
|
|
178
|
+
expect(last.text).toContain('bytes not in export');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('transformContent — null tool IDs', () => {
|
|
183
|
+
test('synthesizes a stable id for null-id tool_use blocks', () => {
|
|
184
|
+
const msg = mkMsg({
|
|
185
|
+
uuid: 'msg-A',
|
|
186
|
+
sender: 'assistant',
|
|
187
|
+
content: [
|
|
188
|
+
{ type: 'text', text: 'thinking out loud' },
|
|
189
|
+
{ type: 'tool_use', id: null, name: 'web_search', input: { q: 'hi' } },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
const out = transformContent(msg);
|
|
193
|
+
const tu = out.find((b) => b.type === 'tool_use') as Extract<ContentBlock, { type: 'tool_use' }>;
|
|
194
|
+
expect(tu).toBeDefined();
|
|
195
|
+
expect(typeof tu.id).toBe('string');
|
|
196
|
+
expect(tu.id.length).toBeGreaterThan(0);
|
|
197
|
+
expect(tu.id).toContain('msg-A');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('pairs null tool_use id with null tool_result.tool_use_id by FIFO', () => {
|
|
201
|
+
const msg = mkMsg({
|
|
202
|
+
uuid: 'msg-B',
|
|
203
|
+
sender: 'assistant',
|
|
204
|
+
content: [
|
|
205
|
+
{ type: 'tool_use', id: null, name: 'conversation_search', input: { q: 'foo' } },
|
|
206
|
+
{ type: 'tool_result', tool_use_id: null, content: 'result body' },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
const out = transformContent(msg);
|
|
210
|
+
const tu = out.find((b) => b.type === 'tool_use') as Extract<ContentBlock, { type: 'tool_use' }>;
|
|
211
|
+
const tr = out.find((b) => b.type === 'tool_result') as Extract<ContentBlock, { type: 'tool_result' }>;
|
|
212
|
+
expect(tu.id).toBe(tr.toolUseId);
|
|
213
|
+
expect(tu.id).toContain('msg-B');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('handles multiple bundled cycles in one message (FIFO pairing)', () => {
|
|
217
|
+
const msg = mkMsg({
|
|
218
|
+
uuid: 'msg-C',
|
|
219
|
+
sender: 'assistant',
|
|
220
|
+
content: [
|
|
221
|
+
{ type: 'tool_use', id: null, name: 'search_A', input: {} },
|
|
222
|
+
{ type: 'tool_result', tool_use_id: null, content: 'A' },
|
|
223
|
+
{ type: 'tool_use', id: null, name: 'search_B', input: {} },
|
|
224
|
+
{ type: 'tool_result', tool_use_id: null, content: 'B' },
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
const out = transformContent(msg);
|
|
228
|
+
const uses = out.filter((b) => b.type === 'tool_use') as Array<Extract<ContentBlock, { type: 'tool_use' }>>;
|
|
229
|
+
const results = out.filter((b) => b.type === 'tool_result') as Array<Extract<ContentBlock, { type: 'tool_result' }>>;
|
|
230
|
+
expect(uses).toHaveLength(2);
|
|
231
|
+
expect(results).toHaveLength(2);
|
|
232
|
+
expect(uses[0]!.id).toBe(results[0]!.toolUseId);
|
|
233
|
+
expect(uses[1]!.id).toBe(results[1]!.toolUseId);
|
|
234
|
+
expect(uses[0]!.id).not.toBe(uses[1]!.id);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('preserves explicit non-null ids unchanged', () => {
|
|
238
|
+
const msg = mkMsg({
|
|
239
|
+
uuid: 'msg-D',
|
|
240
|
+
sender: 'assistant',
|
|
241
|
+
content: [
|
|
242
|
+
{ type: 'tool_use', id: 'toolu_explicit_123', name: 'do', input: {} },
|
|
243
|
+
{ type: 'tool_result', tool_use_id: 'toolu_explicit_123', content: 'ok' },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
const out = transformContent(msg);
|
|
247
|
+
const tu = out.find((b) => b.type === 'tool_use') as Extract<ContentBlock, { type: 'tool_use' }>;
|
|
248
|
+
const tr = out.find((b) => b.type === 'tool_result') as Extract<ContentBlock, { type: 'tool_result' }>;
|
|
249
|
+
expect(tu.id).toBe('toolu_explicit_123');
|
|
250
|
+
expect(tr.toolUseId).toBe('toolu_explicit_123');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('emits an orphan id for tool_result with no preceding tool_use', () => {
|
|
254
|
+
const msg = mkMsg({
|
|
255
|
+
uuid: 'msg-E',
|
|
256
|
+
sender: 'assistant',
|
|
257
|
+
content: [{ type: 'tool_result', tool_use_id: null, content: 'orphaned' }],
|
|
258
|
+
});
|
|
259
|
+
const out = transformContent(msg);
|
|
260
|
+
const tr = out.find((b) => b.type === 'tool_result') as Extract<ContentBlock, { type: 'tool_result' }>;
|
|
261
|
+
expect(tr.toolUseId).toBeTruthy();
|
|
262
|
+
expect(tr.toolUseId).toContain('orphan');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('splitMixedToolMessages', () => {
|
|
267
|
+
function mkBlocks(spec: string): ContentBlock[] {
|
|
268
|
+
// shorthand: 't' = text, 'u' = tool_use, 'r' = tool_result
|
|
269
|
+
return spec.split('').map((c, i) => {
|
|
270
|
+
if (c === 't') return { type: 'text', text: `t${i}` };
|
|
271
|
+
if (c === 'u') return { type: 'tool_use', id: `u${i}`, name: 'fn', input: {} };
|
|
272
|
+
if (c === 'r') return { type: 'tool_result', toolUseId: `u${i}`, content: 'res' };
|
|
273
|
+
throw new Error(`bad char: ${c}`);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
test('passes through a user-source message unchanged', () => {
|
|
278
|
+
const blocks = mkBlocks('tt');
|
|
279
|
+
const out = splitMixedToolMessages(blocks, 'user');
|
|
280
|
+
expect(out).toEqual([{ participant: 'user', content: blocks }]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('returns empty for empty input', () => {
|
|
284
|
+
expect(splitMixedToolMessages([], 'agent')).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('passes through assistant message with no tool_result unchanged', () => {
|
|
288
|
+
const blocks = mkBlocks('tut');
|
|
289
|
+
const out = splitMixedToolMessages(blocks, 'agent');
|
|
290
|
+
expect(out).toHaveLength(1);
|
|
291
|
+
expect(out[0]!.participant).toBe('agent');
|
|
292
|
+
expect(out[0]!.content).toEqual(blocks);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('splits text-tool_use-tool_result-text into three messages', () => {
|
|
296
|
+
const blocks = mkBlocks('turt');
|
|
297
|
+
const out = splitMixedToolMessages(blocks, 'agent');
|
|
298
|
+
expect(out).toHaveLength(3);
|
|
299
|
+
expect(out[0]!.participant).toBe('agent');
|
|
300
|
+
expect(out[0]!.content.map((b) => b.type)).toEqual(['text', 'tool_use']);
|
|
301
|
+
expect(out[1]!.participant).toBe('user');
|
|
302
|
+
expect(out[1]!.content.map((b) => b.type)).toEqual(['tool_result']);
|
|
303
|
+
expect(out[2]!.participant).toBe('agent');
|
|
304
|
+
expect(out[2]!.content.map((b) => b.type)).toEqual(['text']);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('merges adjacent tool_results into one user message', () => {
|
|
308
|
+
const blocks = mkBlocks('uurr');
|
|
309
|
+
const out = splitMixedToolMessages(blocks, 'agent');
|
|
310
|
+
expect(out).toHaveLength(2);
|
|
311
|
+
expect(out[0]!.participant).toBe('agent');
|
|
312
|
+
expect(out[0]!.content.map((b) => b.type)).toEqual(['tool_use', 'tool_use']);
|
|
313
|
+
expect(out[1]!.participant).toBe('user');
|
|
314
|
+
expect(out[1]!.content.map((b) => b.type)).toEqual(['tool_result', 'tool_result']);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('handles tool_result at the start of an assistant message', () => {
|
|
318
|
+
const blocks = mkBlocks('rt');
|
|
319
|
+
const out = splitMixedToolMessages(blocks, 'agent');
|
|
320
|
+
expect(out).toHaveLength(2);
|
|
321
|
+
expect(out[0]!.participant).toBe('user');
|
|
322
|
+
expect(out[0]!.content.map((b) => b.type)).toEqual(['tool_result']);
|
|
323
|
+
expect(out[1]!.participant).toBe('agent');
|
|
324
|
+
expect(out[1]!.content.map((b) => b.type)).toEqual(['text']);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('handles tool_result at the end of an assistant message', () => {
|
|
328
|
+
const blocks = mkBlocks('tur');
|
|
329
|
+
const out = splitMixedToolMessages(blocks, 'agent');
|
|
330
|
+
expect(out).toHaveLength(2);
|
|
331
|
+
expect(out[0]!.participant).toBe('agent');
|
|
332
|
+
expect(out[0]!.content.map((b) => b.type)).toEqual(['text', 'tool_use']);
|
|
333
|
+
expect(out[1]!.participant).toBe('user');
|
|
334
|
+
expect(out[1]!.content.map((b) => b.type)).toEqual(['tool_result']);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('handles multiple alternating cycles in one message', () => {
|
|
338
|
+
const blocks = mkBlocks('urur');
|
|
339
|
+
const out = splitMixedToolMessages(blocks, 'agent');
|
|
340
|
+
// [u]-[r]-[u]-[r] → 4 messages: agent, user, agent, user
|
|
341
|
+
expect(out.map((m) => m.participant)).toEqual(['agent', 'user', 'agent', 'user']);
|
|
342
|
+
expect(out.flatMap((m) => m.content.map((b) => b.type))).toEqual([
|
|
343
|
+
'tool_use', 'tool_result', 'tool_use', 'tool_result',
|
|
344
|
+
]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('preserves custom agentName as participant of non-tool-result chunks', () => {
|
|
348
|
+
const blocks = mkBlocks('tur');
|
|
349
|
+
const out = splitMixedToolMessages(blocks, 'commander');
|
|
350
|
+
expect(out[0]!.participant).toBe('commander');
|
|
351
|
+
expect(out[1]!.participant).toBe('user');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('parseToggleSpec', () => {
|
|
356
|
+
test('handles single numbers', () => {
|
|
357
|
+
const s = parseToggleSpec('3', 10);
|
|
358
|
+
expect([...s].sort((a, b) => a - b)).toEqual([3]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('handles ranges', () => {
|
|
362
|
+
const s = parseToggleSpec('2-5', 10);
|
|
363
|
+
expect([...s].sort((a, b) => a - b)).toEqual([2, 3, 4, 5]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('mixes commas, ranges, and whitespace', () => {
|
|
367
|
+
const s = parseToggleSpec('1, 3-5 7', 10);
|
|
368
|
+
expect([...s].sort((a, b) => a - b)).toEqual([1, 3, 4, 5, 7]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('clamps to [1, max]', () => {
|
|
372
|
+
const s = parseToggleSpec('0-3 8-99', 5);
|
|
373
|
+
expect([...s].sort((a, b) => a - b)).toEqual([1, 2, 3]);
|
|
374
|
+
// 8-99 → low bound > max, nothing added
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('ignores non-numeric junk', () => {
|
|
378
|
+
const s = parseToggleSpec('foo 2 bar', 10);
|
|
379
|
+
expect([...s]).toEqual([2]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('returns empty set for empty input', () => {
|
|
383
|
+
expect(parseToggleSpec('', 10).size).toBe(0);
|
|
384
|
+
expect(parseToggleSpec(' ', 10).size).toBe(0);
|
|
385
|
+
});
|
|
386
|
+
});
|