@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.
@@ -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
- const mid = String(meta.messageId);
127
- const short = mid.length > 12 ? mid.slice(0, 12) : mid;
128
- parts.push(`msg ${short}`);
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
- const short = threadId.length > 12 ? threadId.slice(0, 12) : threadId;
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: framework.ts:2108 emits inference:exhausted
90
- // for budget-restart / stream-side cancel paths. Same semantics as
91
- // aborted — benign termination, not a fault.
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 on a fresh strategy', () => {
8
- const strategy = new AutobiographicalStrategy({
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 = new AutobiographicalStrategy({
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('mcpl--discord--unsubscribe_channel');
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.c1).toBe('off');
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}`;