From 08b36b72ec1e9f20b951aa11c73b951a49a24d8f Mon Sep 17 00:00:00 2001 From: Iris-308 Date: Fri, 2 Oct 2026 01:42:26 -0400 Subject: [PATCH 01/12] fix(mcpl): derive reaction suppression defaults from effective awareness state The refusal-only constant cannot represent a framework's configured awareness marker or emojis retained by offline recovery. Derive a fresh public getPlacedReactionBaseline snapshot from refusals, the Discord adapter's default awareness marker, current configuration, and every retained outbox batch. Corrupt ledger reads surface as DiscordAwarenessAccountingError instead of silently omitting recovery markers. Inject that snapshot at the shared startup/runtime stdio connection boundary, where the framework has its effective state. Server env and intentionally inherited baseline values win, including empty strings. Preserve the original caller config so a manual restart recomputes the default rather than treating the prior derived value as an explicit override. WebSocket environments remain externally managed. The connectome-host companion removes its refusal-only default after a release containing this AF change; the old host default deliberately remains an explicit override until that coordinated cutover. This prevents an AF-only API addition from being mistaken for completed host coverage. Eleven regression tests cover the default/configured/retained marker union, deduplication and fresh snapshots, actual startup/runtime/restarted child environments, explicit and inherited empty overrides, parent-env isolation, and corrupt-ledger refusal. Original source: 4 pass / 7 fail. Fixed focused baseline + refusal + outbox tests: 20 pass / 0 fail. Build/typecheck pass; full compiled suite runs before PR submission. The host composition pair has 9 focused tests passing with this branch; its default-path test fails on the published AF version. Addresses #95; host cutover remains a declared companion dependency. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 4 + src/framework.ts | 41 ++++- src/refusal-reactions.ts | 9 +- .../reaction-baseline-mcpl-server.mjs | 26 +++ test/placed-reaction-baseline.test.ts | 159 ++++++++++++++++++ 5 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/reaction-baseline-mcpl-server.mjs create mode 100644 test/placed-reaction-baseline.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9a30b4c..77e698805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased +### Fixed + +- Discord reaction suppression defaults now include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `AgentFramework.getPlacedReactionBaseline()` exposes that snapshot; stdio MCPL startup and runtime connections inject it by default. Explicit server env and intentionally inherited baseline values, including empty strings, retain precedence. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). + ## 0.19.0 — 2026-09-28 ### Added diff --git a/src/framework.ts b/src/framework.ts index 510b9183a..0b40789db 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -59,7 +59,7 @@ import type { SameRoundThinkTextPolicy, } from './types/index.js'; import { ProcessQueueImpl } from './queue.js'; -import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js'; +import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; import { Agent } from './agent.js'; import { ModuleRegistry, isStateExistsError } from './module-registry.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; @@ -13034,7 +13034,21 @@ export class AgentFramework { ); } - const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities); + // Derive the default here, after the framework has its effective config + // and retained awareness ledger. Keep the caller's config unchanged so + // a later runtime restart derives a fresh default, not a stale override. + // WebSocket servers own their environment outside this process. + const connectionConfig = config.url ? config : { + ...config, + env: { + ...config.env, + DISCORD_SUPPRESSED_REACTIONS_BASELINE: + config.env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE + ?? (config.inheritEnv ? process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE : undefined) + ?? this.getPlacedReactionBaseline().join(','), + }, + }; + const connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities); // Wire listeners before either startup staging or the runtime global gate // releases control traffic needed for registration and marker service. @@ -13372,6 +13386,29 @@ export class AgentFramework { // Runtime MCPL server lifecycle (agent-facing hot deploy/restart/unload) // ========================================================================== + /** + * Snapshot the protective Discord reaction default for this deployment. + * Includes refusal markers, the adapter's own default awareness marker, + * the configured awareness marker, and retained outbox markers (including + * offline recovery choices and marks that can later be removed/replayed). + * Explicit adapter/operator suppression settings still take precedence. + * Throws on an unreadable ledger rather than reporting a partial baseline. + */ + getPlacedReactionBaseline(): readonly string[] { + let retained: string[]; + try { + retained = this.discordAwarenessOutbox?.batches().map((batch) => batch.emoji) ?? []; + } catch (error) { + throw new DiscordAwarenessAccountingError('reaction-baseline ledger read', error); + } + return [...new Set([ + ...REFUSAL_REACTION_BASELINE, + DEFAULT_DISCORD_AWARENESS_EMOJI, + this.discordAwarenessEmoji, + ...retained, + ])].filter((emoji) => emoji.length > 0); + } + /** * Connect a new MCPL server at runtime. Refreshes the tool list and * notifies the agent of newly available tools. Throws if the MCPL diff --git a/src/refusal-reactions.ts b/src/refusal-reactions.ts index 2ea585970..5cafd0d30 100644 --- a/src/refusal-reactions.ts +++ b/src/refusal-reactions.ts @@ -27,11 +27,10 @@ export const REFUSAL_REACTIONS: Readonly> = { /** Marker used when the refusal category has no dedicated emoji. */ export const REFUSAL_REACTION_FALLBACK = '🛑'; -/** Every marker the framework can emit — the category map plus the - * fallback, deduplicated, in stable declaration order. This IS the - * protective baseline: host composition serializes it (comma-joined) into - * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` for adapters that render - * reactions. */ +/** Every refusal marker the framework can emit: the category map plus the + * fallback, deduplicated in stable declaration order. For the deployment's + * complete protective default, including awareness markers, use + * AgentFramework.getPlacedReactionBaseline(). */ export const REFUSAL_REACTION_BASELINE: readonly string[] = [ ...new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]), ]; diff --git a/test/fixtures/reaction-baseline-mcpl-server.mjs b/test/fixtures/reaction-baseline-mcpl-server.mjs new file mode 100644 index 000000000..34bc9d374 --- /dev/null +++ b/test/fixtures/reaction-baseline-mcpl-server.mjs @@ -0,0 +1,26 @@ +// A real stdio child exposing the environment it received from the framework. +import { createInterface } from 'node:readline'; + +const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +const rl = createInterface({ input: process.stdin }); +rl.on('close', () => process.exit(0)); +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (msg.id === undefined || msg.id === null || !msg.method) return; + if (msg.method === 'initialize') { + reply(msg.id, { + protocolVersion: '2024-11-05', + capabilities: { tools: {}, experimental: { mcpl: { version: '0.5' } } }, + serverInfo: { name: 'reaction-baseline', version: '0.0.0' }, + }); + } else if (msg.method === 'tools/list') { + reply(msg.id, { tools: [{ name: 'read_env', description: 'Read fixture environment', inputSchema: { type: 'object', properties: {} } }] }); + } else if (msg.method === 'tools/call') { + reply(msg.id, { content: [{ type: 'text', text: JSON.stringify({ + baseline: process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE, + extra: process.env.BASELINE_TEST_EXTRA, + }) }] }); + } else { + reply(msg.id, { accepted: true }); + } +}); diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts new file mode 100644 index 000000000..bcdd1b44e --- /dev/null +++ b/test/placed-reaction-baseline.test.ts @@ -0,0 +1,159 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AgentFramework } from '../src/framework.js'; +import { REFUSAL_REACTION_BASELINE } from '../src/refusal-reactions.js'; +import { DiscordAwarenessOutbox } from '../src/recovery/discord-awareness-outbox.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +const ENV_KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; +const FIXTURE = fileURLToPath(new URL('./fixtures/reaction-baseline-mcpl-server.mjs', import.meta.url)); + +function server(overrides: Partial = {}): McplServerConfig { + return { id: 'probe', command: process.execPath, args: [FIXTURE], ...overrides }; +} + +function baseline(framework: AgentFramework): readonly string[] { + // Keep this runtime API assertion compilable against the unfixed source. + return (framework as unknown as { getPlacedReactionBaseline(): readonly string[] }).getPlacedReactionBaseline(); +} + +async function childEnv(framework: AgentFramework) { + const registry = (framework as unknown as { + mcplServerRegistry: { getServer(id: string): { sendToolsCall(name: string, args: {}): Promise<{ content: Array<{ text?: string }> }> } }; + }).mcplServerRegistry; + const result = await registry.getServer('probe').sendToolsCall('read_env', {}); + return JSON.parse(result.content[0]!.text!) as { baseline?: string; extra?: string }; +} + +describe('placed-reaction baseline', () => { + let dir: string; + let outboxPath: string; + let framework: AgentFramework | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'placed-reactions-')); + outboxPath = join(dir, 'outbox.json'); + }); + + afterEach(async () => { + // A corrupt-ledger test must not leave teardown trying to reconcile it. + rmSync(outboxPath, { force: true }); + await framework?.stop(); + framework = undefined; + rmSync(dir, { recursive: true, force: true }); + }); + + async function create(emoji?: string, servers: McplServerConfig[] = []) { + framework = await AgentFramework.create({ + storePath: join(dir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'Resident.' }], + modules: [], + discordAwarenessOutboxPath: outboxPath, + discordAwarenessEmoji: emoji, + mcplServers: servers, + }); + return framework; + } + + function retain(emoji: string) { + const outbox = new DiscordAwarenessOutbox(outboxPath); + return outbox.prepare({ + agentName: 'resident', + sourceBranch: 'main', + targetBranch: 'later-recovery', + emoji, + refs: [{ serverId: 'discord', channelId: 'discord:guild:channel', messageId: emoji }], + })!; + } + + it('includes default awareness and refusal markers once, with a fresh snapshot', async () => { + const fw = await create(); + const expected = [...REFUSAL_REACTION_BASELINE, '💤']; + assert.deepEqual(baseline(fw), expected); + (baseline(fw) as string[]).push('not-a-framework-marker'); + assert.deepEqual(baseline(fw), expected); + }); + + it('keeps the adapter default alongside configured and retained offline awareness markers', async () => { + retain('🫥'); + const completed = retain('👁️'); + const outbox = new DiscordAwarenessOutbox(outboxPath); + outbox.activate(completed.id); + outbox.recordSuccess(completed.id, completed.refs[0]!, 'add'); + const fw = await create('🔕'); + assert.deepEqual(new Set(baseline(fw)), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕', '🫥', '👁️'])); + assert.equal(baseline(fw).length, new Set(baseline(fw)).size); + }); + + it('deduplicates a configured marker already used for refusals', async () => { + const fw = await create(REFUSAL_REACTION_BASELINE[0]); + assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤']); + }); + + it('injects the complete default into a real child at framework startup', async () => { + retain('🫥'); + const config = server({ env: { BASELINE_TEST_EXTRA: 'preserved' } }); + const fw = await create('🔕', [config]); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕', '🫥'])); + assert.equal(env.extra, 'preserved'); + assert.deepEqual(config.env, { BASELINE_TEST_EXTRA: 'preserved' }, 'caller config stays unchanged'); + }); + + it('derives the default on runtime connect, including newly retained ledger markers', async () => { + const fw = await create('🔕'); + retain('🫥'); + await fw.connectMcplServer(server()); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕', '🫥'])); + retain('👁️'); + await fw.restartMcplServer('probe'); + assert.ok((await childEnv(fw)).baseline?.split(',').includes('👁️'), 'restart recomputes a default rather than retaining it as an explicit override'); + }); + + for (const value of ['🟪', '']) { + it('preserves an explicit server baseline ' + JSON.stringify(value), async () => { + const fw = await create(undefined, [server({ env: { [ENV_KEY]: value } })]); + assert.equal((await childEnv(fw)).baseline, value); + }); + + it('preserves an intentionally inherited baseline ' + JSON.stringify(value), async (t) => { + const previous = process.env[ENV_KEY]; + process.env[ENV_KEY] = value; + t.after(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + const fw = await create(undefined, [server({ inheritEnv: true })]); + assert.equal((await childEnv(fw)).baseline, value); + }); + } + + it('does not use an unrequested parent override; explicit server env wins over inheritance', async (t) => { + const previous = process.env[ENV_KEY]; + process.env[ENV_KEY] = ''; + t.after(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + const fw = await create(undefined, [server()]); + assert.equal((await childEnv(fw)).baseline, baseline(fw).join(',')); + await fw.disconnectMcplServer('probe'); + await fw.connectMcplServer(server({ inheritEnv: true, env: { [ENV_KEY]: '🟪' } })); + assert.equal((await childEnv(fw)).baseline, '🟪'); + }); + + it('fails explicitly on a corrupt retained ledger rather than deriving an incomplete default', async () => { + const fw = await create(undefined, [server()]); + writeFileSync(outboxPath, '{broken'); + assert.throws(() => baseline(fw), /Discord awareness.*ledger/i); + await fw.disconnectMcplServer('probe'); + await assert.rejects(() => fw.connectMcplServer(server()), /Discord awareness.*ledger/i); + }); +}); From d70e6e76a6cb70d690b3a5e802f51b2ab34662e9 Mon Sep 17 00:00:00 2001 From: Iris-308 Date: Fri, 2 Oct 2026 01:43:03 -0400 Subject: [PATCH 02/12] docs(recovery): explain effective reaction suppression and host cutover Describe retained/default/configured marker coverage, explicit and inherited override precedence, the public snapshot API, remote adapter responsibility, and the old-host migration. This documents the implemented boundary without claiming a coordinated host release already exists. Co-Authored-By: GPT-6 Astra --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 180c94114..614a1426d 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,10 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Explicit server env values and baseline values intentionally inherited with `inheritEnv: true` take precedence, including empty strings; the adapter's filters-file and legacy operator-env precedence remain unchanged. + +`framework.getPlacedReactionBaseline()` returns a fresh snapshot for other embeddings, including separately managed WebSocket adapters. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. + ### MCPL (MCP Live) Optional host-side implementation of the MCP Live protocol. External servers (game engines, dev tools, etc.) can: From cf4e2d555f66d51861d2390913717585b9864b07 Mon Sep 17 00:00:00 2001 From: Iris-308 Date: Fri, 2 Oct 2026 02:18:12 -0400 Subject: [PATCH 03/12] fix(mcpl): honor Windows casing before reaction baseline defaulting Wren's cross-review with PR #205 found that this earlier connection-composition boundary still looked up only the uppercase baseline key. A declared lowercase empty value therefore coexisted with an injected uppercase default, and Windows could select the default. Correcting the later transport merge alone cannot repair an already-generated value that displaced an operator choice. On Windows, resolve the last case-insensitive declared baseline spelling before applying the default. Put that chosen value under the canonical injected key so it survives both Node's Windows sorted-name selection and #205's case-folded merge. POSIX keeps case-sensitive names. Existing inherited/explicit precedence and empty-string semantics are unchanged. Add three platform-scoped tests through the real connection-construction method, intercepted before spawn. They cover lowercase and mixed-case choices, empty values, duplicate declared spellings, both inheritance modes, caller immutability, Node's sorted Windows selection, and POSIX distinction. The synchronous process.platform override is restored before awaiting any work. This is simulated Windows coverage on Linux, not a native Windows process test. Regression evidence: submitted d70e6e7 gives 1 pass / 2 fail; corrected platform/baseline/child-env tests give 18 pass / 0 fail. Build, typecheck, and diff checks pass. The unchanged host companion's paired tests pass 9/9 with this AF source. Full bounded compiled suite and independent review follow before publication. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 2 +- README.md | 2 +- src/framework.ts | 10 +++- test/reaction-baseline-platform.test.ts | 74 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 test/reaction-baseline-platform.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e698805..bb17f9418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Releases up to and including 0.7.3 predate this file; for their contents see ### Fixed -- Discord reaction suppression defaults now include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `AgentFramework.getPlacedReactionBaseline()` exposes that snapshot; stdio MCPL startup and runtime connections inject it by default. Explicit server env and intentionally inherited baseline values, including empty strings, retain precedence. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults now include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `AgentFramework.getPlacedReactionBaseline()` exposes that snapshot; stdio MCPL startup and runtime connections inject it by default. Explicit server env (case-insensitive on Windows) and intentionally inherited baseline values, including empty strings, retain precedence. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). ## 0.19.0 — 2026-09-28 diff --git a/README.md b/README.md index 614a1426d..d66725908 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Explicit server env values and baseline values intentionally inherited with `inheritEnv: true` take precedence, including empty strings; the adapter's filters-file and legacy operator-env precedence remain unchanged. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Explicit server env values (case-insensitive on Windows) and baseline values intentionally inherited with `inheritEnv: true` take precedence, including empty strings; the adapter's filters-file and legacy operator-env precedence remain unchanged. `framework.getPlacedReactionBaseline()` returns a fresh snapshot for other embeddings, including separately managed WebSocket adapters. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. diff --git a/src/framework.ts b/src/framework.ts index 0b40789db..21f41e7fb 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13038,12 +13038,20 @@ export class AgentFramework { // and retained awareness ledger. Keep the caller's config unchanged so // a later runtime restart derives a fresh default, not a stale override. // WebSocket servers own their environment outside this process. + // Windows variable names are case-insensitive. Resolve the last declared + // spelling before defaulting, so a generated uppercase key cannot defeat + // a mixed/lowercase override at the later environment merge or spawn. + const declaredBaseline = process.platform === 'win32' + ? Object.entries(config.env ?? {}) + .filter(([key]) => key.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE') + .at(-1)?.[1] + : config.env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; const connectionConfig = config.url ? config : { ...config, env: { ...config.env, DISCORD_SUPPRESSED_REACTIONS_BASELINE: - config.env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE + declaredBaseline ?? (config.inheritEnv ? process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE : undefined) ?? this.getPlacedReactionBaseline().join(','), }, diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts new file mode 100644 index 000000000..303f7a3bc --- /dev/null +++ b/test/reaction-baseline-platform.test.ts @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AgentFramework } from '../src/framework.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; + +const KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; + +/** Exercise the real default-injection boundary, intercepting immediately + * before connection/spawn. The platform override is synchronous and restored + * before awaiting anything, so no asynchronous work sees the simulated OS. */ +async function connectionEnv( + platform: NodeJS.Platform, + env: Record, + inheritEnv = false, +): Promise> { + let captured: McplServerConfig | undefined; + const stopBeforeSpawn = new Error('captured before spawn'); + const framework = Object.assign(Object.create(AgentFramework.prototype), { + mcplHostCapabilities: {}, + mcplPrefixMap: new Map(), + mcplServerConfigs: new Map(), + channelRegistry: null, + discordAwarenessOutbox: null, + discordAwarenessEmoji: '🔕', + mcplServerRegistry: { + async addServer(config: McplServerConfig) { + captured = config; + throw stopBeforeSpawn; + }, + }, + }) as AgentFramework; + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const originalEnv = { ...env }; + let connection: Promise; + try { + Object.defineProperty(process, 'platform', { ...originalPlatform, value: platform }); + connection = (framework as unknown as { + connectMcplServerInternal(config: McplServerConfig): Promise; + }).connectMcplServerInternal({ id: 'probe', command: 'unused', env, inheritEnv }); + } finally { + Object.defineProperty(process, 'platform', originalPlatform); + } + await assert.rejects(connection!, (error) => error === stopBeforeSpawn); + assert.deepEqual(env, originalEnv, 'connection composition must not mutate the caller env'); + assert.ok(captured?.env); + return captured.env; +} + +for (const inheritEnv of [false, true]) { + test('Windows reaction baseline honors declared case variants, inheritEnv=' + inheritEnv, async () => { + const cases: Array> = [ + { [KEY.toLowerCase()]: '' }, + { Discord_Suppressed_Reactions_Baseline: '🟪' }, + { [KEY]: 'first', [KEY.toLowerCase()]: '' }, + { [KEY.toLowerCase()]: 'first', [KEY]: 'last' }, + ]; + for (const env of cases) { + const expected = Object.values(env).at(-1); + const composed = await connectionEnv('win32', env, inheritEnv); + // Node's Windows spawn sorts and keeps the first spelling of a folded + // name. The chosen value must survive that step as well as #205's merge. + const emittedKey = Object.keys(composed).sort().find((key) => key.toUpperCase() === KEY)!; + assert.equal(composed[emittedKey], expected); + assert.equal(composed[KEY], expected, 'a generated default must not replace an explicit choice'); + } + }); +} + +test('POSIX reaction baseline keeps differently cased env names distinct', async () => { + const composed = await connectionEnv('linux', { [KEY.toLowerCase()]: '' }); + assert.equal(composed[KEY.toLowerCase()], ''); + assert.ok(composed[KEY]!.split(',').includes('💤')); + assert.ok(composed[KEY]!.split(',').includes('🔕')); +}); From 327080add76b323396bca4169ac423eb903a6007 Mon Sep 17 00:00:00 2001 From: Iris-308 Date: Fri, 2 Oct 2026 02:34:58 -0400 Subject: [PATCH 04/12] fix(mcpl): follow selected transport and inherited Windows baseline names Use the same isWebSocketTransport selector as openTransport when deciding whether to inject a stdio child default. Checking URL presence diverged when command and URL were both present, or when transport explicitly selected stdio. Greptile reproduced the omission in PR #211. Reusing the selector also preserves explicit WebSocket precedence over a command. Apply the Windows case-insensitive baseline lookup to intentionally inherited process.env as well as declared env. Wren's review found that Windows Worker copies are case-sensitive objects, so relying on the main-thread process.env property lookup still replaced an inherited lowercase empty choice with the generated default. Both sources now use one last-spelling lookup, while POSIX remains case-sensitive and parent lookup remains opt-in. Add inherited-only Worker-style env cases (empty/nonempty, mixed and duplicate spellings), POSIX/opt-in controls, two real child tests for command+URL with implicit/explicit stdio selection, and an explicit-WebSocket control. The inherited regression fails on cf4e2d5 (4 pass / 1 fail); before the transport selector fix the two real-child cases fail (17 pass / 2 fail in the combined files). Corrected platform/baseline/child-env tests pass 23/23, build/typecheck pass, and the unchanged host pair passes 9/9. Simulated Windows cases intercept the real connection boundary before spawn and restore platform/env before awaiting; native Windows execution was not exercised. Full compiled suite and independent re-review precede pushing both Windows follow-up commits. The host draft retains its published-release/dependency-floor prerequisite. Addresses https://github.com/anima-research/agent-framework/pull/211#discussion_r4163218034 and Wren's inherited-only finding in Commons #18671. Co-Authored-By: GPT-6 Astra --- src/framework.ts | 20 ++++++------ test/placed-reaction-baseline.test.ts | 8 +++++ test/reaction-baseline-platform.test.ts | 41 +++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 21f41e7fb..d78435da3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -385,6 +385,7 @@ import { EventGate, formatShadowWarning } from './gate/event-gate.js'; import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; +import { isWebSocketTransport } from './mcpl/transport.js'; import type { McplServerConfig, McplHostCapabilities, @@ -13038,21 +13039,22 @@ export class AgentFramework { // and retained awareness ledger. Keep the caller's config unchanged so // a later runtime restart derives a fresh default, not a stale override. // WebSocket servers own their environment outside this process. - // Windows variable names are case-insensitive. Resolve the last declared - // spelling before defaulting, so a generated uppercase key cannot defeat - // a mixed/lowercase override at the later environment merge or spawn. - const declaredBaseline = process.platform === 'win32' - ? Object.entries(config.env ?? {}) + // Match Windows names in both sources: Workers expose a case-sensitive + // process.env copy. Resolve the last spelling before defaulting so a + // generated uppercase key cannot defeat a mixed/lowercase choice at + // the later environment merge or spawn. + const baselineValue = (env: NodeJS.ProcessEnv | undefined) => process.platform === 'win32' + ? Object.entries(env ?? {}) .filter(([key]) => key.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE') .at(-1)?.[1] - : config.env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; - const connectionConfig = config.url ? config : { + : env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; + const connectionConfig = isWebSocketTransport(config) ? config : { ...config, env: { ...config.env, DISCORD_SUPPRESSED_REACTIONS_BASELINE: - declaredBaseline - ?? (config.inheritEnv ? process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE : undefined) + baselineValue(config.env) + ?? (config.inheritEnv ? baselineValue(process.env) : undefined) ?? this.getPlacedReactionBaseline().join(','), }, }; diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index bcdd1b44e..13a0a2081 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -106,6 +106,14 @@ describe('placed-reaction baseline', () => { assert.deepEqual(config.env, { BASELINE_TEST_EXTRA: 'preserved' }, 'caller config stays unchanged'); }); + for (const transport of [undefined, 'stdio'] as const) { + it('injects the baseline with command + URL and ' + (transport ?? 'implicit') + ' transport', async () => { + const fw = await create('🔕', [server({ url: 'ws://127.0.0.1:1', transport })]); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕'])); + }); + } + it('derives the default on runtime connect, including newly retained ledger markers', async () => { const fw = await create('🔕'); retain('🫥'); diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts index 303f7a3bc..c882d2cbc 100644 --- a/test/reaction-baseline-platform.test.ts +++ b/test/reaction-baseline-platform.test.ts @@ -6,12 +6,14 @@ import type { McplServerConfig } from '../src/mcpl/types.js'; const KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; /** Exercise the real default-injection boundary, intercepting immediately - * before connection/spawn. The platform override is synchronous and restored - * before awaiting anything, so no asynchronous work sees the simulated OS. */ + * before connection/spawn. Platform/env overrides are synchronous and restored + * before awaiting anything, so asynchronous work sees the real process state. */ async function connectionEnv( platform: NodeJS.Platform, env: Record, inheritEnv = false, + parentEnv?: NodeJS.ProcessEnv, + transportConfig: Pick = {}, ): Promise> { let captured: McplServerConfig | undefined; const stopBeforeSpawn = new Error('captured before spawn'); @@ -30,15 +32,18 @@ async function connectionEnv( }, }) as AgentFramework; const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const originalParentEnv = Object.getOwnPropertyDescriptor(process, 'env')!; const originalEnv = { ...env }; let connection: Promise; try { Object.defineProperty(process, 'platform', { ...originalPlatform, value: platform }); + if (parentEnv) Object.defineProperty(process, 'env', { ...originalParentEnv, value: parentEnv }); connection = (framework as unknown as { connectMcplServerInternal(config: McplServerConfig): Promise; - }).connectMcplServerInternal({ id: 'probe', command: 'unused', env, inheritEnv }); + }).connectMcplServerInternal({ id: 'probe', command: 'unused', env, inheritEnv, ...transportConfig }); } finally { Object.defineProperty(process, 'platform', originalPlatform); + Object.defineProperty(process, 'env', originalParentEnv); } await assert.rejects(connection!, (error) => error === stopBeforeSpawn); assert.deepEqual(env, originalEnv, 'connection composition must not mutate the caller env'); @@ -66,6 +71,36 @@ for (const inheritEnv of [false, true]) { }); } +test('Windows inherited-only baseline lookup handles case-sensitive worker env copies', async () => { + for (const value of ['', '🟪']) { + const parents: NodeJS.ProcessEnv[] = [ + { [KEY.toLowerCase()]: value }, + { Discord_Suppressed_Reactions_Baseline: value }, + { [KEY]: 'first', [KEY.toLowerCase()]: value }, + { [KEY.toLowerCase()]: 'first', [KEY]: value }, + ]; + for (const parent of parents) { + const composed = await connectionEnv('win32', {}, true, parent); + assert.equal(composed[KEY], value); + } + } +}); + +test('inherited-only baseline lookup remains opt-in and POSIX case-sensitive', async () => { + for (const [platform, inheritEnv] of [['win32', false], ['linux', true]] as const) { + const composed = await connectionEnv(platform, {}, inheritEnv, { [KEY.toLowerCase()]: '' }); + assert.ok(composed[KEY]!.split(',').includes('💤')); + } + const explicitPosix = await connectionEnv('linux', {}, true, { [KEY]: '' }); + assert.equal(explicitPosix[KEY], ''); +}); + +test('explicit WebSocket selection leaves its env alone even when a command is also present', async () => { + const env = { EXTRA: 'value' }; + const composed = await connectionEnv('linux', env, false, undefined, { transport: 'websocket', url: 'ws://unused' }); + assert.deepEqual(composed, env); +}); + test('POSIX reaction baseline keeps differently cased env names distinct', async () => { const composed = await connectionEnv('linux', { [KEY.toLowerCase()]: '' }); assert.equal(composed[KEY.toLowerCase()], ''); From 96940cd97ecfa259b0f3189a4388166dc8deb6ff Mon Sep 17 00:00:00 2001 From: Iris-308 Date: Fri, 2 Oct 2026 15:03:52 -0400 Subject: [PATCH 05/12] fix(discord): align reaction defaults with spawn and custom-emote matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weft's firsthand #211 review identified two remaining coverage gaps. Node24 normalizeSpawnArguments selects the first lexicographically sorted case-insensitive Windows key, while our prior baseline lookup selected last insertion. And DiscordFiltersState on discord-mcpl 6f38d13 matches custom IDs only from bare 17–20-digit tokens: composite awareness markers were accepted into the baseline but never suppressed their actual events. Resolve each declared/inherited baseline source by sorted-first spelling, then retain declared-over-host source precedence, including empty values. This corrects our earlier assumption rather than changing the operator override contract. Felix is aligning the general child-env merge in #205 separately; its candidate is 038e06b and a focused composition check follows. Map recognized full/bracketed or name:id custom markers to their stable ID in the derived snapshot before deduplication. Unicode, bare names and explicit environment choices remain unchanged, as do stored outbox emojis and the actual reaction payloads. A rename therefore does not reopen suppression, and another emoji with the same name is not accidentally suppressed. Expanded red regressions before the final extra bare-animated control: 19 pass / 8 fail. Final compiled baseline/platform/outbox/child-env focused suite: 38 pass / 0 fail; build/typecheck pass. A separate round-trip through real DiscordFiltersState at frozen 6f38d13 failed all three composite forms before and passes all three after, with Unicode, different-ID, legacy-empty and file-empty controls. Tests simulate Windows platform/env on Linux; native Windows spawn remains outside the evidence. Move only this PR's direct CHANGELOG entry to the unique fragment explicitly requested by coordinating Felix in Commons #20070. The prior commit merges current main df97a85 while retaining reviewed history. Full validation, #205/host composition and independent review are owned before push. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 4 -- .../211-placed-reaction-baseline.fixed.md | 1 + src/framework.ts | 28 ++++++++----- test/placed-reaction-baseline.test.ts | 29 +++++++++++++- test/reaction-baseline-platform.test.ts | 39 ++++++++++++------- 5 files changed, 73 insertions(+), 28 deletions(-) create mode 100644 changelog.d/211-placed-reaction-baseline.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bb17f9418..6b9a30b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,6 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased -### Fixed - -- Discord reaction suppression defaults now include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `AgentFramework.getPlacedReactionBaseline()` exposes that snapshot; stdio MCPL startup and runtime connections inject it by default. Explicit server env (case-insensitive on Windows) and intentionally inherited baseline values, including empty strings, retain precedence. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). - ## 0.19.0 — 2026-09-28 ### Added diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md new file mode 100644 index 000000000..0f01450ea --- /dev/null +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -0,0 +1 @@ +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `AgentFramework.getPlacedReactionBaseline()` exposes that snapshot, converting composite custom-emote markers to stable IDs for suppression; stored markers and reaction payloads remain unchanged. Stdio MCPL startup and runtime connections inject it by default. Explicit server env and intentionally inherited baseline values, including empty strings, retain precedence. On Windows, duplicate spellings resolve lexicographically within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index a4d13a28d..8a146f89e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13173,14 +13173,15 @@ export class AgentFramework { // a later runtime restart derives a fresh default, not a stale override. // WebSocket servers own their environment outside this process. // Match Windows names in both sources: Workers expose a case-sensitive - // process.env copy. Resolve the last spelling before defaulting so a - // generated uppercase key cannot defeat a mixed/lowercase choice at - // the later environment merge or spawn. - const baselineValue = (env: NodeJS.ProcessEnv | undefined) => process.platform === 'win32' - ? Object.entries(env ?? {}) - .filter(([key]) => key.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE') - .at(-1)?.[1] - : env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; + // process.env copy. Within one source, Node's spawn keeps the first + // lexicographically sorted spelling. Resolve each source that way, + // then keep the declared source's precedence over inherited values. + const baselineValue = (env: NodeJS.ProcessEnv | undefined) => { + if (process.platform !== 'win32') return env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; + const key = Object.keys(env ?? {}).sort() + .find((name) => name.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'); + return key === undefined ? undefined : env?.[key]; + }; const connectionConfig = isWebSocketTransport(config) ? config : { ...config, env: { @@ -13534,6 +13535,8 @@ export class AgentFramework { * Includes refusal markers, the adapter's own default awareness marker, * the configured awareness marker, and retained outbox markers (including * offline recovery choices and marks that can later be removed/replayed). + * Composite custom-emote markers become bare IDs for Discord's suppression + * matcher; stored markers and the actual add/remove payloads stay unchanged. * Explicit adapter/operator suppression settings still take precedence. * Throws on an unreadable ledger rather than reporting a partial baseline. */ @@ -13544,12 +13547,19 @@ export class AgentFramework { } catch (error) { throw new DiscordAwarenessAccountingError('reaction-baseline ledger read', error); } + // Discord events carry the custom emoji's name and ID separately. Full + // marker forms belong in add/remove calls, but suppression matches their + // stable ID (including after a rename), rather than the composite token. + const suppressionToken = (emoji: string): string => { + const custom = /^(?:|(?:a:)?\w+:(\d{17,20}))$/.exec(emoji.trim()); + return custom?.[1] ?? custom?.[2] ?? emoji; + }; return [...new Set([ ...REFUSAL_REACTION_BASELINE, DEFAULT_DISCORD_AWARENESS_EMOJI, this.discordAwarenessEmoji, ...retained, - ])].filter((emoji) => emoji.length > 0); + ].map(suppressionToken))].filter((emoji) => emoji.length > 0); } /** diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index 13a0a2081..c00a1d130 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -91,6 +91,33 @@ describe('placed-reaction baseline', () => { assert.equal(baseline(fw).length, new Set(baseline(fw)).size); }); + for (const emoji of ['<:mark:123456789012345678>', '', 'mark:123456789012345678', 'a:mark:123456789012345678']) { + it('injects a stable custom-emote ID for configured marker ' + emoji, async () => { + const fw = await create(emoji, [server()]); + const expected = [...REFUSAL_REACTION_BASELINE, '💤', '123456789012345678']; + assert.deepEqual(baseline(fw), expected); + assert.equal((await childEnv(fw)).baseline, expected.join(',')); + }); + } + + it('canonicalizes retained custom markers before deduplication without changing the ledger', async () => { + const first = retain('<:old_name:123456789012345678>'); + retain('new_name:123456789012345678'); + retain(''); + const fw = await create('123456789012345678'); + assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤', '123456789012345678', '123456789012345679']); + const stored = new DiscordAwarenessOutbox(outboxPath).batches(); + assert.equal(stored.find((batch) => batch.id === first.id)?.emoji, '<:old_name:123456789012345678>'); + }); + + it('preserves Unicode and name tokens while accepting padded full custom markers', async () => { + retain('👩‍💻'); + retain(':name_only:'); + retain('not:a:custom'); + const fw = await create(' <:mark:123456789012345678> '); + assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤', '123456789012345678', '👩‍💻', ':name_only:', 'not:a:custom']); + }); + it('deduplicates a configured marker already used for refusals', async () => { const fw = await create(REFUSAL_REACTION_BASELINE[0]); assert.deepEqual(baseline(fw), [...REFUSAL_REACTION_BASELINE, '💤']); @@ -125,7 +152,7 @@ describe('placed-reaction baseline', () => { assert.ok((await childEnv(fw)).baseline?.split(',').includes('👁️'), 'restart recomputes a default rather than retaining it as an explicit override'); }); - for (const value of ['🟪', '']) { + for (const value of ['🟪', '', '<:operator_choice:123456789012345678>']) { it('preserves an explicit server baseline ' + JSON.stringify(value), async () => { const fw = await create(undefined, [server({ env: { [ENV_KEY]: value } })]); assert.equal((await childEnv(fw)).baseline, value); diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts index c882d2cbc..24974168f 100644 --- a/test/reaction-baseline-platform.test.ts +++ b/test/reaction-baseline-platform.test.ts @@ -53,14 +53,15 @@ async function connectionEnv( for (const inheritEnv of [false, true]) { test('Windows reaction baseline honors declared case variants, inheritEnv=' + inheritEnv, async () => { - const cases: Array> = [ - { [KEY.toLowerCase()]: '' }, - { Discord_Suppressed_Reactions_Baseline: '🟪' }, - { [KEY]: 'first', [KEY.toLowerCase()]: '' }, - { [KEY.toLowerCase()]: 'first', [KEY]: 'last' }, + const cases: Array<{ env: Record; expected: string }> = [ + { env: { [KEY.toLowerCase()]: '' }, expected: '' }, + { env: { Discord_Suppressed_Reactions_Baseline: '🟪' }, expected: '🟪' }, + { env: { [KEY]: 'upper', [KEY.toLowerCase()]: '' }, expected: 'upper' }, + { env: { [KEY.toLowerCase()]: '', [KEY]: 'upper' }, expected: 'upper' }, + { env: { [KEY]: '', [KEY.toLowerCase()]: 'lower' }, expected: '' }, + { env: { [KEY.toLowerCase()]: 'lower', [KEY]: '' }, expected: '' }, ]; - for (const env of cases) { - const expected = Object.values(env).at(-1); + for (const { env, expected } of cases) { const composed = await connectionEnv('win32', env, inheritEnv); // Node's Windows spawn sorts and keeps the first spelling of a folded // name. The chosen value must survive that step as well as #205's merge. @@ -73,19 +74,29 @@ for (const inheritEnv of [false, true]) { test('Windows inherited-only baseline lookup handles case-sensitive worker env copies', async () => { for (const value of ['', '🟪']) { - const parents: NodeJS.ProcessEnv[] = [ - { [KEY.toLowerCase()]: value }, - { Discord_Suppressed_Reactions_Baseline: value }, - { [KEY]: 'first', [KEY.toLowerCase()]: value }, - { [KEY.toLowerCase()]: 'first', [KEY]: value }, + const cases: Array<{ parent: NodeJS.ProcessEnv; expected: string }> = [ + { parent: { [KEY.toLowerCase()]: value }, expected: value }, + { parent: { Discord_Suppressed_Reactions_Baseline: value }, expected: value }, + { parent: { [KEY]: 'upper', [KEY.toLowerCase()]: value }, expected: 'upper' }, + { parent: { [KEY.toLowerCase()]: value, [KEY]: 'upper' }, expected: 'upper' }, + { parent: { [KEY]: value, [KEY.toLowerCase()]: 'lower' }, expected: value }, + { parent: { [KEY.toLowerCase()]: 'lower', [KEY]: value }, expected: value }, ]; - for (const parent of parents) { + for (const { parent, expected } of cases) { const composed = await connectionEnv('win32', {}, true, parent); - assert.equal(composed[KEY], value); + assert.equal(composed[KEY], expected); } } }); +test('Windows chooses duplicate spellings within each source, then declared over inherited', async () => { + const parent = { [KEY]: 'host-upper', [KEY.toLowerCase()]: 'host-lower' }; + const composed = await connectionEnv('win32', { [KEY.toLowerCase()]: '', [KEY]: 'declared-upper' }, true, parent); + assert.equal(composed[KEY], 'declared-upper'); + const singleDeclared = await connectionEnv('win32', { [KEY.toLowerCase()]: '' }, true, parent); + assert.equal(singleDeclared[KEY], '', 'declared lowercase empty defeats inherited uppercase'); +}); + test('inherited-only baseline lookup remains opt-in and POSIX case-sensitive', async () => { for (const [platform, inheritEnv] of [['win32', false], ['linux', true]] as const) { const composed = await connectionEnv(platform, {}, inheritEnv, { [KEY.toLowerCase()]: '' }); From 2c328b7e49d15f6b274e5f44d92e24ddf034b3f4 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 11:28:04 -0400 Subject: [PATCH 06/12] fix(discord): derive the reaction default at each spawn; a blank value is unset; restart refuses before stopping the child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The haiku sweep of #211 (sw-af211, Oscar-1808's merge procedure; verdicts room-377 #70842) found 18 issues at 96940cd; 13 are fix-now, all here (I12's is the PR body's). Code: - I1: a stdio child's DISCORD_SUPPRESSED_REACTIONS_BASELINE is derived at each spawn. connectionConfig.env is a getter, read by the transport's spawn at first connect, at every automatic reconnect and at a stub's retry (attemptReconnect -> handshake -> openTransport -> StdioTransport.spawn -> buildChildEnv), so a marker retained since the last spawn reaches the next child. Before, the default baked into the stored config at first connect was reused by every respawn. The first connect still derives once, so an unreadable ledger refuses before anything spawns. The caller's config stays unchanged. - I4: restartMcplServer derives the default before disconnecting, so an unreadable ledger refuses the restart and leaves the running child up. - I8: a blank value (empty or whitespace), declared or inherited, counts as unset and the default is derived. discord-mcpl reads an empty value as "no tokens" (parseSuppressionEnvTokens, its backward-compatible OFF), so a blank inherited variable silently turned the protective default off. A non-blank operator value still wins, and the framework logs at connect that it replaces the default ("from its env" or "inherited from the host"). The precedence rules move into declaredReactionBaseline(config), used by connect and restart: declared first, then inherited with inheritEnv; on Windows the first spelling in sorted order within each source, as Node's spawn keeps it. - I14: the refusal emoji lookup uses Object.hasOwn, so a category named like an inherited property ('constructor', 'toString', '__proto__') takes the 🛑 fallback instead of reaching Object.prototype. Text: - I2, I9: refusal-reactions.ts and its test header: REFUSAL_REACTION_BASELINE is the refusal set only; the protective default the framework injects is framework.getPlacedReactionBaseline() on an instance. - I15: getPlacedReactionBaseline's doc and the README: it returns suppression tokens (a custom emoji as its bare ID), not marker payloads. - I18: the comment at the refusal-marker site names DISCORD_SUPPRESSED_REACTIONS_BASELINE. - README and fragment: derived at each spawn (I1); the connect/restart refusal on an unreadable ledger (I3, I4); blank as unset and the log line (I8); suppression is by emoji, so members' 💤 or configured-emoji reactions are dropped too, as their 🛑 already were (I17); the instance form (I9). Tests: - placed-reaction-baseline (30): a reconnect respawns the child with a marker retained since (I1; the fixture's 'crash' tool exits the child); a restart with a corrupt ledger refuses and the child still answers (I4); blank '' and ' ' declared or inherited give the literal default (I8); the log line (I8); a URL-only WebSocket config is passed to addServer as the same object with no env (I16); custom IDs at 16 and 21 digits stay composite, at 17 and 20 reduce to the ID, and a short composite stays (I5). The inherited- override expectation is the literal default, not the function's own report (I13). The explicit-'' cases became the blank cases. - reaction-baseline-platform (7): where the spelling Node keeps is blank, the child gets the default; a blank declared value no longer defeats an inherited one. - refusal-reaction-baseline (4): inherited property names take the fallback (I14). Mutations of the compiled framework, each killed: the default derived once (no getter), the restart check after the disconnect, a blank value kept, no log line, the plain lookup, the ID bounds at 16-20, 17-21 and 17-18, and WebSocket configs given the env. tsc clean. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../211-placed-reaction-baseline.fixed.md | 2 +- src/framework.ts | 114 ++++++++++++------ src/refusal-reactions.ts | 19 +-- .../reaction-baseline-mcpl-server.mjs | 3 + test/placed-reaction-baseline.test.ts | 90 +++++++++++++- test/reaction-baseline-platform.test.ts | 25 ++-- test/refusal-reaction-baseline.test.ts | 14 ++- 8 files changed, 211 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d66725908..e91ae5d5f 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,9 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Explicit server env values (case-insensitive on Windows) and baseline values intentionally inherited with `inheritEnv: true` take precedence, including empty strings; the adapter's filters-file and legacy operator-env precedence remain unchanged. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained or an awareness emoji changed since reaches a running child at its next spawn. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger exists but can't be read. A non-blank value in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A blank value counts as unset, since the adapter reads an empty value as "no tokens". The adapter's filters-file and legacy operator-env precedence remain unchanged. -`framework.getPlacedReactionBaseline()` returns a fresh snapshot for other embeddings, including separately managed WebSocket adapters. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. +`framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. ### MCPL (MCP Live) diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index 0f01450ea..aff988b6f 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `AgentFramework.getPlacedReactionBaseline()` exposes that snapshot, converting composite custom-emote markers to stable IDs for suppression; stored markers and reaction payloads remain unchanged. Stdio MCPL startup and runtime connections inject it by default. Explicit server env and intentionally inherited baseline values, including empty strings, retain precedence. On Windows, duplicate spellings resolve lexicographically within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤 or the configured awareness emoji stop reaching the agent too, as members' 🛑 already did. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger can't be read. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a blank value counts as unset. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index 8a146f89e..c48320fb7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4590,7 +4590,8 @@ export class AgentFramework { const parts = incoming.channelId.split(':'); if (parts[0] !== 'discord') return; const channelId = parts[parts.length - 1]; - const emoji = REFUSAL_REACTIONS[category] ?? REFUSAL_REACTION_FALLBACK; + // Own names only: an inherited one such as 'constructor' takes the fallback. + const emoji = Object.hasOwn(REFUSAL_REACTIONS, category) ? REFUSAL_REACTIONS[category] : REFUSAL_REACTION_FALLBACK; // Resolve the MCPL server that owns the locus channel and call // tools/call directly on its connection (bare tool name — no prefix // games), bypassing the agent event queue so no synthetic tool-result @@ -9809,7 +9810,7 @@ export class AgentFramework { // border is close, rather than noise on every near-threshold // flip. (It must not re-enter any agent's context — the emoji // is suppressed at the surface via - // DISCORD_SUPPRESS_REACTION_EMOJIS; without that, marking a + // DISCORD_SUPPRESSED_REACTIONS_BASELINE; without that, marking a // refusal feeds the next one.) if (!handledByRewind) { void this.reactToRefusal(agent.name, category); @@ -13168,30 +13169,35 @@ export class AgentFramework { ); } - // Derive the default here, after the framework has its effective config - // and retained awareness ledger. Keep the caller's config unchanged so - // a later runtime restart derives a fresh default, not a stale override. - // WebSocket servers own their environment outside this process. - // Match Windows names in both sources: Workers expose a case-sensitive - // process.env copy. Within one source, Node's spawn keeps the first - // lexicographically sorted spelling. Resolve each source that way, - // then keep the declared source's precedence over inherited values. - const baselineValue = (env: NodeJS.ProcessEnv | undefined) => { - if (process.platform !== 'win32') return env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; - const key = Object.keys(env ?? {}).sort() - .find((name) => name.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'); - return key === undefined ? undefined : env?.[key]; - }; - const connectionConfig = isWebSocketTransport(config) ? config : { - ...config, - env: { - ...config.env, - DISCORD_SUPPRESSED_REACTIONS_BASELINE: - baselineValue(config.env) - ?? (config.inheritEnv ? baselineValue(process.env) : undefined) - ?? this.getPlacedReactionBaseline().join(','), - }, - }; + // A stdio child's protective reaction default, DISCORD_SUPPRESSED_REACTIONS_BASELINE + // (WebSocket servers own their environment outside this process). The + // caller's config stays unchanged, so a runtime restart derives afresh. + // The default is derived at each spawn: the env below is a getter, which + // the transport reads at first connect, at every automatic reconnect and + // at a stub's retry, so retained markers added since the last spawn are + // covered. Deriving it once here first refuses the connect, before + // anything spawns, when the awareness ledger can't be read. + let connectionConfig = config; + if (!isWebSocketTransport(config)) { + const declared = this.declaredReactionBaseline(config); + if (declared) { + console.error( + `[mcpl] ${config.id}: DISCORD_SUPPRESSED_REACTIONS_BASELINE ${declared.source === 'env' ? 'from its env' : 'inherited from the host'} ` + + `replaces the framework's protective default`, + ); + } else { + this.getPlacedReactionBaseline(); + } + connectionConfig = { ...config }; + Object.defineProperty(connectionConfig, 'env', { + enumerable: true, + configurable: true, + get: () => ({ + ...config.env, + DISCORD_SUPPRESSED_REACTIONS_BASELINE: declared?.value ?? this.getPlacedReactionBaseline().join(','), + }), + }); + } const connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities); // Wire listeners before either startup staging or the runtime global gate @@ -13531,14 +13537,17 @@ export class AgentFramework { // ========================================================================== /** - * Snapshot the protective Discord reaction default for this deployment. - * Includes refusal markers, the adapter's own default awareness marker, - * the configured awareness marker, and retained outbox markers (including - * offline recovery choices and marks that can later be removed/replayed). - * Composite custom-emote markers become bare IDs for Discord's suppression - * matcher; stored markers and the actual add/remove payloads stay unchanged. - * Explicit adapter/operator suppression settings still take precedence. - * Throws on an unreadable ledger rather than reporting a partial baseline. + * Snapshot the protective Discord reaction default for this deployment, as + * suppression tokens: the forms the Discord adapter's suppression matcher + * reads, not marker payloads for an add, a remove or a replay. It covers + * the reactions the framework places: refusal markers, the adapter's own + * default awareness marker, the configured awareness marker, and retained + * outbox markers (including offline recovery choices and marks that can + * later be removed or replayed). A custom emoji appears as its bare ID, + * which suppression matches even after a rename; stored markers and the + * actual add/remove payloads stay unchanged. Explicit adapter or operator + * suppression settings still take precedence. Throws on an unreadable + * ledger rather than reporting a partial baseline. */ getPlacedReactionBaseline(): readonly string[] { let retained: string[]; @@ -13562,6 +13571,37 @@ export class AgentFramework { ].map(suppressionToken))].filter((emoji) => emoji.length > 0); } + /** + * The reaction baseline an operator chose for a stdio server, if any: a + * value declared in its env, or one inherited from the host with + * `inheritEnv`, the declared one first. A blank value counts as unset: the + * Discord adapter reads an empty value as "no tokens", so a blank inherited + * variable (an empty systemd `Environment=`, say) would otherwise turn the + * protective default off without anyone choosing that. + * + * Windows names are matched in both sources: Workers expose a case- + * sensitive `process.env` copy. Within one source, Node's spawn keeps the + * first spelling in sorted order, so each source is resolved that way. + */ + private declaredReactionBaseline( + config: import('./mcpl/types.js').McplServerConfig, + ): { value: string; source: 'env' | 'inherited' } | undefined { + const valueIn = (env: NodeJS.ProcessEnv | undefined): string | undefined => { + let value: string | undefined; + if (process.platform !== 'win32') value = env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; + else { + const key = Object.keys(env ?? {}).sort() + .find((name) => name.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'); + value = key === undefined ? undefined : env?.[key]; + } + return value !== undefined && value.trim() !== '' ? value : undefined; + }; + const declared = valueIn(config.env); + if (declared !== undefined) return { value: declared, source: 'env' }; + const inherited = config.inheritEnv ? valueIn(process.env) : undefined; + return inherited !== undefined ? { value: inherited, source: 'inherited' } : undefined; + } + /** * Connect a new MCPL server at runtime. Refreshes the tool list and * notifies the agent of newly available tools. Throws if the MCPL @@ -13644,6 +13684,12 @@ export class AgentFramework { if (!config) { throw new Error(`MCPL server "${id}" is not configured`); } + // A stdio child that will take the protective reaction default needs a + // readable awareness ledger. Check before disconnecting, so an + // unreadable ledger refuses the restart and leaves the running child up. + if (!isWebSocketTransport(config) && !this.declaredReactionBaseline(config)) { + this.getPlacedReactionBaseline(); + } await this.disconnectMcplServer(id); await this.connectMcplServer(config); } diff --git a/src/refusal-reactions.ts b/src/refusal-reactions.ts index 5cafd0d30..4ee0bb715 100644 --- a/src/refusal-reactions.ts +++ b/src/refusal-reactions.ts @@ -1,17 +1,20 @@ /** * Refusal-reaction markers — the single source of truth for the emoji the * framework places on a message when inference is refused (see - * AgentFramework.reactToRefusal), exported so host composition can derive - * a protective suppression baseline from the exact set the framework emits. + * AgentFramework.reactToRefusal). * * These annotations are placed by the framework on the resident's account * and must never re-enter a resident's context as reaction events — that is * the self-amplifying loop behind the 8/3 Mythos incident. The Discord - * adapter suppresses them when its operator config or the host-injected - * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them; keeping the emitted - * set and the exported baseline as one constant is what makes drift between - * "what we stamp" and "what we suppress" structurally impossible, rather - * than a promise kept in two files. + * adapter suppresses them when its operator config or + * `DISCORD_SUPPRESSED_REACTIONS_BASELINE` names them. That variable's + * protective default, which the framework injects into stdio children, is + * `framework.getPlacedReactionBaseline()` on an instance: these refusal + * markers plus the awareness markers. REFUSAL_REACTION_BASELINE below is the + * refusal set only. Keeping the emitted refusal set and the exported one as + * one constant is what makes drift between "what we stamp" and "what we + * suppress" structurally impossible, rather than a promise kept in two + * files. */ /** Refusal category → Discord reaction emoji. Unknown categories get the @@ -30,7 +33,7 @@ export const REFUSAL_REACTION_FALLBACK = '🛑'; /** Every refusal marker the framework can emit: the category map plus the * fallback, deduplicated in stable declaration order. For the deployment's * complete protective default, including awareness markers, use - * AgentFramework.getPlacedReactionBaseline(). */ + * `framework.getPlacedReactionBaseline()` on an instance. */ export const REFUSAL_REACTION_BASELINE: readonly string[] = [ ...new Set([...Object.values(REFUSAL_REACTIONS), REFUSAL_REACTION_FALLBACK]), ]; diff --git a/test/fixtures/reaction-baseline-mcpl-server.mjs b/test/fixtures/reaction-baseline-mcpl-server.mjs index 34bc9d374..a579d793f 100644 --- a/test/fixtures/reaction-baseline-mcpl-server.mjs +++ b/test/fixtures/reaction-baseline-mcpl-server.mjs @@ -15,6 +15,9 @@ rl.on('line', (line) => { }); } else if (msg.method === 'tools/list') { reply(msg.id, { tools: [{ name: 'read_env', description: 'Read fixture environment', inputSchema: { type: 'object', properties: {} } }] }); + } else if (msg.method === 'tools/call' && msg.params?.name === 'crash') { + // Stands in for a child that dies, so a reconnecting host respawns it. + process.exit(3); } else if (msg.method === 'tools/call') { reply(msg.id, { content: [{ type: 'text', text: JSON.stringify({ baseline: process.env.DISCORD_SUPPRESSED_REACTIONS_BASELINE, diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index c00a1d130..5a7cd936a 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -152,7 +152,7 @@ describe('placed-reaction baseline', () => { assert.ok((await childEnv(fw)).baseline?.split(',').includes('👁️'), 'restart recomputes a default rather than retaining it as an explicit override'); }); - for (const value of ['🟪', '', '<:operator_choice:123456789012345678>']) { + for (const value of ['🟪', '<:operator_choice:123456789012345678>']) { it('preserves an explicit server baseline ' + JSON.stringify(value), async () => { const fw = await create(undefined, [server({ env: { [ENV_KEY]: value } })]); assert.equal((await childEnv(fw)).baseline, value); @@ -172,18 +172,102 @@ describe('placed-reaction baseline', () => { it('does not use an unrequested parent override; explicit server env wins over inheritance', async (t) => { const previous = process.env[ENV_KEY]; - process.env[ENV_KEY] = ''; + process.env[ENV_KEY] = '🟥'; t.after(() => { if (previous === undefined) delete process.env[ENV_KEY]; else process.env[ENV_KEY] = previous; }); const fw = await create(undefined, [server()]); - assert.equal((await childEnv(fw)).baseline, baseline(fw).join(',')); + // The literal default, not the function under test's own report. + assert.equal((await childEnv(fw)).baseline, [...REFUSAL_REACTION_BASELINE, '💤'].join(',')); await fw.disconnectMcplServer('probe'); await fw.connectMcplServer(server({ inheritEnv: true, env: { [ENV_KEY]: '🟪' } })); assert.equal((await childEnv(fw)).baseline, '🟪'); }); + for (const blank of ['', ' ']) { + it('a blank baseline, declared or inherited, counts as unset ' + JSON.stringify(blank), async (t) => { + // The adapter reads an empty value as "no tokens": a blank inherited + // variable would otherwise turn the protective default off. + const previous = process.env[ENV_KEY]; + process.env[ENV_KEY] = blank; + t.after(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + const expected = [...REFUSAL_REACTION_BASELINE, '💤'].join(','); + const fw = await create(undefined, [server({ env: { [ENV_KEY]: blank } })]); + assert.equal((await childEnv(fw)).baseline, expected, 'declared'); + await fw.disconnectMcplServer('probe'); + await fw.connectMcplServer(server({ inheritEnv: true })); + assert.equal((await childEnv(fw)).baseline, expected, 'inherited'); + }); + } + + it("says at connect when an operator's value replaces the protective default", async (t) => { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + t.after(() => { console.error = original; }); + const fw = await create(undefined, [server({ env: { [ENV_KEY]: '🟪' } })]); + console.error = original; + assert.ok(lines.some((line) => line.includes('[mcpl] probe: DISCORD_SUPPRESSED_REACTIONS_BASELINE from its env replaces the framework') ), lines.join('\n')); + assert.equal((await childEnv(fw)).baseline, '🟪'); + }); + + it('a reconnect respawns the child with a default derived at that spawn', async () => { + const fw = await create(undefined, [server({ reconnect: true, reconnectIntervalMs: 20, reconnectMaxIntervalMs: 20 })]); + assert.equal((await childEnv(fw)).baseline?.split(',').includes('🫥'), false); + retain('🫥'); + const registry = (fw as unknown as { + mcplServerRegistry: { getServer(id: string): { sendToolsCall(name: string, args: {}): Promise } }; + }).mcplServerRegistry; + await registry.getServer('probe').sendToolsCall('crash', {}).catch(() => {}); + let env: { baseline?: string } | undefined; + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + env = await childEnv(fw).catch(() => undefined); + if (env) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(env, 'the child came back'); + assert.ok(env.baseline?.split(',').includes('🫥'), `the respawned child has the marker retained since: ${env.baseline}`); + }); + + it('a restart that needs the default refuses on a corrupt ledger and leaves the child up', async () => { + const fw = await create(undefined, [server()]); + writeFileSync(outboxPath, '{broken'); + await assert.rejects(() => fw.restartMcplServer('probe'), /Discord awareness.*ledger/i); + assert.ok(await childEnv(fw), 'the running child still answers'); + }); + + it('passes a URL-only WebSocket config through unchanged', async () => { + const fw = await create(); + // Bring up the MCPL subsystem as a first runtime connect does. + await (fw as unknown as { initializeMcpl(servers: McplServerConfig[]): Promise }).initializeMcpl([]); + let added: unknown; + const registry = (fw as unknown as { mcplServerRegistry: { addServer(config: unknown): Promise } }).mcplServerRegistry; + registry.addServer = async (config: unknown) => { added = config; throw new Error('stop before connecting'); }; + const config: McplServerConfig = { id: 'remote', url: 'ws://127.0.0.1:1' }; + await assert.rejects(() => fw.connectMcplServer(config), /stop before connecting/); + assert.equal(added, config, 'the same object'); + assert.equal(config.env, undefined, 'no env added'); + }); + + for (const [marker, converted] of [ + [`<:mark:${'1'.repeat(16)}>`, false], + [`<:mark:${'1'.repeat(17)}>`, true], + [`mark:${'2'.repeat(20)}`, true], + [`<:mark:${'3'.repeat(21)}>`, false], + ['a:b:1', false], + ] as const) { + it(`a custom marker ${marker} is ${converted ? '' : 'not '}reduced to its ID`, async () => { + const fw = await create(marker); + const id = /(\d+)>?$/.exec(marker)![1]; + assert.equal(baseline(fw).at(-1), converted ? id : marker); + }); + } + it('fails explicitly on a corrupt retained ledger rather than deriving an incomplete default', async () => { const fw = await create(undefined, [server()]); writeFileSync(outboxPath, '{broken'); diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts index 24974168f..9e9cc6c0b 100644 --- a/test/reaction-baseline-platform.test.ts +++ b/test/reaction-baseline-platform.test.ts @@ -2,8 +2,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { AgentFramework } from '../src/framework.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; +import { REFUSAL_REACTION_BASELINE } from '../src/refusal-reactions.js'; const KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; +/** The framework's protective default for the stub below (awareness emoji 🔕, no ledger). */ +const DEFAULT = [...REFUSAL_REACTION_BASELINE, '💤', '🔕'].join(','); /** Exercise the real default-injection boundary, intercepting immediately * before connection/spawn. Platform/env overrides are synchronous and restored @@ -53,13 +56,15 @@ async function connectionEnv( for (const inheritEnv of [false, true]) { test('Windows reaction baseline honors declared case variants, inheritEnv=' + inheritEnv, async () => { + // A blank value counts as unset: where the spelling Node's spawn keeps is + // blank, the child gets the protective default. const cases: Array<{ env: Record; expected: string }> = [ - { env: { [KEY.toLowerCase()]: '' }, expected: '' }, + { env: { [KEY.toLowerCase()]: '' }, expected: DEFAULT }, { env: { Discord_Suppressed_Reactions_Baseline: '🟪' }, expected: '🟪' }, { env: { [KEY]: 'upper', [KEY.toLowerCase()]: '' }, expected: 'upper' }, { env: { [KEY.toLowerCase()]: '', [KEY]: 'upper' }, expected: 'upper' }, - { env: { [KEY]: '', [KEY.toLowerCase()]: 'lower' }, expected: '' }, - { env: { [KEY.toLowerCase()]: 'lower', [KEY]: '' }, expected: '' }, + { env: { [KEY]: '', [KEY.toLowerCase()]: 'lower' }, expected: DEFAULT }, + { env: { [KEY.toLowerCase()]: 'lower', [KEY]: '' }, expected: DEFAULT }, ]; for (const { env, expected } of cases) { const composed = await connectionEnv('win32', env, inheritEnv); @@ -74,13 +79,15 @@ for (const inheritEnv of [false, true]) { test('Windows inherited-only baseline lookup handles case-sensitive worker env copies', async () => { for (const value of ['', '🟪']) { + // A blank inherited value counts as unset, like a blank declared one. + const kept = value === '' ? DEFAULT : value; const cases: Array<{ parent: NodeJS.ProcessEnv; expected: string }> = [ - { parent: { [KEY.toLowerCase()]: value }, expected: value }, - { parent: { Discord_Suppressed_Reactions_Baseline: value }, expected: value }, + { parent: { [KEY.toLowerCase()]: value }, expected: kept }, + { parent: { Discord_Suppressed_Reactions_Baseline: value }, expected: kept }, { parent: { [KEY]: 'upper', [KEY.toLowerCase()]: value }, expected: 'upper' }, { parent: { [KEY.toLowerCase()]: value, [KEY]: 'upper' }, expected: 'upper' }, - { parent: { [KEY]: value, [KEY.toLowerCase()]: 'lower' }, expected: value }, - { parent: { [KEY.toLowerCase()]: 'lower', [KEY]: value }, expected: value }, + { parent: { [KEY]: value, [KEY.toLowerCase()]: 'lower' }, expected: kept }, + { parent: { [KEY.toLowerCase()]: 'lower', [KEY]: value }, expected: kept }, ]; for (const { parent, expected } of cases) { const composed = await connectionEnv('win32', {}, true, parent); @@ -94,7 +101,7 @@ test('Windows chooses duplicate spellings within each source, then declared over const composed = await connectionEnv('win32', { [KEY.toLowerCase()]: '', [KEY]: 'declared-upper' }, true, parent); assert.equal(composed[KEY], 'declared-upper'); const singleDeclared = await connectionEnv('win32', { [KEY.toLowerCase()]: '' }, true, parent); - assert.equal(singleDeclared[KEY], '', 'declared lowercase empty defeats inherited uppercase'); + assert.equal(singleDeclared[KEY], 'host-upper', 'a blank declared value is unset, so the inherited choice stands'); }); test('inherited-only baseline lookup remains opt-in and POSIX case-sensitive', async () => { @@ -103,7 +110,7 @@ test('inherited-only baseline lookup remains opt-in and POSIX case-sensitive', a assert.ok(composed[KEY]!.split(',').includes('💤')); } const explicitPosix = await connectionEnv('linux', {}, true, { [KEY]: '' }); - assert.equal(explicitPosix[KEY], ''); + assert.equal(explicitPosix[KEY], DEFAULT, 'a blank inherited value is unset'); }); test('explicit WebSocket selection leaves its env alone even when a command is also present', async () => { diff --git a/test/refusal-reaction-baseline.test.ts b/test/refusal-reaction-baseline.test.ts index ede230e5a..0f602fdac 100644 --- a/test/refusal-reaction-baseline.test.ts +++ b/test/refusal-reaction-baseline.test.ts @@ -1,9 +1,11 @@ /** * The exported REFUSAL_REACTION_BASELINE must be exactly the set of markers * reactToRefusal can emit — every category's emoji plus the unknown-category - * fallback, nothing more, nothing less. Host composition serializes this - * export into DISCORD_SUPPRESSED_REACTIONS_BASELINE; if the emitted set and - * the export could drift, a framework annotation could re-enter a resident's + * fallback, nothing more, nothing less. It is the refusal set only: the + * protective default the framework injects into DISCORD_SUPPRESSED_REACTIONS_BASELINE + * is framework.getPlacedReactionBaseline(), which adds the awareness markers + * (test/placed-reaction-baseline.test.ts). If the emitted refusal set and the + * export could drift, a framework annotation could re-enter a resident's * context as a reaction event (the 8/3 Mythos self-amplifying refusal loop). * The implementation shares one constant; these tests pin the contract so a * refactor that splits them fails here first. @@ -75,6 +77,12 @@ describe('refusal-reaction baseline export', () => { assert.deepEqual(emitted, new Set(REFUSAL_REACTION_BASELINE)); }); + it('a category named like an inherited property takes the fallback', async () => { + for (const category of ['constructor', 'toString', '__proto__']) { + assert.equal(await emittedFor(category), REFUSAL_REACTION_FALLBACK, category); + } + }); + it('serialized baseline survives the Discord adapter env round-trip (comma-join)', () => { // Host composition joins with ','; discord-mcpl's parseSuppressionEnvTokens // splits on ',' and trims. Entries therefore must not contain commas or From 2ec3d579ff888374003409fba9cc3994f52412b6 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 11:30:03 -0400 Subject: [PATCH 07/12] docs(discord): a reconnect that can't read the ledger retries rather than spawn without the default Petra-1012's review of 2c328b7 (room-377 #71140): with the env getter, an automatic reconnect whose awareness ledger has become unreadable throws while spawning (StdioTransport.spawn -> buildChildEnv -> the getter), and attemptReconnect catches it, emits reconnect-failed and schedules the next attempt, so the child is never spawned without its default. The README and the fragment named only the connect and restart refusals; each now says what a reconnect does too. README and fragment only; no code or test change. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- changelog.d/211-placed-reaction-baseline.fixed.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e91ae5d5f..03044af4b 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained or an awareness emoji changed since reaches a running child at its next spawn. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger exists but can't be read. A non-blank value in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A blank value counts as unset, since the adapter reads an empty value as "no tokens". The adapter's filters-file and legacy operator-env precedence remain unchanged. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained or an awareness emoji changed since reaches a running child at its next spawn. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger exists but can't be read; an automatic reconnect that can't read it fails and retries rather than spawn the child without the default. A non-blank value in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A blank value counts as unset, since the adapter reads an empty value as "no tokens". The adapter's filters-file and legacy operator-env precedence remain unchanged. `framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index aff988b6f..912d8406e 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤 or the configured awareness emoji stop reaching the agent too, as members' 🛑 already did. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger can't be read. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a blank value counts as unset. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤 or the configured awareness emoji stop reaching the agent too, as members' 🛑 already did. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger can't be read, and an automatic reconnect that can't read it fails and retries rather than spawn without the default. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a blank value counts as unset. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). From 729b36e63a767dbc8df5967716347f819bf8f97e Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 13:46:44 -0400 Subject: [PATCH 08/12] fix(mcpl): a non-string reaction baseline in a server's env is read as the child will see it, and a null as unset declaredReactionBaseline read DISCORD_SUPPRESSED_REACTIONS_BASELINE with value.trim(), with no type check. The config type says string, but a config loaded from JSON can carry a number there, and the host's loaders don't type env values. Such a value threw "value.trim is not a function" at connect, failing the server, where Node's spawn writes every defined env value as its string form. A defined value is now read as String(value), so it's the declared value the child sees, and the blank-as-unset rule applies to that string. A null counts as unset, like a blank. Written in JSON it means no value; read as spawn's "null" it would be a non-blank declared baseline, replacing the protective default with a list that suppresses nothing, which is what the blank-as-unset rule exists to prevent. Other keys keep spawn's handling. Test: a server whose env declares the baseline as the number 7 connects, and its child sees "7" (without the fix: "value.trim is not a function"); one that declares null gets the derived default (with null read as "null", the child sees "null"). placed-reaction-baseline, reaction-baseline-platform and refusal-reaction-baseline: 42/42. --- .../211-placed-reaction-baseline.fixed.md | 2 +- src/framework.ts | 17 +++++++++++++---- test/placed-reaction-baseline.test.ts | 11 +++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index 912d8406e..a26824a57 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤 or the configured awareness emoji stop reaching the agent too, as members' 🛑 already did. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger can't be read, and an automatic reconnect that can't read it fails and retries rather than spawn without the default. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a blank value counts as unset. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤 or the configured awareness emoji stop reaching the agent too, as members' 🛑 already did. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger can't be read, and an automatic reconnect that can't read it fails and retries rather than spawn without the default. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a blank or null value counts as unset, and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index c48320fb7..8c39e0118 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13582,19 +13582,28 @@ export class AgentFramework { * Windows names are matched in both sources: Workers expose a case- * sensitive `process.env` copy. Within one source, Node's spawn keeps the * first spelling in sorted order, so each source is resolved that way. + * + * A value is read as the child will see it. A config loaded from JSON can + * carry a non-string there despite the type, and spawn writes every + * defined value as its string form, so a number is a declared value, not a + * failed connect. A null counts as unset, like a blank: written in JSON it + * means no value, and read as spawn's "null" it would replace the + * protective default with a list that suppresses nothing. */ private declaredReactionBaseline( config: import('./mcpl/types.js').McplServerConfig, ): { value: string; source: 'env' | 'inherited' } | undefined { const valueIn = (env: NodeJS.ProcessEnv | undefined): string | undefined => { - let value: string | undefined; - if (process.platform !== 'win32') value = env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; + let raw: unknown; + if (process.platform !== 'win32') raw = env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; else { const key = Object.keys(env ?? {}).sort() .find((name) => name.toUpperCase() === 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'); - value = key === undefined ? undefined : env?.[key]; + raw = key === undefined ? undefined : env?.[key]; } - return value !== undefined && value.trim() !== '' ? value : undefined; + if (raw === undefined || raw === null) return undefined; + const value = String(raw); + return value.trim() !== '' ? value : undefined; }; const declared = valueIn(config.env); if (declared !== undefined) return { value: declared, source: 'env' }; diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index 5a7cd936a..740ca33d4 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -185,6 +185,17 @@ describe('placed-reaction baseline', () => { assert.equal((await childEnv(fw)).baseline, '🟪'); }); + it('reads a non-string declared baseline as the child sees it, rather than failing the connect, and a null as unset', async () => { + // A config loaded from JSON can carry a number despite the type; spawn + // writes it as its string form. + const fw = await create(undefined, [server({ env: { [ENV_KEY]: 7 as unknown as string } })]); + assert.equal((await childEnv(fw)).baseline, '7'); + // A null means no value: the derived default, not spawn's "null". + await fw.disconnectMcplServer('probe'); + await fw.connectMcplServer(server({ env: { [ENV_KEY]: null as unknown as string } })); + assert.equal((await childEnv(fw)).baseline, [...REFUSAL_REACTION_BASELINE, '💤'].join(',')); + }); + for (const blank of ['', ' ']) { it('a blank baseline, declared or inherited, counts as unset ' + JSON.stringify(blank), async (t) => { // The adapter reads an empty value as "no tokens": a blank inherited From ffb39ee000bb0d67dd089c99568480ecb9955de0 Mon Sep 17 00:00:00 2001 From: Duncan-2410 Date: Tue, 13 Oct 2026 09:31:40 -0400 Subject: [PATCH 09/12] fix(discord): a value with no tokens counts as unset; every restart reads the ledger before it stops the child An internal review of the last round, after main was merged in, found two places where the rules didn't match what they protect. A value of commas alone replaced the protective default. The blank rule counts an empty or whitespace value as unset because discord-mcpl reads it as "no tokens", which turns suppression off. But discord-mcpl's parser (parseSuppressionEnvTokens) splits on commas and drops empty tokens, so "," or " , " (say ${A},${B} with both empty) means no tokens too, and it passed the blank check. A declared or inherited value now counts as unset when none of its comma-separated tokens is non-blank. A restart of a server with its own baseline stopped its child and then failed. The restart read the ledger first only when the server needed the default. Every runtime connect reads the ledger anyway, at its awareness barrier (branch reconciliation), so on an unreadable ledger such a restart stopped the running child, failed its connect and left the server down. The restart now reads the ledger before it disconnects whatever the server's baseline or transport; with no outbox configured there is nothing to read. Tests: - the no-tokens loop adds "," and " , ", declared and inherited; - the restart test also restarts a server with its own baseline on a corrupt ledger, and its child still answers (before: rejected with the barrier's error, and no connection left); - a reconnecting server's connect on a corrupt ledger is refused before anything is registered under its id, which pins the connect's own ledger read: without it the connect makes a stub, the barrier closes it, and the closed stub stays registered; - the Windows platform cases also run the composed env through buildChildEnv's win32 merge with the same host env and check the value the child gets; - the custom-marker table pins the bare form at 16 digits and the bracket form at 20, so each form is checked at both edges. Text: the README and the fragment say "no tokens" for the unset rule, name the adapter's filters file and legacy operator env as where suppression is turned off, say any restart refuses before it stops the child, and add a retained recovery emoji to the members' reactions that stop reaching the agent. The README no longer says a changed awareness emoji reaches a running child, since the emoji is fixed for the framework's lifetime. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../211-placed-reaction-baseline.fixed.md | 2 +- src/framework.ts | 28 ++++++++-------- test/placed-reaction-baseline.test.ts | 32 +++++++++++++------ test/reaction-baseline-platform.test.ts | 20 +++++++++--- 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0b917a8c0..d844b570e 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained or an awareness emoji changed since reaches a running child at its next spawn. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger exists but can't be read; an automatic reconnect that can't read it fails and retries rather than spawn the child without the default. A non-blank value in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A blank value counts as unset, since the adapter reads an empty value as "no tokens". The adapter's filters-file and legacy operator-env precedence remain unchanged. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained since reaches a running child at its next spawn. When the awareness ledger exists but can't be read, a connect that needs the default refuses before anything spawns, and a restart refuses before it stops the running child, whatever the server's baseline, since every connect reads the ledger. An automatic reconnect that can't read it fails and retries rather than spawn the child without the default. A non-blank value in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, blank or commas alone, counts as unset, since the adapter reads it as "no tokens". The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. `framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index a26824a57..951229093 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤 or the configured awareness emoji stop reaching the agent too, as members' 🛑 already did. A connect or restart that needs the default refuses, before anything spawns or the running child is stopped, when the awareness ledger can't be read, and an automatic reconnect that can't read it fails and retries rather than spawn without the default. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a blank or null value counts as unset, and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, a connect that needs the default refuses before anything spawns, any restart refuses before it stops the running child, and an automatic reconnect fails and retries rather than spawn without the default. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a value with no tokens (blank, or commas alone) or a null counts as unset, since the adapter's filters file and legacy operator env are where suppression is turned off, and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index 1768ec6bd..279fc39a8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14267,8 +14267,9 @@ export class AgentFramework { // The default is derived at each spawn: the env below is a getter, which // the transport reads at first connect, at every automatic reconnect and // at a stub's retry, so retained markers added since the last spawn are - // covered. Deriving it once here first refuses the connect, before - // anything spawns, when the awareness ledger can't be read. + // covered. Deriving it once here first refuses the connect when the + // awareness ledger can't be read, before anything spawns and before a + // reconnecting server could leave a closed stub registered under its id. let connectionConfig = config; if (!isWebSocketTransport(config)) { const declared = this.declaredReactionBaseline(config); @@ -14694,10 +14695,11 @@ export class AgentFramework { /** * The reaction baseline an operator chose for a stdio server, if any: a * value declared in its env, or one inherited from the host with - * `inheritEnv`, the declared one first. A blank value counts as unset: the - * Discord adapter reads an empty value as "no tokens", so a blank inherited - * variable (an empty systemd `Environment=`, say) would otherwise turn the - * protective default off without anyone choosing that. + * `inheritEnv`, the declared one first. A value with no tokens, blank or + * commas alone, counts as unset: the Discord adapter splits on commas and + * drops empty tokens, so such a value (an empty systemd `Environment=`, say, + * or `${A},${B}` with both empty) would otherwise turn the protective + * default off without anyone choosing that. * * Windows names are matched in both sources: Workers expose a case- * sensitive `process.env` copy. Within one source, Node's spawn keeps the @@ -14723,7 +14725,7 @@ export class AgentFramework { } if (raw === undefined || raw === null) return undefined; const value = String(raw); - return value.trim() !== '' ? value : undefined; + return value.split(',').some((token) => token.trim() !== '') ? value : undefined; }; const declared = valueIn(config.env); if (declared !== undefined) return { value: declared, source: 'env' }; @@ -14813,12 +14815,12 @@ export class AgentFramework { if (!config) { throw new Error(`MCPL server "${id}" is not configured`); } - // A stdio child that will take the protective reaction default needs a - // readable awareness ledger. Check before disconnecting, so an - // unreadable ledger refuses the restart and leaves the running child up. - if (!isWebSocketTransport(config) && !this.declaredReactionBaseline(config)) { - this.getPlacedReactionBaseline(); - } + // Every connect reads the awareness ledger: its awareness barrier + // reconciles it, and a stdio child taking the protective default derives + // that from it. Read it before disconnecting, so an unreadable ledger + // refuses the restart and leaves the running child up, whatever the + // server's baseline or transport. (No outbox configured: nothing to read.) + this.getPlacedReactionBaseline(); await this.disconnectMcplServer(id); await this.connectMcplServer(config); } diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index 740ca33d4..ad5a406a7 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -196,10 +196,11 @@ describe('placed-reaction baseline', () => { assert.equal((await childEnv(fw)).baseline, [...REFUSAL_REACTION_BASELINE, '💤'].join(',')); }); - for (const blank of ['', ' ']) { - it('a blank baseline, declared or inherited, counts as unset ' + JSON.stringify(blank), async (t) => { - // The adapter reads an empty value as "no tokens": a blank inherited - // variable would otherwise turn the protective default off. + for (const blank of ['', ' ', ',', ' , ']) { + it('a baseline with no tokens, declared or inherited, counts as unset ' + JSON.stringify(blank), async (t) => { + // The adapter splits on commas and drops empty tokens, so it reads each + // of these as "no tokens": inherited by accident (an empty variable, or + // `${A},${B}` with both empty), it would turn the protective default off. const previous = process.env[ENV_KEY]; process.env[ENV_KEY] = blank; t.after(() => { @@ -245,12 +246,16 @@ describe('placed-reaction baseline', () => { assert.ok(env.baseline?.split(',').includes('🫥'), `the respawned child has the marker retained since: ${env.baseline}`); }); - it('a restart that needs the default refuses on a corrupt ledger and leaves the child up', async () => { - const fw = await create(undefined, [server()]); - writeFileSync(outboxPath, '{broken'); - await assert.rejects(() => fw.restartMcplServer('probe'), /Discord awareness.*ledger/i); - assert.ok(await childEnv(fw), 'the running child still answers'); - }); + for (const own of [undefined, '🟪']) { + it(`a restart refuses on a corrupt ledger and leaves the child up (${own ? 'its own baseline' : 'the default'})`, async () => { + // Every connect reads the ledger at its awareness barrier, so a server + // with its own baseline can't come back either. + const fw = await create(undefined, [server(own ? { env: { [ENV_KEY]: own } } : {})]); + writeFileSync(outboxPath, '{broken'); + await assert.rejects(() => fw.restartMcplServer('probe'), /Discord awareness.*ledger/i); + assert.ok(await childEnv(fw), 'the running child still answers'); + }); + } it('passes a URL-only WebSocket config through unchanged', async () => { const fw = await create(); @@ -270,6 +275,8 @@ describe('placed-reaction baseline', () => { [`<:mark:${'1'.repeat(17)}>`, true], [`mark:${'2'.repeat(20)}`, true], [`<:mark:${'3'.repeat(21)}>`, false], + [`mark:${'4'.repeat(16)}`, false], + [`<:mark:${'5'.repeat(20)}>`, true], ['a:b:1', false], ] as const) { it(`a custom marker ${marker} is ${converted ? '' : 'not '}reduced to its ID`, async () => { @@ -285,5 +292,10 @@ describe('placed-reaction baseline', () => { assert.throws(() => baseline(fw), /Discord awareness.*ledger/i); await fw.disconnectMcplServer('probe'); await assert.rejects(() => fw.connectMcplServer(server()), /Discord awareness.*ledger/i); + // A reconnecting server is refused before it could leave a closed stub + // registered under its id, so the connect can simply be retried. + await assert.rejects(() => fw.connectMcplServer(server({ reconnect: true })), /Discord awareness.*ledger/i); + const registry = (fw as unknown as { mcplServerRegistry: { getServer(id: string): unknown } }).mcplServerRegistry; + assert.equal(registry.getServer('probe'), null, 'nothing is left registered'); }); }); diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts index 9e9cc6c0b..83b9e1163 100644 --- a/test/reaction-baseline-platform.test.ts +++ b/test/reaction-baseline-platform.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { AgentFramework } from '../src/framework.js'; +import { buildChildEnv } from '../src/mcpl/transport.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; import { REFUSAL_REACTION_BASELINE } from '../src/refusal-reactions.js'; @@ -54,6 +55,15 @@ async function connectionEnv( return captured.env; } +/** The value a Windows child gets: the composed env through buildChildEnv's + * win32 merge with the host env, which leaves one spelling of each name. */ +function windowsChildValue(composed: Record, inheritEnv: boolean, host: NodeJS.ProcessEnv): string | undefined { + const child = buildChildEnv({ env: composed, inheritEnv }, host, 'win32'); + const spellings = Object.keys(child).filter((key) => key.toUpperCase() === KEY); + assert.equal(spellings.length, 1, `one spelling reaches the child: ${spellings.join(', ')}`); + return child[spellings[0]!]; +} + for (const inheritEnv of [false, true]) { test('Windows reaction baseline honors declared case variants, inheritEnv=' + inheritEnv, async () => { // A blank value counts as unset: where the spelling Node's spawn keeps is @@ -68,10 +78,9 @@ for (const inheritEnv of [false, true]) { ]; for (const { env, expected } of cases) { const composed = await connectionEnv('win32', env, inheritEnv); - // Node's Windows spawn sorts and keeps the first spelling of a folded - // name. The chosen value must survive that step as well as #205's merge. - const emittedKey = Object.keys(composed).sort().find((key) => key.toUpperCase() === KEY)!; - assert.equal(composed[emittedKey], expected); + // The chosen value must survive the spawn's own merge (#205), which + // keeps the first spelling of a folded name within each source. + assert.equal(windowsChildValue(composed, inheritEnv, process.env), expected); assert.equal(composed[KEY], expected, 'a generated default must not replace an explicit choice'); } }); @@ -92,6 +101,7 @@ test('Windows inherited-only baseline lookup handles case-sensitive worker env c for (const { parent, expected } of cases) { const composed = await connectionEnv('win32', {}, true, parent); assert.equal(composed[KEY], expected); + assert.equal(windowsChildValue(composed, true, parent), expected); } } }); @@ -100,8 +110,10 @@ test('Windows chooses duplicate spellings within each source, then declared over const parent = { [KEY]: 'host-upper', [KEY.toLowerCase()]: 'host-lower' }; const composed = await connectionEnv('win32', { [KEY.toLowerCase()]: '', [KEY]: 'declared-upper' }, true, parent); assert.equal(composed[KEY], 'declared-upper'); + assert.equal(windowsChildValue(composed, true, parent), 'declared-upper'); const singleDeclared = await connectionEnv('win32', { [KEY.toLowerCase()]: '' }, true, parent); assert.equal(singleDeclared[KEY], 'host-upper', 'a blank declared value is unset, so the inherited choice stands'); + assert.equal(windowsChildValue(singleDeclared, true, parent), 'host-upper'); }); test('inherited-only baseline lookup remains opt-in and POSIX case-sensitive', async () => { From deb888cf735165800f88794b373cf834b91dd6b8 Mon Sep 17 00:00:00 2001 From: Duncan-2410 Date: Tue, 13 Oct 2026 09:46:59 -0400 Subject: [PATCH 10/12] fix(discord): every connect reads the awareness ledger before it registers anything A review of the previous commit found the same mismatch at the connect as at the restart. A connect read the ledger before registering only when the server took the default, but every runtime connect reads it anyway, at its awareness barrier. So on an unreadable ledger a reconnecting server with its own baseline, or a WebSocket one, was registered, refused by the barrier, and left in the registry as a closed stub that blocked a retried connect; and every connect had already set its prefix, config and channel policy. The ledger read now comes first in connectMcplServerInternal, for every connect, before anything is registered or spawned. With no outbox configured it reads nothing. Tests: the refusal test connects a reconnecting server with and without its own baseline on a corrupt ledger, and finds neither a connection nor a config left registered. The custom-marker table adds the bare form at 17 and 21 digits, so both forms are pinned at both edges. Text: the README and the fragment say every connect refuses before it registers or spawns anything, that a value with tokens takes precedence, and that a value with no tokens counts as unset because the adapter would read it as suppression off. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../211-placed-reaction-baseline.fixed.md | 2 +- src/framework.ts | 14 +++++++++----- test/placed-reaction-baseline.test.ts | 19 ++++++++++++++----- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d844b570e..57da60661 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained since reaches a running child at its next spawn. When the awareness ledger exists but can't be read, a connect that needs the default refuses before anything spawns, and a restart refuses before it stops the running child, whatever the server's baseline, since every connect reads the ledger. An automatic reconnect that can't read it fails and retries rather than spawn the child without the default. A non-blank value in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, blank or commas alone, counts as unset, since the adapter reads it as "no tokens". The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained since reaches a running child at its next spawn. When the awareness ledger exists but can't be read, every connect reads it first and refuses before anything is registered or spawned, and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, blank or commas alone, counts as unset, since the adapter would read it as suppression off. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. `framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index 951229093..857c7b0ba 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, a connect that needs the default refuses before anything spawns, any restart refuses before it stops the running child, and an automatic reconnect fails and retries rather than spawn without the default. Non-blank explicit server env and intentionally inherited baseline values retain precedence, and the framework logs when one replaces the default; a value with no tokens (blank, or commas alone) or a null counts as unset, since the adapter's filters file and legacy operator env are where suppression is turned off, and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, any connect refuses before it registers or spawns anything, any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens (blank, or commas alone) or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index 279fc39a8..23075c73f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14246,6 +14246,14 @@ export class AgentFramework { throw new Error('MCPL subsystem is not initialized'); } + // Every connect needs a readable awareness ledger: a runtime connect's + // awareness barrier reconciles it, and a stdio child taking the + // protective default derives that from it. Read it first, so an + // unreadable ledger refuses the connect before anything is registered + // under its id or spawned (a reconnecting server would otherwise be left + // registered as a closed stub). With no outbox configured nothing is read. + this.getPlacedReactionBaseline(); + // Register prefix + config for tool dispatch routing (idempotent with // the pre-registration pass in initializeMcpl). const prefix = config.toolPrefix ?? `mcpl--${config.id}`; @@ -14267,9 +14275,7 @@ export class AgentFramework { // The default is derived at each spawn: the env below is a getter, which // the transport reads at first connect, at every automatic reconnect and // at a stub's retry, so retained markers added since the last spawn are - // covered. Deriving it once here first refuses the connect when the - // awareness ledger can't be read, before anything spawns and before a - // reconnecting server could leave a closed stub registered under its id. + // covered. let connectionConfig = config; if (!isWebSocketTransport(config)) { const declared = this.declaredReactionBaseline(config); @@ -14278,8 +14284,6 @@ export class AgentFramework { `[mcpl] ${config.id}: DISCORD_SUPPRESSED_REACTIONS_BASELINE ${declared.source === 'env' ? 'from its env' : 'inherited from the host'} ` + `replaces the framework's protective default`, ); - } else { - this.getPlacedReactionBaseline(); } connectionConfig = { ...config }; Object.defineProperty(connectionConfig, 'env', { diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index ad5a406a7..525ee3768 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -277,6 +277,8 @@ describe('placed-reaction baseline', () => { [`<:mark:${'3'.repeat(21)}>`, false], [`mark:${'4'.repeat(16)}`, false], [`<:mark:${'5'.repeat(20)}>`, true], + [`mark:${'6'.repeat(17)}`, true], + [`mark:${'7'.repeat(21)}`, false], ['a:b:1', false], ] as const) { it(`a custom marker ${marker} is ${converted ? '' : 'not '}reduced to its ID`, async () => { @@ -292,10 +294,17 @@ describe('placed-reaction baseline', () => { assert.throws(() => baseline(fw), /Discord awareness.*ledger/i); await fw.disconnectMcplServer('probe'); await assert.rejects(() => fw.connectMcplServer(server()), /Discord awareness.*ledger/i); - // A reconnecting server is refused before it could leave a closed stub - // registered under its id, so the connect can simply be retried. - await assert.rejects(() => fw.connectMcplServer(server({ reconnect: true })), /Discord awareness.*ledger/i); - const registry = (fw as unknown as { mcplServerRegistry: { getServer(id: string): unknown } }).mcplServerRegistry; - assert.equal(registry.getServer('probe'), null, 'nothing is left registered'); + // Every connect reads the ledger before it registers anything: a + // reconnecting server, with or without its own baseline, would otherwise + // be left registered as a closed stub that blocks a retried connect. + const internals = fw as unknown as { + mcplServerRegistry: { getServer(id: string): unknown }; + mcplServerConfigs: Map; + }; + for (const config of [server({ reconnect: true }), server({ reconnect: true, env: { [ENV_KEY]: '🟪' } })]) { + await assert.rejects(() => fw.connectMcplServer(config), /Discord awareness.*ledger/i); + assert.equal(internals.mcplServerRegistry.getServer('probe'), null, 'no connection is left registered'); + assert.equal(internals.mcplServerConfigs.has('probe'), false, 'no config is left registered'); + } }); }); From adff8a2ecf3a009ddbd383eae0742c974c661a36 Mon Sep 17 00:00:00 2001 From: Duncan-2410 Date: Tue, 13 Oct 2026 09:59:38 -0400 Subject: [PATCH 11/12] docs(discord): a runtime connect refuses before registering; a startup connect before spawning At startup, initializeMcpl stages every server's routing (its prefix and config) before it connects any of them, so the ledger read that a connect now makes first precedes registration only for a runtime connect. The README and the fragment said every connect refused before registering anything; they now say a runtime connect does, and a startup connect refuses before anything spawns. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- changelog.d/211-placed-reaction-baseline.fixed.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57da60661..729a8a5d2 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained since reaches a running child at its next spawn. When the awareness ledger exists but can't be read, every connect reads it first and refuses before anything is registered or spawned, and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, blank or commas alone, counts as unset, since the adapter would read it as suppression off. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained since reaches a running child at its next spawn. When the awareness ledger exists but can't be read, a runtime connect reads it first and refuses before anything is registered or spawned (a startup connect, whose routing is staged beforehand, before anything spawns), and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, blank or commas alone, counts as unset, since the adapter would read it as suppression off. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. `framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index 857c7b0ba..4b17611b4 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, any connect refuses before it registers or spawns anything, any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens (blank, or commas alone) or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, a runtime connect refuses before it registers or spawns anything (a startup connect, before it spawns), any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens (blank, or commas alone) or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). From 8ef10bc0ea996867bee7a230d331d2b0ee01351d Mon Sep 17 00:00:00 2001 From: Duncan-2410 Date: Tue, 13 Oct 2026 10:21:45 -0400 Subject: [PATCH 12/12] fix(discord): no tokens means no tokens as discord-mcpl's parser reads them The no-token rule counted a token wherever something other than whitespace was left, but discord-mcpl's parser (parseSuppressionEnvTokens) keeps a token only when normalizeReactionEmoji (src/filters.ts) leaves something of it: that strips U+FE0F, trims, and strips one leading and one trailing colon. So `:`, `::` and a lone U+FE0F are no tokens to the adapter, and a value made of them turns suppression off there. `::` is what `:${NAME}:` gives with NAME empty, the adapter's documented form for a custom emoji, so it is the same accident as `${A},${B}` with both empty. Such a value passed through as the operator's choice, replacing the protective default with a list that suppresses nothing, and the framework logged that it did. declaredReactionBaseline now applies the parser's own steps, in its order, with a comment naming the adapter's functions so the two can be compared when either changes. Tests: - The no-token table adds `:`, `::`, `::,::`, ` : , :: `, a lone U+FE0F, and U+FE0F followed by ` :` (which needs the adapter's trim after the U+FE0F strip), declared and inherited. Beside them, values the adapter keeps a token of stay the operator's: `:sigil:`, `sigil`, a Unicode emoji, a bracketed custom emoji, and `:::` (one colon from each end leaves `:`). - The ledger refusal tests match the refusal by its category (DiscordAwarenessAccountingError) rather than by the read's "ledger" label, so a refusal from the awareness barrier passes that assertion and the test fails where its claim is broken: a connection or config left registered, or the running child gone. The plain connect joins the loop that checks what each refused connect leaves registered. Text: the doc comment, README and changelog fragment say "no tokens, as discord-mcpl's parser reads them", naming the values it reads as none. The README's spawn sentence says the next child spawned gets the markers retained since the last spawn. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../211-placed-reaction-baseline.fixed.md | 2 +- src/framework.ts | 22 +++++-- test/placed-reaction-baseline.test.ts | 58 ++++++++++++++----- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 729a8a5d2..84b3cca38 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ it with `discordAwarenessDeadlineMs` (default 10000ms, clamped to 50..60000ms). Configure the online marker with `discordAwarenessEmoji`; the offline CLI accepts `--emoji`. -For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so a marker retained since reaches a running child at its next spawn. When the awareness ledger exists but can't be read, a runtime connect reads it first and refuses before anything is registered or spawned (a startup connect, whose routing is staged beforehand, before anything spawns), and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, blank or commas alone, counts as unset, since the adapter would read it as suppression off. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. +For stdio MCPL children, the framework defaults `DISCORD_SUPPRESSED_REACTIONS_BASELINE` to the reactions it places: its refusal markers, the Discord adapter's default awareness marker, the configured awareness marker, and every retained outbox emoji. This covers later removals and replays after a configuration change. Suppression is by emoji, so members' own reactions with those emoji stop reaching the agent too. The default is derived at each spawn of a child (its first connect, each automatic reconnect, a restart), so the next child spawned gets the markers retained since the last spawn. When the awareness ledger exists but can't be read, a runtime connect reads it first and refuses before anything is registered or spawned (a startup connect, whose routing is staged beforehand, before anything spawns), and a restart refuses before it stops the running child. An automatic reconnect of a child that takes the default fails and retries rather than spawn it without the default. A value with tokens in the server's env (case-insensitive on Windows), or one intentionally inherited with `inheritEnv: true`, takes precedence, and the framework logs that it replaces the default. A value with no tokens, as discord-mcpl's parser reads them, counts as unset, since the adapter would read it as suppression off. The parser splits on commas and keeps a token only when its normalizer leaves something of it, after stripping U+FE0F, trimming, and stripping one leading and one trailing colon: so a blank value, commas alone, `:`, `::` or a lone U+FE0F (say from `${A},${B}` or `:${NAME}:` with the variables empty) has no tokens. The adapter's filters file and legacy operator env still take precedence over this variable, and they are where an operator turns suppression off. `framework.getPlacedReactionBaseline()` returns a fresh snapshot of that default for other embeddings, including separately managed WebSocket adapters. It returns suppression tokens, with a custom emoji as its bare ID, not marker payloads for adding, removing or replaying a reaction. An unreadable awareness ledger throws instead of producing a partial snapshot. Hosts that previously supplied a refusal-only default must stop supplying it to adopt this framework-derived default; an explicit operator choice should remain intact. diff --git a/changelog.d/211-placed-reaction-baseline.fixed.md b/changelog.d/211-placed-reaction-baseline.fixed.md index 4b17611b4..23191ee7e 100644 --- a/changelog.d/211-placed-reaction-baseline.fixed.md +++ b/changelog.d/211-placed-reaction-baseline.fixed.md @@ -1 +1 @@ -- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, a runtime connect refuses before it registers or spawns anything (a startup connect, before it spawns), any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens (blank, or commas alone) or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). +- Discord reaction suppression defaults include refusal markers, default/configured awareness markers, and retained recovery-outbox emojis. `framework.getPlacedReactionBaseline()` exposes that snapshot as suppression tokens, converting composite custom-emote markers to stable IDs; stored markers and reaction payloads remain unchanged. Stdio MCPL children get it by default, derived at each spawn (first connect, automatic reconnect, restart). Suppression is by emoji, so members' own reactions with 💤, the configured awareness emoji or a retained recovery emoji stop reaching the agent too, as members' 🛑 already did. When the awareness ledger can't be read, a runtime connect refuses before it registers or spawns anything (a startup connect, before it spawns), any restart refuses before it stops the running child, and an automatic reconnect of a child taking the default fails and retries rather than spawn without it. Explicit server env and intentionally inherited baseline values with tokens retain precedence, and the framework logs when one replaces the default; a value with no tokens, as discord-mcpl's parser reads them (blank, commas alone, or only tokens its normalizer reduces to nothing, such as `:`, `::` or a lone U+FE0F), or a null counts as unset, since the adapter would read it as suppression off (its filters file and legacy operator env are where suppression is turned off), and any other non-string value (a number from a JSON config) is read as its string form, as the child sees it, rather than failing the connect. On Windows, duplicate spellings resolve to the first in sorted order within each source, then declared values take precedence over inherited values. Hosts that inject the old refusal-only default must remove that default to adopt the framework-derived one; remote WebSocket adapters still need their own environment configuration (#95). diff --git a/src/framework.ts b/src/framework.ts index 23075c73f..fe73ad9aa 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14699,11 +14699,13 @@ export class AgentFramework { /** * The reaction baseline an operator chose for a stdio server, if any: a * value declared in its env, or one inherited from the host with - * `inheritEnv`, the declared one first. A value with no tokens, blank or - * commas alone, counts as unset: the Discord adapter splits on commas and - * drops empty tokens, so such a value (an empty systemd `Environment=`, say, - * or `${A},${B}` with both empty) would otherwise turn the protective - * default off without anyone choosing that. + * `inheritEnv`, the declared one first. A value with no tokens, as + * discord-mcpl's parser reads them, counts as unset: the adapter splits on + * commas and keeps a token only when its normalizer leaves something of it + * (blank, `:`, `::` and a lone U+FE0F leave nothing), so such a value (an + * empty systemd `Environment=`, say, or `${A},${B}` or `:${NAME}:` with the + * variables empty) would otherwise turn the protective default off without + * anyone choosing that. * * Windows names are matched in both sources: Workers expose a case- * sensitive `process.env` copy. Within one source, Node's spawn keeps the @@ -14719,6 +14721,14 @@ export class AgentFramework { private declaredReactionBaseline( config: import('./mcpl/types.js').McplServerConfig, ): { value: string; source: 'env' | 'inherited' } | undefined { + // A token as discord-mcpl's parser keeps one: `parseSuppressionEnvTokens` + // trims each comma-separated token and keeps it when + // `normalizeReactionEmoji` (discord-mcpl `src/filters.ts`) leaves + // something, after it strips U+FE0F, trims, and strips one leading and one + // trailing colon. The same steps in the same order, so the two can be + // compared when either changes. + const isToken = (token: string): boolean => + token.trim().replace(/\uFE0F/g, '').trim().replace(/^:|:$/g, '') !== ''; const valueIn = (env: NodeJS.ProcessEnv | undefined): string | undefined => { let raw: unknown; if (process.platform !== 'win32') raw = env?.DISCORD_SUPPRESSED_REACTIONS_BASELINE; @@ -14729,7 +14739,7 @@ export class AgentFramework { } if (raw === undefined || raw === null) return undefined; const value = String(raw); - return value.split(',').some((token) => token.trim() !== '') ? value : undefined; + return value.split(',').some(isToken) ? value : undefined; }; const declared = valueIn(config.env); if (declared !== undefined) return { value: declared, source: 'env' }; diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index 525ee3768..77c7dbee3 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -22,6 +22,13 @@ function baseline(framework: AgentFramework): readonly string[] { return (framework as unknown as { getPlacedReactionBaseline(): readonly string[] }).getPlacedReactionBaseline(); } +// A refusal for an awareness ledger that can't be read, whichever read met it +// first: the connect's or restart's own read, or a connect's awareness +// barrier. The assertions after each refusal say what it left behind. +function accountingRefusal(error: unknown): boolean { + return error instanceof Error && error.name === 'DiscordAwarenessAccountingError'; +} + async function childEnv(framework: AgentFramework) { const registry = (framework as unknown as { mcplServerRegistry: { getServer(id: string): { sendToolsCall(name: string, args: {}): Promise<{ content: Array<{ text?: string }> }> } }; @@ -196,19 +203,44 @@ describe('placed-reaction baseline', () => { assert.equal((await childEnv(fw)).baseline, [...REFUSAL_REACTION_BASELINE, '💤'].join(',')); }); - for (const blank of ['', ' ', ',', ' , ']) { - it('a baseline with no tokens, declared or inherited, counts as unset ' + JSON.stringify(blank), async (t) => { - // The adapter splits on commas and drops empty tokens, so it reads each - // of these as "no tokens": inherited by accident (an empty variable, or - // `${A},${B}` with both empty), it would turn the protective default off. + // discord-mcpl's parser (`parseSuppressionEnvTokens`) splits a value on + // commas, trims each token, and keeps one when `normalizeReactionEmoji` + // (discord-mcpl `src/filters.ts`) leaves something of it: that strips + // U+FE0F, trims, and strips one leading and one trailing colon. A value it + // reads as no tokens turns suppression off, so inherited by accident (an + // empty variable, or `${A},${B}` or `:${NAME}:` with the variables empty) + // it would turn the protective default off: such a value counts as unset. + // A value it keeps a token of stays the operator's. + for (const [value, tokens] of [ + ['', false], + [' ', false], + [',', false], + [' , ', false], + [':', false], + ['::', false], + ['::,::', false], + [' : , :: ', false], + ['\uFE0F', false], + // The adapter trims again after stripping U+FE0F, and only then strips + // the colon. + ['\uFE0F :', false], + [':sigil:', true], + ['sigil', true], + ['🟪', true], + ['<:mark:123456789012345678>', true], + // One colon from each end leaves ':'. + [':::', true], + ] as const) { + const name = JSON.stringify(value).replace(/\uFE0F/g, '\\uFE0F'); + it(`a baseline ${name}, declared or inherited, ${tokens ? 'stays the operator\'s' : 'has no tokens and counts as unset'}`, async (t) => { const previous = process.env[ENV_KEY]; - process.env[ENV_KEY] = blank; + process.env[ENV_KEY] = value; t.after(() => { if (previous === undefined) delete process.env[ENV_KEY]; else process.env[ENV_KEY] = previous; }); - const expected = [...REFUSAL_REACTION_BASELINE, '💤'].join(','); - const fw = await create(undefined, [server({ env: { [ENV_KEY]: blank } })]); + const expected = tokens ? value : [...REFUSAL_REACTION_BASELINE, '💤'].join(','); + const fw = await create(undefined, [server({ env: { [ENV_KEY]: value } })]); assert.equal((await childEnv(fw)).baseline, expected, 'declared'); await fw.disconnectMcplServer('probe'); await fw.connectMcplServer(server({ inheritEnv: true })); @@ -252,8 +284,9 @@ describe('placed-reaction baseline', () => { // with its own baseline can't come back either. const fw = await create(undefined, [server(own ? { env: { [ENV_KEY]: own } } : {})]); writeFileSync(outboxPath, '{broken'); - await assert.rejects(() => fw.restartMcplServer('probe'), /Discord awareness.*ledger/i); - assert.ok(await childEnv(fw), 'the running child still answers'); + await assert.rejects(() => fw.restartMcplServer('probe'), accountingRefusal); + // A restart that stopped the child leaves no server to ask. + assert.ok(await childEnv(fw).catch(() => undefined), 'the running child still answers'); }); } @@ -293,7 +326,6 @@ describe('placed-reaction baseline', () => { writeFileSync(outboxPath, '{broken'); assert.throws(() => baseline(fw), /Discord awareness.*ledger/i); await fw.disconnectMcplServer('probe'); - await assert.rejects(() => fw.connectMcplServer(server()), /Discord awareness.*ledger/i); // Every connect reads the ledger before it registers anything: a // reconnecting server, with or without its own baseline, would otherwise // be left registered as a closed stub that blocks a retried connect. @@ -301,8 +333,8 @@ describe('placed-reaction baseline', () => { mcplServerRegistry: { getServer(id: string): unknown }; mcplServerConfigs: Map; }; - for (const config of [server({ reconnect: true }), server({ reconnect: true, env: { [ENV_KEY]: '🟪' } })]) { - await assert.rejects(() => fw.connectMcplServer(config), /Discord awareness.*ledger/i); + for (const config of [server(), server({ reconnect: true }), server({ reconnect: true, env: { [ENV_KEY]: '🟪' } })]) { + await assert.rejects(() => fw.connectMcplServer(config), accountingRefusal); assert.equal(internals.mcplServerRegistry.getServer('probe'), null, 'no connection is left registered'); assert.equal(internals.mcplServerConfigs.has('probe'), false, 'no config is left registered'); }