@animalabs/connectome-host 0.7.3 → 0.8.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/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +401 -10
- package/CONTRIBUTING.md +47 -19
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +39 -1
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +6 -6
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/audit-module-optins.ts +288 -0
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +50 -4
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +24 -0
- package/src/index.ts +179 -64
- package/src/mcpl-config.ts +99 -1
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +89 -13
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/subagent-module.ts +18 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +787 -29
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1206 -0
- package/src/web/protocol.ts +75 -10
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +174 -0
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +41 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +64 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +160 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Health.tsx +61 -1
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- package/web/vite.config.ts +8 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe surface for per-channel conversation routing:
|
|
3
|
+
* schema validation of the `conversations` block, and the host-side mapping
|
|
4
|
+
* to the framework's ConversationRouterConfig (templateAgent auto-filled
|
|
5
|
+
* from the recipe agent, strategyFactory building fresh per-fork strategy
|
|
6
|
+
* instances from the recipe's own strategy config).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect } from 'bun:test';
|
|
9
|
+
import { validateRecipe, type Recipe } from '../src/recipe.js';
|
|
10
|
+
import { buildConversationsConfig } from '../src/framework-strategy.js';
|
|
11
|
+
|
|
12
|
+
function baseRecipe(extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
13
|
+
return {
|
|
14
|
+
name: 'Test',
|
|
15
|
+
agent: { name: 'sherlock', systemPrompt: 'test' },
|
|
16
|
+
...extra,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('validateRecipe — conversations schema', () => {
|
|
21
|
+
test('absent block is accepted', () => {
|
|
22
|
+
expect(() => validateRecipe(baseRecipe())).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('full valid block is accepted', () => {
|
|
26
|
+
expect(() => validateRecipe(baseRecipe({
|
|
27
|
+
conversations: {
|
|
28
|
+
bind: { dm: 'always', groupDm: 'always', channel: 'mention' },
|
|
29
|
+
trigger: { dm: 'always', groupDm: 'mention', channel: 'mention' },
|
|
30
|
+
idleTtlMs: 12 * 60 * 60 * 1000,
|
|
31
|
+
closurePrompt: 'Finalize the case report and post the answer.',
|
|
32
|
+
agentPrefix: 'case',
|
|
33
|
+
},
|
|
34
|
+
}))).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('empty object is accepted (all defaults come from the framework)', () => {
|
|
38
|
+
expect(() => validateRecipe(baseRecipe({ conversations: {} }))).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('non-object block is rejected', () => {
|
|
42
|
+
expect(() => validateRecipe(baseRecipe({ conversations: true }))).toThrow(/must be an object/);
|
|
43
|
+
expect(() => validateRecipe(baseRecipe({ conversations: [] }))).toThrow(/must be an object/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('unknown top-level conversation field is rejected rather than silently ignored', () => {
|
|
47
|
+
expect(() => validateRecipe(baseRecipe({
|
|
48
|
+
conversations: { idleTTLms: 1000 },
|
|
49
|
+
}))).toThrow(/unknown field \"idleTTLms\"/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('unknown channel kind is rejected', () => {
|
|
53
|
+
expect(() => validateRecipe(baseRecipe({
|
|
54
|
+
conversations: { bind: { thread: 'always' } },
|
|
55
|
+
}))).toThrow(/unknown channel kind "thread"/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('invalid bind rule is rejected', () => {
|
|
59
|
+
expect(() => validateRecipe(baseRecipe({
|
|
60
|
+
conversations: { bind: { channel: 'sometimes' } },
|
|
61
|
+
}))).toThrow(/conversations\.bind\.channel/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("trigger rule 'never' is rejected (bind-only rule)", () => {
|
|
65
|
+
expect(() => validateRecipe(baseRecipe({
|
|
66
|
+
conversations: { trigger: { channel: 'never' } },
|
|
67
|
+
}))).toThrow(/conversations\.trigger\.channel/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('non-positive idleTtlMs is rejected', () => {
|
|
71
|
+
expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: 0 } })))
|
|
72
|
+
.toThrow(/idleTtlMs/);
|
|
73
|
+
expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: -5 } })))
|
|
74
|
+
.toThrow(/idleTtlMs/);
|
|
75
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5]) {
|
|
76
|
+
expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: bad } })))
|
|
77
|
+
.toThrow(/idleTtlMs/);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('blank closurePrompt is rejected', () => {
|
|
82
|
+
expect(() => validateRecipe(baseRecipe({ conversations: { closurePrompt: ' ' } })))
|
|
83
|
+
.toThrow(/closurePrompt/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('agentPrefix with unsafe characters is rejected', () => {
|
|
87
|
+
for (const bad of ['with space', 'slash/py', 'dot.seg', '']) {
|
|
88
|
+
expect(() => validateRecipe(baseRecipe({ conversations: { agentPrefix: bad } })))
|
|
89
|
+
.toThrow(/agentPrefix/);
|
|
90
|
+
}
|
|
91
|
+
expect(() => validateRecipe(baseRecipe({ conversations: { agentPrefix: 'case-fork_2' } })))
|
|
92
|
+
.not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('buildConversationsConfig — recipe → FrameworkConfig mapping', () => {
|
|
97
|
+
const recipe = validateRecipe(baseRecipe({
|
|
98
|
+
conversations: {
|
|
99
|
+
bind: { channel: 'mention' },
|
|
100
|
+
idleTtlMs: 3600_000,
|
|
101
|
+
agentPrefix: 'case',
|
|
102
|
+
},
|
|
103
|
+
})) as Recipe;
|
|
104
|
+
|
|
105
|
+
test('returns undefined when the recipe has no conversations block', () => {
|
|
106
|
+
const bare = validateRecipe(baseRecipe()) as Recipe;
|
|
107
|
+
expect(buildConversationsConfig(bare, 'sherlock', 'model-x', 'UTC')).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('templateAgent is auto-filled with the host agent name', () => {
|
|
111
|
+
const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC');
|
|
112
|
+
expect(cfg?.templateAgent).toBe('sherlock');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('recipe fields pass through; omitted fields stay absent for framework defaults', () => {
|
|
116
|
+
const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC')!;
|
|
117
|
+
expect(cfg.bind).toEqual({ channel: 'mention' });
|
|
118
|
+
expect(cfg.idleTtlMs).toBe(3600_000);
|
|
119
|
+
expect(cfg.agentPrefix).toBe('case');
|
|
120
|
+
expect('trigger' in cfg).toBe(false);
|
|
121
|
+
expect('closurePrompt' in cfg).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('strategyFactory builds a FRESH strategy instance per call', () => {
|
|
125
|
+
const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC')!;
|
|
126
|
+
expect(cfg.strategyFactory).toBeDefined();
|
|
127
|
+
const a = cfg.strategyFactory!();
|
|
128
|
+
const b = cfg.strategyFactory!();
|
|
129
|
+
expect(a).toBeDefined();
|
|
130
|
+
expect(b).toBeDefined();
|
|
131
|
+
expect(a).not.toBe(b);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('strategyFactory honors the recipe strategy type', () => {
|
|
135
|
+
const passthrough = validateRecipe(baseRecipe({
|
|
136
|
+
agent: { name: 'p', systemPrompt: 't', strategy: { type: 'passthrough' } },
|
|
137
|
+
conversations: {},
|
|
138
|
+
})) as Recipe;
|
|
139
|
+
const cfg = buildConversationsConfig(passthrough, 'p', 'model-x', 'UTC')!;
|
|
140
|
+
expect(cfg.strategyFactory!().constructor.name).toBe('PassthroughStrategy');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FleetModule.requestPanel — the promise-based panel-op verb the WebUI's
|
|
3
|
+
* fleet-scope routing rides on (WS panels and the HTTP ?scope= proxy).
|
|
4
|
+
*
|
|
5
|
+
* Uses the mock headless child, which answers panel-request with:
|
|
6
|
+
* op 'hang' → nothing (timeout path)
|
|
7
|
+
* op 'fail' → {ok:false, error, status:418} (error passthrough)
|
|
8
|
+
* others → {ok:true, data:{op, echo:params}}
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { FleetModule, type FleetModuleConfig } from '../src/modules/fleet-module.js';
|
|
16
|
+
|
|
17
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const MOCK_CHILD_PATH = join(TEST_DIR, 'mock-headless-child.ts');
|
|
19
|
+
|
|
20
|
+
function makeFleet(overrides: Partial<FleetModuleConfig> = {}): FleetModule {
|
|
21
|
+
return new FleetModule({
|
|
22
|
+
childIndexPath: MOCK_CHILD_PATH,
|
|
23
|
+
socketWaitTimeoutMs: 10_000,
|
|
24
|
+
readyTimeoutMs: 5_000,
|
|
25
|
+
gracefulShutdownMs: 3_000,
|
|
26
|
+
sigtermEscalationMs: 1_000,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('FleetModule.requestPanel', () => {
|
|
32
|
+
let tmpDir: string;
|
|
33
|
+
let fleet: FleetModule;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-panel-'));
|
|
37
|
+
fleet = makeFleet();
|
|
38
|
+
const res = await fleet.handleToolCall({
|
|
39
|
+
id: 'launch-panelkid',
|
|
40
|
+
name: 'launch',
|
|
41
|
+
input: { name: 'panelkid', recipe: 'mock-recipe', dataDir: join(tmpDir, 'panelkid') },
|
|
42
|
+
});
|
|
43
|
+
if (!res.success) throw new Error(`launch failed: ${res.error}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
try { await fleet.stop(); } catch { /* noop */ }
|
|
48
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('round-trips op + params and resolves the child data', async () => {
|
|
52
|
+
const result = await fleet.requestPanel('panelkid', 'settings', { agent: 'clerk' });
|
|
53
|
+
expect(result.ok).toBe(true);
|
|
54
|
+
expect(result.data).toEqual({ op: 'settings', echo: { agent: 'clerk' } });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('concurrent requests correlate independently', async () => {
|
|
58
|
+
const [a, b] = await Promise.all([
|
|
59
|
+
fleet.requestPanel('panelkid', 'health', { n: 1 }),
|
|
60
|
+
fleet.requestPanel('panelkid', 'pins', { n: 2 }),
|
|
61
|
+
]);
|
|
62
|
+
expect(a.ok).toBe(true);
|
|
63
|
+
expect((a.data as { op: string }).op).toBe('health');
|
|
64
|
+
expect(b.ok).toBe(true);
|
|
65
|
+
expect((b.data as { op: string }).op).toBe('pins');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('child-reported failure passes error and status through', async () => {
|
|
69
|
+
const result = await fleet.requestPanel('panelkid', 'fail');
|
|
70
|
+
expect(result.ok).toBe(false);
|
|
71
|
+
expect(result.error).toBe('mock failure');
|
|
72
|
+
expect(result.status).toBe(418);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('unanswered request resolves ok:false with 504 after timeoutMs', async () => {
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
const result = await fleet.requestPanel('panelkid', 'hang', undefined, 400);
|
|
78
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(380);
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
expect(result.status).toBe(504);
|
|
81
|
+
expect(result.error).toContain('timed out');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('unknown child resolves ok:false with 404 without touching the wire', async () => {
|
|
85
|
+
const result = await fleet.requestPanel('nobody', 'settings');
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
expect(result.status).toBe(404);
|
|
88
|
+
expect(result.error).toContain('nobody');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -86,7 +86,7 @@ describe('framework FKM composition', () => {
|
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', () => {
|
|
89
|
+
test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', async () => {
|
|
90
90
|
const parsed = validateRecipe(recipe({
|
|
91
91
|
provider: 'openai-responses',
|
|
92
92
|
maxTokens: 4_096,
|
|
@@ -103,13 +103,20 @@ describe('framework FKM composition', () => {
|
|
|
103
103
|
type: 'autobiographical',
|
|
104
104
|
compressionRefusalCurveFallbacks: 2,
|
|
105
105
|
compressionContextBudgetTokens: 19_000,
|
|
106
|
+
compressionRecallBudgetTokens: 17_000,
|
|
107
|
+
compressionSourceOnly: false,
|
|
108
|
+
compressionSourceOnlyFallback: true,
|
|
109
|
+
compressionMergeSourceOnly: false,
|
|
110
|
+
compressionMergeSourceOnlyFallback: true,
|
|
106
111
|
},
|
|
107
112
|
}));
|
|
108
113
|
|
|
109
114
|
const dir = mkdtempSync(join(tmpdir(), 'connectome-fkm-'));
|
|
110
115
|
tempDirs.push(dir);
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
// Saving an already-resolved recipe through the new unresolved-snapshot
|
|
117
|
+
// path is legal: substitution over resolved content is a no-op.
|
|
118
|
+
saveRecipe(dir, parsed as unknown as Record<string, unknown>);
|
|
119
|
+
const reloaded = await loadSavedRecipe(dir);
|
|
113
120
|
expect(reloaded).not.toBeNull();
|
|
114
121
|
|
|
115
122
|
expect(reloaded!.agent.sameRoundThinkTextPolicy).toBe('private');
|
|
@@ -118,11 +125,21 @@ describe('framework FKM composition', () => {
|
|
|
118
125
|
});
|
|
119
126
|
expect(reloaded!.agent.strategy?.compressionRefusalCurveFallbacks).toBe(2);
|
|
120
127
|
expect(reloaded!.agent.strategy?.compressionContextBudgetTokens).toBe(19_000);
|
|
128
|
+
expect(reloaded!.agent.strategy?.compressionRecallBudgetTokens).toBe(17_000);
|
|
129
|
+
expect(reloaded!.agent.strategy?.compressionSourceOnly).toBe(false);
|
|
130
|
+
expect(reloaded!.agent.strategy?.compressionSourceOnlyFallback).toBe(true);
|
|
131
|
+
expect(reloaded!.agent.strategy?.compressionMergeSourceOnly).toBe(false);
|
|
132
|
+
expect(reloaded!.agent.strategy?.compressionMergeSourceOnlyFallback).toBe(true);
|
|
121
133
|
|
|
122
134
|
const runtimeStrategy = buildFrameworkStrategy(reloaded!, 'model', 'America/Los_Angeles');
|
|
123
135
|
const runtimeConfig = strategyConfigView(runtimeStrategy);
|
|
124
136
|
expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
|
|
125
137
|
expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
|
|
138
|
+
expect(runtimeConfig.compressionRecallBudgetTokens).toBe(17_000);
|
|
139
|
+
expect(runtimeConfig.compressionSourceOnly).toBe(false);
|
|
140
|
+
expect(runtimeConfig.compressionSourceOnlyFallback).toBe(true);
|
|
141
|
+
expect(runtimeConfig.compressionMergeSourceOnly).toBe(false);
|
|
142
|
+
expect(runtimeConfig.compressionMergeSourceOnlyFallback).toBe(true);
|
|
126
143
|
|
|
127
144
|
const agentConfig = buildFrameworkAgentConfig(reloaded!, 'agent', 'model', runtimeStrategy);
|
|
128
145
|
expect(agentConfig.sameRoundThinkTextPolicy).toBe('private');
|
|
@@ -138,14 +155,29 @@ describe('framework FKM composition', () => {
|
|
|
138
155
|
type: 'autobiographical',
|
|
139
156
|
compressionRefusalCurveFallbacks: 0,
|
|
140
157
|
compressionContextBudgetTokens: 50_000,
|
|
158
|
+
compressionRecallBudgetTokens: 41_000,
|
|
159
|
+
compressionSourceOnly: true,
|
|
160
|
+
compressionSourceOnlyFallback: false,
|
|
161
|
+
compressionMergeSourceOnly: true,
|
|
162
|
+
compressionMergeSourceOnlyFallback: false,
|
|
141
163
|
},
|
|
142
164
|
}));
|
|
143
165
|
const otherStrategy = buildFrameworkStrategy(otherRecipe, 'other-model', 'America/Los_Angeles');
|
|
144
166
|
const otherConfig = strategyConfigView(otherStrategy);
|
|
145
167
|
expect(otherConfig.compressionRefusalCurveFallbacks).toBe(0);
|
|
146
168
|
expect(otherConfig.compressionContextBudgetTokens).toBe(50_000);
|
|
169
|
+
expect(otherConfig.compressionRecallBudgetTokens).toBe(41_000);
|
|
170
|
+
expect(otherConfig.compressionSourceOnly).toBe(true);
|
|
171
|
+
expect(otherConfig.compressionSourceOnlyFallback).toBe(false);
|
|
172
|
+
expect(otherConfig.compressionMergeSourceOnly).toBe(true);
|
|
173
|
+
expect(otherConfig.compressionMergeSourceOnlyFallback).toBe(false);
|
|
147
174
|
expect(otherConfig).not.toBe(runtimeConfig);
|
|
148
175
|
expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
|
|
149
176
|
expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
|
|
177
|
+
expect(runtimeConfig.compressionRecallBudgetTokens).toBe(17_000);
|
|
178
|
+
expect(runtimeConfig.compressionSourceOnly).toBe(false);
|
|
179
|
+
expect(runtimeConfig.compressionSourceOnlyFallback).toBe(true);
|
|
180
|
+
expect(runtimeConfig.compressionMergeSourceOnly).toBe(false);
|
|
181
|
+
expect(runtimeConfig.compressionMergeSourceOnlyFallback).toBe(true);
|
|
150
182
|
});
|
|
151
183
|
});
|
|
@@ -39,6 +39,47 @@ describe('standard-recipe memory defaults', () => {
|
|
|
39
39
|
expect(config.summaryParticipant).toBe('Mira');
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test('frontdesk gets adaptive + kv-stable too (2026-08-03 clerk outage: hierarchical saturates)', () => {
|
|
43
|
+
const strategy = buildFrameworkStrategy(
|
|
44
|
+
recipe({ name: 'Desk', strategy: { type: 'frontdesk' } }),
|
|
45
|
+
'some-model',
|
|
46
|
+
'Europe/Kyiv',
|
|
47
|
+
);
|
|
48
|
+
const config = configView(strategy);
|
|
49
|
+
expect(config.adaptiveResolution).toBe(true);
|
|
50
|
+
expect(config.foldingStrategy).toBe('kv-stable');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('frontdesk adaptiveResolution: false is the hierarchical rollback lever', () => {
|
|
54
|
+
const strategy = buildFrameworkStrategy(
|
|
55
|
+
recipe({ name: 'Desk', strategy: { type: 'frontdesk', adaptiveResolution: false } }),
|
|
56
|
+
'some-model',
|
|
57
|
+
'Europe/Kyiv',
|
|
58
|
+
);
|
|
59
|
+
const config = configView(strategy);
|
|
60
|
+
expect(config.adaptiveResolution).toBe(false);
|
|
61
|
+
expect(config.foldingStrategy).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('productionBudgetTokens is passed through exactly and omission stays omitted', () => {
|
|
65
|
+
const configured = buildFrameworkStrategy(
|
|
66
|
+
recipe({
|
|
67
|
+
name: 'Mira',
|
|
68
|
+
strategy: { type: 'autobiographical', productionBudgetTokens: 123_456 },
|
|
69
|
+
}),
|
|
70
|
+
'some-model',
|
|
71
|
+
'America/Los_Angeles',
|
|
72
|
+
);
|
|
73
|
+
expect(configView(configured).productionBudgetTokens).toBe(123_456);
|
|
74
|
+
|
|
75
|
+
const omitted = buildFrameworkStrategy(
|
|
76
|
+
recipe({ name: 'Mira', strategy: { type: 'autobiographical' } }),
|
|
77
|
+
'some-model',
|
|
78
|
+
'America/Los_Angeles',
|
|
79
|
+
);
|
|
80
|
+
expect(configView(omitted).productionBudgetTokens).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
42
83
|
test('explicit recipe values override the defaults', () => {
|
|
43
84
|
const strategy = buildFrameworkStrategy(
|
|
44
85
|
recipe({
|
|
@@ -2,7 +2,6 @@ import { describe, test, expect } from 'bun:test';
|
|
|
2
2
|
import type {
|
|
3
3
|
MessageStoreView,
|
|
4
4
|
StoredMessage,
|
|
5
|
-
SummaryEntry,
|
|
6
5
|
ContextEntry,
|
|
7
6
|
} from '@animalabs/context-manager';
|
|
8
7
|
import { FrontdeskStrategy } from '../src/strategies/frontdesk-strategy.js';
|
|
@@ -54,12 +53,12 @@ class TestFrontdesk extends FrontdeskStrategy {
|
|
|
54
53
|
public pub_updateSalience(store: MessageStoreView) {
|
|
55
54
|
this.updateSalience(store);
|
|
56
55
|
}
|
|
57
|
-
public pub_selectL1(l1: SummaryEntry[], budget: number, maxTokens: number) {
|
|
58
|
-
return this.selectL1Summaries(l1, budget, maxTokens);
|
|
59
|
-
}
|
|
60
56
|
public pub_isTopicBoundary(a: StoredMessage, b: StoredMessage) {
|
|
61
57
|
return this.isTopicBoundary(a, b);
|
|
62
58
|
}
|
|
59
|
+
public pub_chunkBoundaryHint(a: StoredMessage, b: StoredMessage) {
|
|
60
|
+
return this.chunkBoundaryHint(a, b);
|
|
61
|
+
}
|
|
63
62
|
public pub_compressionInstruction(chunkMessages: StoredMessage[], target: number) {
|
|
64
63
|
// Build a minimal Chunk shape sufficient for getCompressionInstruction
|
|
65
64
|
const chunk = {
|
|
@@ -293,44 +292,33 @@ describe('compression instruction', () => {
|
|
|
293
292
|
});
|
|
294
293
|
});
|
|
295
294
|
|
|
296
|
-
describe('
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
content: '',
|
|
302
|
-
tokens,
|
|
303
|
-
sourceLevel: 0,
|
|
304
|
-
sourceIds,
|
|
305
|
-
sourceRange: { first: sourceIds[0] ?? '', last: sourceIds[sourceIds.length - 1] ?? '' },
|
|
306
|
-
created: Date.now(),
|
|
307
|
-
};
|
|
308
|
-
}
|
|
295
|
+
describe('chunk boundary hint (topic-aware chunking via the base seam)', () => {
|
|
296
|
+
// The chunking mechanics — record persistence, minimum-size, tool-pairing
|
|
297
|
+
// guard — are context-manager's contract, gated by its
|
|
298
|
+
// chunk-boundary-hook tests. What is conhost's to pin is the hint policy:
|
|
299
|
+
// frontdesk hints exactly at topic boundaries.
|
|
309
300
|
|
|
310
|
-
test('
|
|
301
|
+
test('hints a close when adjacent messages change topic on one channel', () => {
|
|
311
302
|
const s = makeStrategy();
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// Two L1 summaries, each 100 tokens; budget fits only one
|
|
316
|
-
const routineFirst = summary('L1-0', 100, ['unrelated-1']);
|
|
317
|
-
const salientSecond = summary('L1-1', 100, [q.id]);
|
|
318
|
-
|
|
319
|
-
const { selected } = s.pub_selectL1([routineFirst, salientSecond], 100, 100);
|
|
320
|
-
expect(selected).toHaveLength(1);
|
|
321
|
-
expect(selected[0].id).toBe('L1-1');
|
|
303
|
+
const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
|
|
304
|
+
const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'deploys' });
|
|
305
|
+
expect(s.pub_chunkBoundaryHint(a, b)).toBe(true);
|
|
322
306
|
});
|
|
323
307
|
|
|
324
|
-
test('
|
|
308
|
+
test('does not hint within a topic or when topic metadata is absent', () => {
|
|
325
309
|
const s = makeStrategy();
|
|
326
|
-
const
|
|
327
|
-
|
|
310
|
+
const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
|
|
311
|
+
const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
|
|
312
|
+
const bare = msg('User', 'z', {});
|
|
313
|
+
expect(s.pub_chunkBoundaryHint(a, b)).toBe(false);
|
|
314
|
+
expect(s.pub_chunkBoundaryHint(a, bare)).toBe(false);
|
|
315
|
+
expect(s.pub_chunkBoundaryHint(bare, a)).toBe(false);
|
|
316
|
+
});
|
|
328
317
|
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
expect(
|
|
334
|
-
expect(selected[1].id).toBe('L1-2');
|
|
318
|
+
test('hint agrees with isTopicBoundary across channels (same topic name, different channel)', () => {
|
|
319
|
+
const s = makeStrategy();
|
|
320
|
+
const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
|
|
321
|
+
const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:ops', topic: 'retries' });
|
|
322
|
+
expect(s.pub_chunkBoundaryHint(a, b)).toBe(s.pub_isTopicBoundary(a, b));
|
|
335
323
|
});
|
|
336
324
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter-level proof (review finding on #113): with the PUBLISHED membrane,
|
|
3
|
+
* a stream call consults the active-turn trigger and carries the origin trio,
|
|
4
|
+
* while a complete call (compression / side-calls / keepalive) carries the
|
|
5
|
+
* debt stamp only. Runs the real AnthropicAdapter with a mocked SDK client.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'bun:test';
|
|
8
|
+
import { AnthropicAdapter } from '@animalabs/membrane';
|
|
9
|
+
import { gateTelemetryHeaders } from '../src/gate-telemetry.js';
|
|
10
|
+
|
|
11
|
+
const env = { GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' };
|
|
12
|
+
const wake = { reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:1:2', counterparty: 'discord:user:42' };
|
|
13
|
+
|
|
14
|
+
const REQUEST = {
|
|
15
|
+
model: 'claude-test',
|
|
16
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
17
|
+
} as any;
|
|
18
|
+
|
|
19
|
+
const RESPONSE = {
|
|
20
|
+
id: 'msg_test', model: 'claude-test', role: 'assistant',
|
|
21
|
+
content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn',
|
|
22
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Minimal SDK stream: an async iterable of the events the adapter reads. */
|
|
26
|
+
function mockStream() {
|
|
27
|
+
const events = [
|
|
28
|
+
{ type: 'message_start', message: { id: 'msg_s', model: 'claude-test', role: 'assistant', content: [], usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
29
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
|
30
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } },
|
|
31
|
+
{ type: 'content_block_stop', index: 0 },
|
|
32
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } },
|
|
33
|
+
{ type: 'message_stop' },
|
|
34
|
+
];
|
|
35
|
+
return {
|
|
36
|
+
async *[Symbol.asyncIterator]() { for (const e of events) yield e; },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function adapterWithTrigger(trigger: () => typeof wake | null) {
|
|
41
|
+
let asked = 0;
|
|
42
|
+
const dyn = gateTelemetryHeaders(env, () => 3, () => { asked++; return trigger(); })!;
|
|
43
|
+
const adapter = new AnthropicAdapter({ apiKey: 'sk-test', cacheKeepalive: { enabled: false }, dynamicHeaders: dyn } as any);
|
|
44
|
+
const calls: Array<{ kind: string; headers: Record<string, string> | undefined }> = [];
|
|
45
|
+
(adapter as any).client = {
|
|
46
|
+
messages: {
|
|
47
|
+
create: async (_req: unknown, opts: { headers?: Record<string, string> }) => { calls.push({ kind: 'complete', headers: opts?.headers }); return RESPONSE; },
|
|
48
|
+
stream: async (_req: unknown, opts: { headers?: Record<string, string> }) => { calls.push({ kind: 'stream', headers: opts?.headers }); return mockStream(); },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return { adapter, calls, asked: () => asked };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('gate telemetry through the published AnthropicAdapter', () => {
|
|
55
|
+
it('complete lane: debt only — the active trigger is not consulted', async () => {
|
|
56
|
+
const { adapter, calls, asked } = adapterWithTrigger(() => wake);
|
|
57
|
+
await adapter.complete(REQUEST);
|
|
58
|
+
expect(calls).toHaveLength(1);
|
|
59
|
+
expect(calls[0]!.kind).toBe('complete');
|
|
60
|
+
expect(calls[0]!.headers).toEqual({ 'x-gate-debt-chunks': '3' });
|
|
61
|
+
expect(asked()).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('stream lane: the trigger is consulted and the origin trio rides the request', async () => {
|
|
65
|
+
const { adapter, calls, asked } = adapterWithTrigger(() => wake);
|
|
66
|
+
const chunks: string[] = [];
|
|
67
|
+
await adapter.stream(REQUEST, { onChunk: (c: string) => { chunks.push(c); } } as any);
|
|
68
|
+
expect(calls).toHaveLength(1);
|
|
69
|
+
expect(calls[0]!.kind).toBe('stream');
|
|
70
|
+
expect(calls[0]!.headers).toEqual({
|
|
71
|
+
'x-gate-debt-chunks': '3',
|
|
72
|
+
'x-gate-origin': 'event',
|
|
73
|
+
'x-gate-channel': 'discord:1:2',
|
|
74
|
+
'x-gate-counterparty': 'discord:user:42',
|
|
75
|
+
});
|
|
76
|
+
expect(asked()).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stream lane with no turn in progress: debt only, nothing guessed', async () => {
|
|
80
|
+
const { adapter, calls } = adapterWithTrigger(() => null);
|
|
81
|
+
await adapter.stream(REQUEST, { onChunk: () => {} } as any);
|
|
82
|
+
expect(calls[0]!.headers).toEqual({ 'x-gate-debt-chunks': '3' });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The x-gate-* stamp must be impossible to send to a vendor: attachment is
|
|
3
|
+
* double-gated on the operator's explicit GATE_TELEMETRY declaration AND a
|
|
4
|
+
* configured base URL. Review finding on the first wiring: the stamp was
|
|
5
|
+
* attached unconditionally, so with ANTHROPIC_BASE_URL unset the household
|
|
6
|
+
* value went to the vendor's default endpoint.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'bun:test';
|
|
9
|
+
import { gateTelemetryHeaders, originClass } from '../src/gate-telemetry.js';
|
|
10
|
+
|
|
11
|
+
const debt = () => 7;
|
|
12
|
+
|
|
13
|
+
describe('gateTelemetryHeaders', () => {
|
|
14
|
+
it('default vendor endpoint (no base URL): never attaches, even when flagged', () => {
|
|
15
|
+
expect(gateTelemetryHeaders({ GATE_TELEMETRY: '1' }, debt)).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('base URL without the operator declaration: never attaches', () => {
|
|
19
|
+
expect(gateTelemetryHeaders({ ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
|
|
20
|
+
expect(gateTelemetryHeaders({ GATE_TELEMETRY: '0', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
|
|
21
|
+
expect(gateTelemetryHeaders({ GATE_TELEMETRY: 'false', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt)).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('declared gateway: attaches a live stamp', () => {
|
|
25
|
+
const fn = gateTelemetryHeaders({ GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, debt);
|
|
26
|
+
expect(fn).toBeDefined();
|
|
27
|
+
expect(fn!()).toEqual({ 'x-gate-debt-chunks': 7 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('unreadable debt stays null (membrane drops null values — unstamped, never guessed)', () => {
|
|
31
|
+
const fn = gateTelemetryHeaders({ GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' }, () => null);
|
|
32
|
+
expect(fn!()).toEqual({ 'x-gate-debt-chunks': null });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const env = { GATE_TELEMETRY: '1', ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic' };
|
|
36
|
+
const wake = { reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:1:2', counterparty: 'discord:user:42' };
|
|
37
|
+
|
|
38
|
+
it('origin trio rides the stream lane: why, where, by whom — ids only', () => {
|
|
39
|
+
const fn = gateTelemetryHeaders(env, debt, () => wake);
|
|
40
|
+
expect(fn!({ lane: 'stream' })).toEqual({
|
|
41
|
+
'x-gate-debt-chunks': 7,
|
|
42
|
+
'x-gate-origin': 'event',
|
|
43
|
+
'x-gate-channel': 'discord:1:2',
|
|
44
|
+
'x-gate-counterparty': 'discord:user:42',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('complete lane (compression, side-calls, keepalive) carries debt only — a background call is not the turn', () => {
|
|
49
|
+
const fn = gateTelemetryHeaders(env, debt, () => wake);
|
|
50
|
+
expect(fn!({ lane: 'complete' })).toEqual({ 'x-gate-debt-chunks': 7 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('no lane told (older membrane) or no trigger known: honest — debt only, or trio without guessing', () => {
|
|
54
|
+
expect(gateTelemetryHeaders(env, debt, () => null)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
|
|
55
|
+
expect(gateTelemetryHeaders(env, debt, () => wake)!()).toMatchObject({ 'x-gate-origin': 'event' });
|
|
56
|
+
expect(gateTelemetryHeaders(env, debt)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('heartbeat wakes carry no channel or counterparty (null → dropped by membrane)', () => {
|
|
60
|
+
const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'heartbeat:tick', source: 'heartbeat' }));
|
|
61
|
+
expect(fn!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7, 'x-gate-origin': 'heartbeat', 'x-gate-channel': null, 'x-gate-counterparty': null });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('originClass: heartbeat / mail / event / operator, raw reason otherwise (sanitized, clipped)', () => {
|
|
65
|
+
expect(originClass({ reason: 'heartbeat', source: 'heartbeat' })).toBe('heartbeat');
|
|
66
|
+
expect(originClass({ reason: 'mail:incoming', source: 'fenmail' })).toBe('mail');
|
|
67
|
+
expect(originClass({ reason: 'mcpl:push-event', source: 'discord' })).toBe('event');
|
|
68
|
+
expect(originClass({ reason: 'admin-nudge (someone)', source: 'framework' })).toBe('operator');
|
|
69
|
+
expect(originClass({ reason: 'external-message', source: 'headless' })).toBe('operator');
|
|
70
|
+
expect(originClass({ reason: 'external-message', source: 'tui' })).toBe('operator');
|
|
71
|
+
expect(originClass({ reason: 'provider-acceleration-retry', source: 'framework' })).toBe('provider-acceleration-retry');
|
|
72
|
+
expect(originClass({ reason: 'weird reason!!'.repeat(6), source: 'x' })).toHaveLength(40);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('attributes are header-safe: a value with any non-ASCII or control character is withheld whole, never rewritten', () => {
|
|
76
|
+
const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:\u{1F600}', counterparty: 'discord:user:4\r\n2' }));
|
|
77
|
+
const h = fn!({ lane: 'stream' });
|
|
78
|
+
expect(h['x-gate-channel']).toBeNull();
|
|
79
|
+
expect(h['x-gate-counterparty']).toBeNull();
|
|
80
|
+
// and what IS sent can always be put in real Headers (Fetch ByteString rule)
|
|
81
|
+
const ok = gateTelemetryHeaders(env, debt, () => wake)!({ lane: 'stream' });
|
|
82
|
+
const sendable = Object.fromEntries(Object.entries(ok).filter(([, v]) => v !== null).map(([k, v]) => [k, String(v)]));
|
|
83
|
+
expect(() => new Headers(sendable)).not.toThrow();
|
|
84
|
+
expect(new Headers(sendable).get('x-gate-counterparty')).toBe('discord:user:42');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('attributes are clipped to 120 visible-ASCII characters', () => {
|
|
88
|
+
const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', counterparty: 'x'.repeat(200) }));
|
|
89
|
+
expect((fn!({ lane: 'stream' })['x-gate-counterparty'] as string).length).toBe(120);
|
|
90
|
+
});
|
|
91
|
+
});
|