@animalabs/connectome-host 0.3.7 → 0.3.8
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/.github/workflows/ci.yml +52 -0
- package/.github/workflows/publish.yml +2 -2
- package/bun.lock +9 -5
- package/package.json +5 -3
- package/src/framework-agent-config.ts +51 -0
- package/src/framework-strategy.ts +70 -0
- package/src/index.ts +68 -94
- package/src/logging-adapter.ts +33 -3
- package/src/mcpl-config.ts +1 -0
- package/src/modules/channel-mode-module.ts +14 -16
- package/src/modules/mcpl-admin-module.ts +0 -15
- package/src/modules/settings-module.ts +83 -59
- package/src/modules/subscription-gc-module.ts +17 -20
- package/src/modules/web-ui-module.ts +63 -10
- package/src/modules/web-ui-observers.ts +19 -6
- package/src/recipe.ts +85 -3
- package/src/state/agent-tree-reducer.ts +17 -3
- package/src/strategies/frontdesk-strategy.ts +5 -5
- package/test/agent-tree-reducer.test.ts +35 -3
- package/test/autobio-progress-snapshot.test.ts +27 -12
- package/test/framework-fkm-composition.test.ts +160 -0
- package/test/logging-adapter-refusal.test.ts +57 -0
- package/test/recipe-compression-fallback.test.ts +36 -0
- package/test/recipe-primary-summary-fallback.test.ts +40 -0
- package/test/recipe-think-policy.test.ts +39 -0
- package/test/subscription-gc-module.test.ts +7 -5
- package/test/web-ui-observers.test.ts +27 -2
- package/web/package-lock.json +425 -302
- package/web/bun.lock +0 -357
|
@@ -123,15 +123,15 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
|
|
|
123
123
|
if (ts) parts.push(ts);
|
|
124
124
|
|
|
125
125
|
if (meta.messageId !== undefined && meta.messageId !== null && meta.messageId !== '') {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
// Render the FULL id — truncating (an old token-saving trim) corrupts
|
|
127
|
+
// Discord snowflakes, so every reply_message/fetch_around/add_reaction
|
|
128
|
+
// call the agent copies from its own context 404s with Unknown message.
|
|
129
|
+
parts.push(`msg ${String(meta.messageId)}`);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
const threadId = meta.threadId !== undefined && meta.threadId !== null ? String(meta.threadId) : '';
|
|
132
133
|
if (threadId && threadId !== topic) {
|
|
133
|
-
|
|
134
|
-
parts.push(`thread ${short}`);
|
|
134
|
+
parts.push(`thread ${threadId}`);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
if (parts.length === 0) return null;
|
|
@@ -86,9 +86,12 @@ describe('AgentTreeReducer', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
test('inference:exhausted from in-band stream abort also produces cancelled (P1 #3)', () => {
|
|
89
|
-
// Postmortem 2026-05-28 F1:
|
|
90
|
-
//
|
|
91
|
-
//
|
|
89
|
+
// Postmortem 2026-05-28 F1: AF used to emit inference:exhausted for
|
|
90
|
+
// budget-restart / stream-side cancel paths. Same semantics as aborted —
|
|
91
|
+
// benign termination, not a fault. AF PR #55 (≥ 0.6.5) suppresses the
|
|
92
|
+
// endTurn/budget-restart cases at the source, but genuine cancels and
|
|
93
|
+
// reboot-induced exhausted still arrive, and the mapping must keep
|
|
94
|
+
// handling streams from older AF.
|
|
92
95
|
const r = new AgentTreeReducer();
|
|
93
96
|
r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
|
|
94
97
|
r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'budget', timestamp: ts(1) });
|
|
@@ -97,6 +100,35 @@ describe('AgentTreeReducer', () => {
|
|
|
97
100
|
expect(node.status).toBe('cancelled');
|
|
98
101
|
});
|
|
99
102
|
|
|
103
|
+
test('inference:turn_ended is terminal in its own right (AF PR #55: no trailing exhausted)', () => {
|
|
104
|
+
// endTurn = the agent deliberately ended its turn — a successful
|
|
105
|
+
// terminal. Before AF PR #55 the terminal state of endTurn'd agents came
|
|
106
|
+
// from the SPURIOUS inference:exhausted the framework emitted right
|
|
107
|
+
// after; ≥ 0.6.5 no longer sends it, so without this transition an
|
|
108
|
+
// endTurn'd agent would sit in the tree as 'running' forever.
|
|
109
|
+
const r = new AgentTreeReducer();
|
|
110
|
+
r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
|
|
111
|
+
r.applyEvent({ type: 'inference:turn_ended', agentName: 'a', timestamp: ts(1) });
|
|
112
|
+
const node = r.getNode('a')!;
|
|
113
|
+
expect(node.phase).toBe('done');
|
|
114
|
+
expect(node.status).toBe('completed');
|
|
115
|
+
expect(node.completedAt).toBe(ts(1));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('older AF: the exhausted trailing an endTurn re-terminates as cancelled (harmless)', () => {
|
|
119
|
+
// Pre-0.6.5 streams still deliver turn_ended followed by the spurious
|
|
120
|
+
// exhausted. The node flips completed → cancelled one event later; both
|
|
121
|
+
// are terminal-and-benign, so renderers stay correct either way. Pinned
|
|
122
|
+
// so the compat behavior is a documented choice, not an accident.
|
|
123
|
+
const r = new AgentTreeReducer();
|
|
124
|
+
r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
|
|
125
|
+
r.applyEvent({ type: 'inference:turn_ended', agentName: 'a', timestamp: ts(1) });
|
|
126
|
+
r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'Stream aborted: user', timestamp: ts(2) });
|
|
127
|
+
const node = r.getNode('a')!;
|
|
128
|
+
expect(node.phase).toBe('cancelled');
|
|
129
|
+
expect(node.status).toBe('cancelled');
|
|
130
|
+
});
|
|
131
|
+
|
|
100
132
|
// Postmortem 2026-05-28 F1: the reducer never transitions status to 'completed'.
|
|
101
133
|
// inference:completed only sets phase='done', leaving status='running' for the
|
|
102
134
|
// entire lifetime of the agent — which is fine until a later aborted/exhausted/
|
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
2
|
import { AutobiographicalStrategy } from '@animalabs/agent-framework';
|
|
3
|
+
import { ContextManager } from '@animalabs/context-manager';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
async function initializedStrategy() {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'autobio-progress-'));
|
|
10
|
+
const path = join(root, 'store');
|
|
11
|
+
const strategy = new AutobiographicalStrategy({
|
|
12
|
+
compressionModel: 'claude-sonnet-4-5-20250929',
|
|
13
|
+
autoTickOnNewMessage: false,
|
|
14
|
+
});
|
|
15
|
+
const cm = await ContextManager.open({ path, strategy });
|
|
16
|
+
return {
|
|
17
|
+
strategy,
|
|
18
|
+
close: () => {
|
|
19
|
+
cm.close();
|
|
20
|
+
rmSync(root, { recursive: true, force: true });
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
3
24
|
|
|
4
25
|
// Guards against silent breakage if upstream renames the protected fields
|
|
5
26
|
// that getProgressSnapshot reads. The shape is what warmup-session.ts relies on.
|
|
6
27
|
describe('AutobiographicalStrategy.getProgressSnapshot', () => {
|
|
7
|
-
test('returns the expected shape
|
|
8
|
-
const strategy =
|
|
9
|
-
compressionModel: 'claude-sonnet-4-5-20250929',
|
|
10
|
-
autoTickOnNewMessage: false,
|
|
11
|
-
});
|
|
28
|
+
test('returns the expected shape after branch state is initialized', async () => {
|
|
29
|
+
const { strategy, close } = await initializedStrategy();
|
|
12
30
|
const snapshot = strategy.getProgressSnapshot();
|
|
13
31
|
expect(snapshot).toEqual({
|
|
14
32
|
totalChunks: 0,
|
|
@@ -18,16 +36,12 @@ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
|
|
|
18
36
|
summaryCounts: { l1: 0, l2: 0, l3: 0 },
|
|
19
37
|
pending: false,
|
|
20
38
|
});
|
|
39
|
+
close();
|
|
21
40
|
});
|
|
22
41
|
|
|
23
|
-
test('exposes the keys warmup-session.ts depends on', () => {
|
|
24
|
-
const strategy =
|
|
25
|
-
compressionModel: 'claude-sonnet-4-5-20250929',
|
|
26
|
-
autoTickOnNewMessage: false,
|
|
27
|
-
});
|
|
42
|
+
test('exposes the keys warmup-session.ts depends on', async () => {
|
|
43
|
+
const { strategy, close } = await initializedStrategy();
|
|
28
44
|
const s = strategy.getProgressSnapshot();
|
|
29
|
-
// Property access — if any rename happens upstream these become undefined
|
|
30
|
-
// and the test fails loudly.
|
|
31
45
|
expect(typeof s.totalChunks).toBe('number');
|
|
32
46
|
expect(typeof s.chunksCompressed).toBe('number');
|
|
33
47
|
expect(typeof s.l1QueueLength).toBe('number');
|
|
@@ -36,5 +50,6 @@ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
|
|
|
36
50
|
expect(typeof s.summaryCounts.l1).toBe('number');
|
|
37
51
|
expect(typeof s.summaryCounts.l2).toBe('number');
|
|
38
52
|
expect(typeof s.summaryCounts.l3).toBe('number');
|
|
53
|
+
close();
|
|
39
54
|
});
|
|
40
55
|
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { buildFrameworkAgentConfig } from '../src/framework-agent-config.js';
|
|
6
|
+
import { buildFrameworkStrategy } from '../src/framework-strategy.js';
|
|
7
|
+
import { loadSavedRecipe, saveRecipe, validateRecipe } from '../src/recipe.js';
|
|
8
|
+
|
|
9
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'framework-fkm-composition',
|
|
12
|
+
agent: {
|
|
13
|
+
systemPrompt: 'sys',
|
|
14
|
+
...agent,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function strategyConfigView(strategy: object): Record<string, unknown> {
|
|
20
|
+
return (strategy as { config?: Record<string, unknown> }).config ?? {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tempDirs: string[] = [];
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
while (tempDirs.length > 0) {
|
|
27
|
+
rmSync(tempDirs.pop()!, { force: true, recursive: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('framework FKM composition', () => {
|
|
32
|
+
test('preserves the pre-merge AgentConfig shape field-for-field when FKM additions are omitted', () => {
|
|
33
|
+
const parsed = validateRecipe(recipe({
|
|
34
|
+
provider: 'openai-responses',
|
|
35
|
+
maxTokens: 4_096,
|
|
36
|
+
maxStreamTokens: 80_000,
|
|
37
|
+
contextBudgetTokens: 120_000,
|
|
38
|
+
cacheTtl: '5m',
|
|
39
|
+
responses: {
|
|
40
|
+
reasoningEffort: 'medium',
|
|
41
|
+
reasoningContext: 'current_turn',
|
|
42
|
+
serviceTier: 'priority',
|
|
43
|
+
compactThreshold: 90_000,
|
|
44
|
+
},
|
|
45
|
+
thinking: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
budgetTokens: 1_024,
|
|
48
|
+
},
|
|
49
|
+
refusalHandling: {
|
|
50
|
+
autoRewind: true,
|
|
51
|
+
maxRewinds: 2,
|
|
52
|
+
announceHumanTurns: false,
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
const strategy = { kind: 'baseline-strategy' } as never;
|
|
56
|
+
|
|
57
|
+
expect(buildFrameworkAgentConfig(parsed, 'agent', 'model', strategy)).toEqual({
|
|
58
|
+
name: 'agent',
|
|
59
|
+
model: 'model',
|
|
60
|
+
systemPrompt: 'sys',
|
|
61
|
+
maxTokens: 4_096,
|
|
62
|
+
maxStreamTokens: 80_000,
|
|
63
|
+
contextBudgetTokens: 120_000,
|
|
64
|
+
cacheTtl: '5m',
|
|
65
|
+
providerParams: {
|
|
66
|
+
reasoning: {
|
|
67
|
+
effort: 'medium',
|
|
68
|
+
context: 'current_turn',
|
|
69
|
+
},
|
|
70
|
+
service_tier: 'priority',
|
|
71
|
+
context_management: [{
|
|
72
|
+
type: 'compaction',
|
|
73
|
+
compact_threshold: 90_000,
|
|
74
|
+
}],
|
|
75
|
+
},
|
|
76
|
+
strategy,
|
|
77
|
+
thinking: {
|
|
78
|
+
enabled: true,
|
|
79
|
+
budgetTokens: 1_024,
|
|
80
|
+
},
|
|
81
|
+
refusalHandling: {
|
|
82
|
+
autoRewind: true,
|
|
83
|
+
maxRewinds: 2,
|
|
84
|
+
announceHumanTurns: false,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', () => {
|
|
90
|
+
const parsed = validateRecipe(recipe({
|
|
91
|
+
provider: 'openai-responses',
|
|
92
|
+
maxTokens: 4_096,
|
|
93
|
+
responses: {
|
|
94
|
+
reasoningEffort: 'high',
|
|
95
|
+
reasoningContext: 'all_turns',
|
|
96
|
+
serviceTier: 'priority',
|
|
97
|
+
},
|
|
98
|
+
sameRoundThinkTextPolicy: 'private',
|
|
99
|
+
refusalHandling: {
|
|
100
|
+
autoRewind: true,
|
|
101
|
+
primarySummaryFallback: {
|
|
102
|
+
enabled: true,
|
|
103
|
+
maxNewSummaries: 4,
|
|
104
|
+
requestBudgetTokens: 216_000,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
strategy: {
|
|
108
|
+
type: 'autobiographical',
|
|
109
|
+
compressionRefusalCurveFallbacks: 2,
|
|
110
|
+
compressionContextBudgetTokens: 19_000,
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const dir = mkdtempSync(join(tmpdir(), 'connectome-fkm-'));
|
|
115
|
+
tempDirs.push(dir);
|
|
116
|
+
saveRecipe(dir, parsed);
|
|
117
|
+
const reloaded = loadSavedRecipe(dir);
|
|
118
|
+
expect(reloaded).not.toBeNull();
|
|
119
|
+
|
|
120
|
+
expect(reloaded!.agent.sameRoundThinkTextPolicy).toBe('private');
|
|
121
|
+
expect(reloaded!.agent.refusalHandling?.primarySummaryFallback).toEqual({
|
|
122
|
+
enabled: true,
|
|
123
|
+
maxNewSummaries: 4,
|
|
124
|
+
requestBudgetTokens: 216_000,
|
|
125
|
+
});
|
|
126
|
+
expect(reloaded!.agent.strategy?.compressionRefusalCurveFallbacks).toBe(2);
|
|
127
|
+
expect(reloaded!.agent.strategy?.compressionContextBudgetTokens).toBe(19_000);
|
|
128
|
+
|
|
129
|
+
const runtimeStrategy = buildFrameworkStrategy(reloaded!, 'model', 'America/Los_Angeles');
|
|
130
|
+
const runtimeConfig = strategyConfigView(runtimeStrategy);
|
|
131
|
+
expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
|
|
132
|
+
expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
|
|
133
|
+
|
|
134
|
+
const agentConfig = buildFrameworkAgentConfig(reloaded!, 'agent', 'model', runtimeStrategy);
|
|
135
|
+
expect(agentConfig.sameRoundThinkTextPolicy).toBe('private');
|
|
136
|
+
expect(agentConfig.refusalHandling?.primarySummaryFallback).toEqual({
|
|
137
|
+
enabled: true,
|
|
138
|
+
maxNewSummaries: 4,
|
|
139
|
+
requestBudgetTokens: 216_000,
|
|
140
|
+
});
|
|
141
|
+
expect((agentConfig.providerParams as Record<string, unknown>).service_tier).toBe('priority');
|
|
142
|
+
expect(Object.keys(agentConfig.providerParams as Record<string, unknown>)
|
|
143
|
+
.filter((key) => key === 'service_tier')).toHaveLength(1);
|
|
144
|
+
|
|
145
|
+
const otherRecipe = validateRecipe(recipe({
|
|
146
|
+
strategy: {
|
|
147
|
+
type: 'autobiographical',
|
|
148
|
+
compressionRefusalCurveFallbacks: 0,
|
|
149
|
+
compressionContextBudgetTokens: 50_000,
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
152
|
+
const otherStrategy = buildFrameworkStrategy(otherRecipe, 'other-model', 'America/Los_Angeles');
|
|
153
|
+
const otherConfig = strategyConfigView(otherStrategy);
|
|
154
|
+
expect(otherConfig.compressionRefusalCurveFallbacks).toBe(0);
|
|
155
|
+
expect(otherConfig.compressionContextBudgetTokens).toBe(50_000);
|
|
156
|
+
expect(otherConfig).not.toBe(runtimeConfig);
|
|
157
|
+
expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
|
|
158
|
+
expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-path refusal dragnet (observability M3): the logging adapter fires
|
|
3
|
+
* onRefusal for refusals on complete() calls — the compression/summarizer
|
|
4
|
+
* path the framework's own noteRefusal never sees — and stays silent for
|
|
5
|
+
* streamed refusals (main driver's job) and non-refusal completions.
|
|
6
|
+
*/
|
|
7
|
+
import { test, expect } from 'bun:test';
|
|
8
|
+
import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
|
|
9
|
+
|
|
10
|
+
function makeAdapter() {
|
|
11
|
+
const adapter = new LoggingAnthropicAdapter({ apiKey: 'test-key' }, '/tmp/llm-test.jsonl', () => ({ enabled: false, budgetTokens: 0 }));
|
|
12
|
+
const fired: unknown[] = [];
|
|
13
|
+
adapter.onRefusal = (info) => fired.push(info);
|
|
14
|
+
const observe = (kind: 'complete' | 'stream', stopReason?: string, category?: string) =>
|
|
15
|
+
(adapter as unknown as {
|
|
16
|
+
observeCall: (k: string, t: string, d: number, req: unknown, raw: unknown, res?: unknown) => void;
|
|
17
|
+
}).observeCall(kind, new Date().toISOString(), 100,
|
|
18
|
+
{ model: 'claude-fable-5', messages: new Array(37).fill({}) },
|
|
19
|
+
undefined,
|
|
20
|
+
stopReason
|
|
21
|
+
? {
|
|
22
|
+
usage: { inputTokens: 64_000, outputTokens: 3, cacheReadTokens: 0, cacheCreationTokens: 0 },
|
|
23
|
+
stopReason,
|
|
24
|
+
raw: { stop_reason: stopReason, ...(category ? { stop_details: { category } } : {}) },
|
|
25
|
+
}
|
|
26
|
+
: undefined,
|
|
27
|
+
);
|
|
28
|
+
return { adapter, fired, observe };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('complete() refusal fires the dragnet with category + size', () => {
|
|
32
|
+
const { fired, observe } = makeAdapter();
|
|
33
|
+
observe('complete', 'refusal', 'reasoning_extraction');
|
|
34
|
+
expect(fired.length).toBe(1);
|
|
35
|
+
expect(fired[0]).toMatchObject({
|
|
36
|
+
kind: 'complete',
|
|
37
|
+
category: 'reasoning_extraction',
|
|
38
|
+
model: 'claude-fable-5',
|
|
39
|
+
messages: 37,
|
|
40
|
+
inputTokens: 64_000,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('streamed refusal does NOT fire (main driver already escalates those)', () => {
|
|
45
|
+
const { fired, observe } = makeAdapter();
|
|
46
|
+
observe('stream', 'refusal', 'cyber');
|
|
47
|
+
expect(fired.length).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('non-refusal completions and throwing callbacks are harmless', () => {
|
|
51
|
+
const { adapter, fired, observe } = makeAdapter();
|
|
52
|
+
observe('complete', 'end_turn');
|
|
53
|
+
observe('complete'); // error path: no response at all
|
|
54
|
+
expect(fired.length).toBe(0);
|
|
55
|
+
adapter.onRefusal = () => { throw new Error('observer bug'); };
|
|
56
|
+
expect(() => observe('complete', 'refusal', 'cyber')).not.toThrow();
|
|
57
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
|
|
4
|
+
function recipe(strategy: Record<string, unknown>) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'compression-fallback-test',
|
|
7
|
+
agent: { systemPrompt: 'sys', strategy: { type: 'autobiographical', ...strategy } },
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('compression recall-curve recipe settings', () => {
|
|
12
|
+
test('preserves valid fallback count and complete-request budget', () => {
|
|
13
|
+
const parsed = validateRecipe(recipe({
|
|
14
|
+
compressionRefusalCurveFallbacks: 3,
|
|
15
|
+
compressionContextBudgetTokens: 200_000,
|
|
16
|
+
}));
|
|
17
|
+
expect(parsed.agent.strategy?.compressionRefusalCurveFallbacks).toBe(3);
|
|
18
|
+
expect(parsed.agent.strategy?.compressionContextBudgetTokens).toBe(200_000);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('accepts zero as an explicit fallback disable', () => {
|
|
22
|
+
expect(validateRecipe(recipe({ compressionRefusalCurveFallbacks: 0 }))
|
|
23
|
+
.agent.strategy?.compressionRefusalCurveFallbacks).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rejects malformed fallback settings', () => {
|
|
27
|
+
expect(() => validateRecipe(recipe({ compressionRefusalCurveFallbacks: -1 })))
|
|
28
|
+
.toThrow(/compressionRefusalCurveFallbacks/);
|
|
29
|
+
expect(() => validateRecipe(recipe({ compressionRefusalCurveFallbacks: 1.5 })))
|
|
30
|
+
.toThrow(/compressionRefusalCurveFallbacks/);
|
|
31
|
+
expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: 0 })))
|
|
32
|
+
.toThrow(/compressionContextBudgetTokens/);
|
|
33
|
+
expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: '200000' })))
|
|
34
|
+
.toThrow(/compressionContextBudgetTokens/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
|
|
4
|
+
function recipe(primarySummaryFallback?: Record<string, unknown>) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'primary-summary-fallback-test',
|
|
7
|
+
agent: {
|
|
8
|
+
systemPrompt: 'sys',
|
|
9
|
+
refusalHandling: primarySummaryFallback
|
|
10
|
+
? { primarySummaryFallback }
|
|
11
|
+
: undefined,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('recipe primary summary fallback validation', () => {
|
|
17
|
+
test('leaves the fallback disabled by default when omitted', () => {
|
|
18
|
+
const parsed = validateRecipe(recipe());
|
|
19
|
+
expect(parsed.agent.refusalHandling?.primarySummaryFallback).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('preserves valid fallback settings', () => {
|
|
23
|
+
const parsed = validateRecipe(recipe({
|
|
24
|
+
enabled: true,
|
|
25
|
+
maxNewSummaries: 4,
|
|
26
|
+
requestBudgetTokens: 216_000,
|
|
27
|
+
}));
|
|
28
|
+
expect(parsed.agent.refusalHandling?.primarySummaryFallback).toEqual({
|
|
29
|
+
enabled: true,
|
|
30
|
+
maxNewSummaries: 4,
|
|
31
|
+
requestBudgetTokens: 216_000,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('rejects malformed fallback settings', () => {
|
|
36
|
+
expect(() => validateRecipe(recipe({ enabled: 'yes' }))).toThrow(/primarySummaryFallback\.enabled/);
|
|
37
|
+
expect(() => validateRecipe(recipe({ maxNewSummaries: -1 }))).toThrow(/maxNewSummaries/);
|
|
38
|
+
expect(() => validateRecipe(recipe({ requestBudgetTokens: 0 }))).toThrow(/requestBudgetTokens/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
3
|
+
import { buildFrameworkAgentConfig } from '../src/framework-agent-config.js';
|
|
4
|
+
|
|
5
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
6
|
+
return { name: 'think-policy-test', agent: { systemPrompt: 'sys', ...agent } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('recipe sameRoundThinkTextPolicy', () => {
|
|
10
|
+
test('valid public/private values are preserved and passed through to Agent Framework config', () => {
|
|
11
|
+
const publicRecipe = validateRecipe(recipe({ sameRoundThinkTextPolicy: 'public' }));
|
|
12
|
+
expect(publicRecipe.agent.sameRoundThinkTextPolicy).toBe('public');
|
|
13
|
+
expect(
|
|
14
|
+
buildFrameworkAgentConfig(publicRecipe, 'agent', 'model', undefined).sameRoundThinkTextPolicy,
|
|
15
|
+
).toBe('public');
|
|
16
|
+
|
|
17
|
+
const privateRecipe = validateRecipe(recipe({ sameRoundThinkTextPolicy: 'private' }));
|
|
18
|
+
expect(privateRecipe.agent.sameRoundThinkTextPolicy).toBe('private');
|
|
19
|
+
expect(
|
|
20
|
+
buildFrameworkAgentConfig(privateRecipe, 'agent', 'model', undefined).sameRoundThinkTextPolicy,
|
|
21
|
+
).toBe('private');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('omitted value stays omitted so Agent Framework can report the compatibility source', () => {
|
|
25
|
+
const parsed = validateRecipe(recipe());
|
|
26
|
+
expect(parsed.agent.sameRoundThinkTextPolicy).toBeUndefined();
|
|
27
|
+
const config = buildFrameworkAgentConfig(parsed, 'agent', 'model', undefined);
|
|
28
|
+
expect(Object.prototype.hasOwnProperty.call(config, 'sameRoundThinkTextPolicy')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('invalid strings and types are rejected', () => {
|
|
32
|
+
expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: 'secret' })))
|
|
33
|
+
.toThrow(/sameRoundThinkTextPolicy/);
|
|
34
|
+
expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: true })))
|
|
35
|
+
.toThrow(/sameRoundThinkTextPolicy/);
|
|
36
|
+
expect(() => validateRecipe(recipe({ sameRoundThinkTextPolicy: { mode: 'public' } })))
|
|
37
|
+
.toThrow(/sameRoundThinkTextPolicy/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -37,6 +37,7 @@ function ambient(
|
|
|
37
37
|
origin: {
|
|
38
38
|
source: 'discord',
|
|
39
39
|
channelId,
|
|
40
|
+
mcplChannelId: `discord:g1:${channelId}`,
|
|
40
41
|
isMention: !!opts.isMention,
|
|
41
42
|
isDM: !!opts.isDM,
|
|
42
43
|
},
|
|
@@ -59,8 +60,9 @@ describe('SubscriptionGcModule', () => {
|
|
|
59
60
|
|
|
60
61
|
r = await m.onProcess(ambient('c1', 'ghijkl'), PS); // +6 = 12 > 10 → unsub
|
|
61
62
|
expect(toolCalls.length).toBe(1);
|
|
62
|
-
expect(toolCalls[0].name).toBe('
|
|
63
|
-
expect(toolCalls[0].input.channelId).toBe('c1');
|
|
63
|
+
expect(toolCalls[0].name).toBe('channel_close');
|
|
64
|
+
expect(toolCalls[0].input.channelId).toBe('discord:g1:c1');
|
|
65
|
+
expect(toolCalls[0].input.serverId).toBe('discord');
|
|
64
66
|
expect(r.addMessages?.length).toBe(1);
|
|
65
67
|
|
|
66
68
|
await m.stop();
|
|
@@ -100,7 +102,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
100
102
|
await m.handleToolCall({
|
|
101
103
|
id: 't1',
|
|
102
104
|
name: 'set_channel_idle_limit',
|
|
103
|
-
input: { channelId: 'c1', limit: 'off' },
|
|
105
|
+
input: { channelId: 'discord:g1:c1', limit: 'off' },
|
|
104
106
|
});
|
|
105
107
|
await m.onProcess(ambient('c1', 'waytoolongambient'), PS);
|
|
106
108
|
expect(toolCalls.length).toBe(0); // pinned → never unsubscribed
|
|
@@ -108,7 +110,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
108
110
|
// A different channel still accrues, and state is persisted.
|
|
109
111
|
await m.onProcess(ambient('c2', 'abc'), PS);
|
|
110
112
|
const persisted = getState() as { overrides: Record<string, unknown>; counters: Record<string, number> };
|
|
111
|
-
expect(persisted.overrides
|
|
113
|
+
expect(persisted.overrides['discord:g1:c1']).toBe('off');
|
|
112
114
|
|
|
113
115
|
// Simulate restart: a new module loads the persisted state (counters carry
|
|
114
116
|
// across — a restart is not an activation).
|
|
@@ -128,7 +130,7 @@ describe('SubscriptionGcModule', () => {
|
|
|
128
130
|
// c2 was at 3; +3 = 6 > 5 → unsubscribe (counter survived the "restart")
|
|
129
131
|
await m2.onProcess(ambient('c2', 'def'), PS);
|
|
130
132
|
expect(restart.toolCalls.length).toBe(1);
|
|
131
|
-
expect(restart.toolCalls[0].input.channelId).toBe('c2');
|
|
133
|
+
expect(restart.toolCalls[0].input.channelId).toBe('discord:g1:c2');
|
|
132
134
|
|
|
133
135
|
await m.stop();
|
|
134
136
|
await m2.stop();
|
|
@@ -220,14 +220,19 @@ describe('scope filters', () => {
|
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
describe('ObserverSessions', () => {
|
|
223
|
-
test('mint/lookup round-trip; bad token null', () => {
|
|
223
|
+
test('mint/lookup round-trip; bad token null; full flag carried', () => {
|
|
224
224
|
const s = new ObserverSessions();
|
|
225
225
|
const scopes = new Set<ObserverScope>(['health']);
|
|
226
226
|
const token = s.mint(scopes);
|
|
227
227
|
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
|
228
|
-
expect(s.lookup(token)).toBe(scopes);
|
|
228
|
+
expect(s.lookup(token)!.scopes).toBe(scopes);
|
|
229
|
+
expect(s.lookup(token)!.full).toBe(false);
|
|
229
230
|
expect(s.lookup('0'.repeat(64))).toBeNull();
|
|
230
231
|
expect(s.lookup(null)).toBeNull();
|
|
232
|
+
|
|
233
|
+
// Full sessions (password sign-in path) carry operator authority.
|
|
234
|
+
const fullToken = s.mint(new Set(), { full: true });
|
|
235
|
+
expect(s.lookup(fullToken)!.full).toBe(true);
|
|
231
236
|
});
|
|
232
237
|
});
|
|
233
238
|
|
|
@@ -332,6 +337,26 @@ describe('WebUiModule observer flow (e2e)', () => {
|
|
|
332
337
|
ws.close();
|
|
333
338
|
}, 15_000);
|
|
334
339
|
|
|
340
|
+
test('password sign-in mints a full-session cookie the WS upgrade honors', async () => {
|
|
341
|
+
// /auth/basic with valid credentials → 302 + full-session cookie.
|
|
342
|
+
const res = await fetch(`${base()}/auth/basic`, { headers: { authorization: BASIC }, redirect: 'manual' });
|
|
343
|
+
expect(res.status).toBe(302);
|
|
344
|
+
const m = /fkm_obs=([a-f0-9]{64})/.exec(res.headers.get('set-cookie') ?? '');
|
|
345
|
+
expect(m).not.toBeNull();
|
|
346
|
+
|
|
347
|
+
// A WS upgrade carrying ONLY the cookie (no Authorization — the Chrome
|
|
348
|
+
// situation) must be treated as a FULL client: no observer-auth-required
|
|
349
|
+
// demand. (Full clients are parked silently in this app-less harness;
|
|
350
|
+
// the observer path would send auth-required immediately.)
|
|
351
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers: { cookie: `fkm_obs=${m![1]}` } } as never);
|
|
352
|
+
const firstFrame = await Promise.race([
|
|
353
|
+
new Promise<string>((r) => ws.addEventListener('message', (ev) => r(String((ev as MessageEvent).data)))),
|
|
354
|
+
new Promise<string>((r) => setTimeout(() => r('SILENCE'), 1500)),
|
|
355
|
+
]);
|
|
356
|
+
ws.close();
|
|
357
|
+
expect(firstFrame).toBe('SILENCE'); // not observer-auth-required
|
|
358
|
+
}, 10_000);
|
|
359
|
+
|
|
335
360
|
test('wrong key: hello rejected and socket closed', async () => {
|
|
336
361
|
const stranger = makeKeypair();
|
|
337
362
|
const host = `127.0.0.1:${port}`;
|