From 7b67277bedaf4195158567ab6c19b8a6d24b82bf Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 14:33:40 -0400 Subject: [PATCH 1/5] fix(gate): refuse a conversation fork's and an ephemeral run's sleep and wake The gate's sleep is one for the whole agent: while it lasts, the gate holds back every wake but the heartbeat's and a privileged user's, and it decides a channel message's wake before the message is routed to a fork. So a fork's sleep held back the primary's ordinary wakes until the sleep or the fork ended, a fork's wake ended the primary's own sleep, and an ephemeral run's sleep did the same for the rest of its run. dispatchSleepToolCall now refuses both tools for a conversation fork and an ephemeral run, before the tool runs, as a fork's channel_open is refused. The result says that nothing was set or changed, and why. A fork that can call skip_reply is told its wake_in_seconds is the way to come back to the conversation later. The primary's sleep is unchanged, and still quiets the messages its forks would wake on. With no fork or run able to set a sleep, EventGate.agentGone() keeps only its self-wake cancel. Part of #646. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fork-sleep-refused.changed.md | 1 + src/framework.ts | 21 +++- src/gate/event-gate.ts | 8 +- test/conversation-routing.test.ts | 82 +++++++++++----- test/ephemeral-no-global-fallback.test.ts | 12 ++- test/event-gate-sleep.test.ts | 112 +++++++++++++++++----- 6 files changed, 178 insertions(+), 58 deletions(-) create mode 100644 changelog.d/fork-sleep-refused.changed.md diff --git a/changelog.d/fork-sleep-refused.changed.md b/changelog.d/fork-sleep-refused.changed.md new file mode 100644 index 000000000..0a8c598f3 --- /dev/null +++ b/changelog.d/fork-sleep-refused.changed.md @@ -0,0 +1 @@ +- A conversation fork's or an ephemeral run's `sleep` and `wake` are refused, with a result saying that nothing was set or changed, and why. The gate's sleep quiets the whole agent, so a fork's sleep held back the primary's wakes until the sleep or the fork ended, and a fork's `wake` ended the primary's own sleep. Where a fork can call `skip_reply`, the refusal names its `wake_in_seconds` as the way to come back to the conversation later. The primary's sleep is unchanged, and still quiets the messages its forks would wake on. diff --git a/src/framework.ts b/src/framework.ts index 9bea1fd49..7209b4290 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8546,7 +8546,7 @@ export class AgentFramework { // deregistered, so disposal must release EventGate liveness itself. this.eventGate?.onInferenceEnded(agent.name); this.agents.delete(agent.name); - // Its sleep and self-wake end with it, as a fork's do. + // Its pending self-wake ends with it, as a fork's does. this.eventGate?.agentGone(agent.name); this.releaseWindowNowOrAfterResume(agent); } @@ -12739,8 +12739,7 @@ export class AgentFramework { this.activeStreams.delete(agentName); this.pendingAssistantBlocks.delete(agentName); this.agents.delete(agentName); - // Its sleep and self-wake end with it (the gate's sleep holds back every - // agent's wakes), and neither wake goes to a name that's gone. + // Its pending self-wake ends with it, rather than wake a name that's gone. this.eventGate?.agentGone(agentName); if (agent) this.releaseWindowNowOrAfterResume(agent); this.turnRouting.delete(agentName); @@ -27085,6 +27084,22 @@ export class AgentFramework { } private dispatchSleepToolCall(agentName: string, call: ToolCall): void { + // The gate's sleep quiets the whole agent, every conversation of it, so a + // conversation fork or an ephemeral run neither sets one nor ends the + // agent's own. Refused before the tool runs, as a fork's channel_open is. + const fork = this.conversationAgentHomes.has(agentName); + if (fork || this.ephemeralRuns.has(agentName)) { + const one = fork ? 'conversation' : 'run'; + const comeBack = fork && this.channelRegistry && this.canAgentUseTool(agentName, 'skip_reply') + ? ' To come back to this conversation later, call skip_reply with wake_in_seconds.' + : ''; + const error = call.name === 'sleep' + ? `Nothing was set: a sleep quiets the whole agent, not one ${one}.${comeBack}` + : `Nothing was changed: a sleep is the agent's, so this ${one} can't end one.`; + this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: `${agentName}: ${error}` }); + this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result: this.refusedResult(agentName, call.id, error) }); + return; + } this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input }); const gate = this.eventGate!; const input = (call.input ?? {}) as { seconds?: number; announce?: boolean; message?: string }; diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 9480007cd..c9bac88eb 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1782,10 +1782,9 @@ export class EventGate { /** * `agentName` is gone, as a conversation fork's end or an ephemeral run's - * removes one: its pending self-wake is cancelled, and a sleep it set ends. - * The gate's sleep holds back every agent's wakes, so nobody is asleep once - * its agent is gone, and neither wake would reach a name that no longer - * exists. A sleep another agent set, or one set with no agent, stays. + * removes one: its pending self-wake is cancelled, rather than wake a name + * that no longer exists. It holds no sleep: neither can set one (the + * framework refuses their `sleep`). */ agentGone(agentName: string): void { const selfWake = this.selfWakeTimers.get(agentName); @@ -1793,7 +1792,6 @@ export class EventGate { clearTimeout(selfWake); this.selfWakeTimers.delete(agentName); } - if (this.sleepUntil !== 0 && this.sleepAgent === agentName) this.clearSleep(); } // ========================================================================= diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 928047842..b56a4bac9 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -680,8 +680,9 @@ describe('Conversation routing', () => { } }); - // The gate's sleep is one for every agent, and its wake goes to the agent - // that set it: a fork's sleep ends with the fork. + // The gate's sleep quiets the whole agent, every conversation of it, so a + // fork's sleep and wake are refused (#646), and a fork's end tells the gate, + // which cancels the fork's pending self-wake. async function makeGatedFramework(conversations: Partial = {}) { return AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), @@ -693,53 +694,88 @@ describe('Conversation routing', () => { }); } const gateOf = (framework: AgentFramework) => (framework as unknown as { eventGate: EventGate }).eventGate; + const gateRefusals = (framework: AgentFramework) => { + const refusals: string[] = []; + framework.onTrace((e: TraceEvent) => { + const failed = e as { type: string; module?: string; tool?: string; error?: string }; + if (failed.type === 'tool:failed' && failed.module === 'gate') refusals.push(`${failed.tool}: ${failed.error}`); + }); + return refusals; + }; + const ordinaryWake = { eventType: 'external-message', serverId: 'test', channelId: '', content: 'tick' }; + + it("a fork's sleep is refused, so the primary's ordinary wakes aren't held back", async () => { + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await makeGatedFramework(); + const gate = gateOf(framework); + const refusals = gateRefusals(framework); + try { + framework.pushEvent(incomingEvent({ channelId: 'slack:D9', text: 'hi', channelType: 'im' })); + await framework.runUntilIdle(); + const fork = forkName('slack:D9', 1); + assert.deepStrictEqual(refusals, [`sleep: ${fork}: Nothing was set: a sleep quiets the whole agent, not one conversation.`]); + assert.equal(gate.getSleepState(), null, 'nothing is asleep'); + assert.notEqual(gate.evaluate(ordinaryWake).policyName, 'asleep', "the primary's ordinary wake goes on to the policies"); + } finally { + // So that a run where the sleep takes doesn't hold the file's process for its hour. + gate.clearSleep(); + await framework.stop(); + } + }); + + it("a fork's wake is refused, so the primary's sleep stays", async () => { + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'w1', name: 'wake', input: {} }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + const framework = await makeGatedFramework(); + const gate = gateOf(framework); + const refusals = gateRefusals(framework); + try { + gate.setSleep(3600, 'resting', 'trunk'); + framework.pushEvent(incomingEvent({ channelId: 'slack:D10', text: 'hi', channelType: 'im' })); + await framework.runUntilIdle(); + const fork = forkName('slack:D10', 1); + assert.deepStrictEqual(refusals, [`wake: ${fork}: Nothing was changed: a sleep is the agent's, so this conversation can't end one.`]); + assert.ok(gate.getSleepState() !== null, "the primary's sleep stays"); + } finally { + gate.clearSleep(); + await framework.stop(); + } + }); - it("a fork's sleep ends when the idle sweep closes the fork", async () => { + it("a fork's end at the idle sweep tells the gate", async () => { const IDLE_TTL_MS = 60_000; membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); const framework = await makeGatedFramework({ idleTtlMs: IDLE_TTL_MS }); + const gone = goneNames(gateOf(framework)); try { framework.pushEvent(incomingEvent({ channelId: 'slack:D7', text: 'hi', channelType: 'im' })); await framework.runUntilIdle(); const fork = forkName('slack:D7', 1); assert.ok(framework.getAgent(fork), 'the fork exists'); - gateOf(framework).setSleep(3600, 'resting', fork); - const gone = goneNames(gateOf(framework)); forceExpiry(framework, 'slack:D7', IDLE_TTL_MS); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Final report posted.' }])); framework.pushEvent({ type: 'external-message', source: 'test', content: 'tick', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); - assert.equal(framework.getAgent(fork), null, 'the fork is disposed'); - assert.equal(gateOf(framework).getSleepState(), null, "and its sleep ends with it, so the other agents' wakes aren't held back"); - assert.deepStrictEqual(gone, [fork], "the fork's end told the gate, which ends its self-wake too"); + assert.deepStrictEqual(gone, [fork], 'and its end told the gate, which cancels its self-wake'); } finally { - // So that a run where the fork's end leaves the sleep doesn't hold the - // file's process for the sleep's hour. - gateOf(framework).clearSleep(); await framework.stop(); } }); - it("a fork's sleep ends when stop() ends the fork", async () => { + it("a fork's end at stop() tells the gate", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); const framework = await makeGatedFramework(); + const gone = goneNames(gateOf(framework)); framework.pushEvent(incomingEvent({ channelId: 'slack:D8', text: 'hi', channelType: 'im' })); await framework.runUntilIdle(); const fork = forkName('slack:D8', 1); assert.ok(framework.getAgent(fork), 'the fork exists'); - const gate = gateOf(framework); - const gone = goneNames(gate); - try { - gate.setSleep(3600, 'resting', fork); - await framework.stop(); - assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); - assert.equal(gate.inferenceDiagnostics().sleepUntil, 0, 'and its sleep with it'); - assert.deepStrictEqual(gone, [fork], "the fork's end told the gate, which ends its self-wake too"); - } finally { - gate.clearSleep(); - } + await framework.stop(); + assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); + assert.deepStrictEqual(gone, [fork], 'and its end told the gate'); }); it('a write deferred for a fork during its closure turn is stored in the fork before it is disposed', async () => { diff --git a/test/ephemeral-no-global-fallback.test.ts b/test/ephemeral-no-global-fallback.test.ts index ce70c2b1d..06ff589d3 100644 --- a/test/ephemeral-no-global-fallback.test.ts +++ b/test/ephemeral-no-global-fallback.test.ts @@ -257,14 +257,22 @@ describe('an ephemeral run takes no global fallback (#551)', () => { } finally { await f.close(); } }); - it('a subagent\'s sleep announcement is not posted to the latest inbound channel', async () => { + it('a subagent\'s sleep is refused, so nothing is announced and the gate stays awake', async () => { const f = await fixture({ framework: { gate: { config: { default: 'always' } } } as never }); const t = speechTraces(f); + const refusals: string[] = []; + f.framework.onTrace((e) => { + const failed = e as { type?: string; module?: string; tool?: string; error?: string }; + if (failed.type === 'tool:failed' && failed.module === 'gate') refusals.push(`${failed.tool}: ${failed.error}`); + }); try { await busyFallback(f); await runSubagent(f, 'sub-sleep', 'locus', true, - createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 60, message: 'subagent going quiet' } }], 'tool_use')); + createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 60, message: 'subagent going quiet' } }], 'tool_use'), + text('done')); + assert.deepEqual(refusals, ['sleep: sub-sleep: Nothing was set: a sleep quiets the whole agent, not one run.']); + assert.equal((f.framework as unknown as { eventGate: { getSleepState(): unknown } }).eventGate.getSleepState(), null, 'the gate stays awake'); assert.ok(!publishedText(f).includes('subagent going quiet'), `not published: ${publishedText(f)}`); assert.deepEqual(t.seen.filter((x) => x.conversationId === 'sub-sleep'), [], 'neither routed nor counted as a failed route'); } finally { t.off(); await f.close(); } diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index c2c7c15a0..0e686bfed 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -24,6 +24,7 @@ import { EventGate } from '../src/gate/event-gate.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { goneNames } from './helpers/gone-names.js'; +import { fixture, eventually, TS, ok } from './helpers/coalescing-fixture.js'; const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); @@ -171,17 +172,15 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); - it("agentGone() ends that agent's sleep and self-wake together, and leaves another agent's", () => { + it("agentGone() cancels that agent's self-wake, and leaves another agent's", () => { const { gate } = makeGate(); const selfWakes = (gate as unknown as { selfWakeTimers: Map }).selfWakeTimers; const before = timeouts(); try { - gate.setSleep(60, 'resting', 'fork'); gate.armSelfWake('fork', 60); gate.armSelfWake('agent', 60); gate.agentGone('fork'); - assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0, "the gone agent's sleep ends"); - assert.ok(!selfWakes.has('fork'), 'and its self-wake goes, entry and all'); + assert.ok(!selfWakes.has('fork'), "the gone agent's self-wake goes, entry and all"); assert.ok(selfWakes.has('agent'), "another agent's self-wake stays"); assert.strictEqual(timeouts(), before + 1, "and only that one's timer is left"); } finally { @@ -189,20 +188,6 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); - it("agentGone() leaves a sleep another agent set, or one set with no agent", () => { - const { gate } = makeGate(); - try { - gate.setSleep(60, 'resting', 'agent'); - gate.agentGone('fork'); - assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "another agent's end leaves its sleep"); - gate.setSleep(60, 'resting'); - gate.agentGone('fork'); - assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, 'a sleep with no agent stays'); - } finally { - gate.clearSleep(); - } - }); - it('after dispose(), a sleep set again arms no timer until resumeSleep()', () => { const { gate } = makeGate(); const before = timeouts(); @@ -233,7 +218,7 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); - it("an ephemeral run's sleep ends when the run does", async () => { + it("an ephemeral run's sleep is refused, and its end tells the gate", async () => { const membrane = new MockMembrane(); membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); @@ -247,25 +232,102 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { const gate = (framework as unknown as { eventGate: EventGate }).eventGate; const gone = goneNames(gate); const slept: string[] = []; + const refusals: string[] = []; framework.onTrace((e) => { - if ((e as { eventType?: unknown }).eventType === 'sleep:set') slept.push('sleep:set'); + const traced = e as { type?: string; eventType?: unknown; module?: string; tool?: string; error?: string }; + if (traced.eventType === 'sleep:set') slept.push('sleep:set'); + if (traced.type === 'tool:failed' && traced.module === 'gate') refusals.push(`${traced.tool}: ${traced.error}`); }); try { framework.start(); const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Work.', allowedTools: 'all' }); created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); await framework.runEphemeralToCompletion(created.agent, created.contextManager); - assert.deepStrictEqual(slept, ['sleep:set'], 'the run slept'); - assert.strictEqual(gate.getSleepState(), null, 'and its sleep ended with it'); - assert.deepStrictEqual(gone, ['worker'], "the run's end told the gate, which ends its self-wake too"); + assert.deepStrictEqual(refusals, ['sleep: worker: Nothing was set: a sleep quiets the whole agent, not one run.']); + assert.deepStrictEqual(slept, [], 'nothing slept'); + assert.strictEqual(gate.getSleepState(), null); + assert.deepStrictEqual(gone, ['worker'], "the run's end told the gate, which cancels its self-wake"); } finally { - // So that a run where the sleep outlives the run doesn't hold the - // file's process for the sleep's hour. + // So that a run where the sleep takes doesn't hold the file's process + // for its hour. gate.clearSleep(); await framework.stop(); } }); + it("the primary's sleep still silences a message to its conversation fork", async () => { + // The gate decides a channel message's wake before it's routed to a fork, + // so the primary's sleep quiets every conversation of the agent. + const CH = 'discord:g1:dm'; + const f = await fixture({ framework: { + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-fork.json') }, + conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } }, + } as never }); + const gate = (f.framework as unknown as { eventGate: EventGate }).eventGate; + const router = () => (f.framework as unknown as { getConversationRouter(): { getBinding(id: string): { agentName: string } | undefined } }).getConversationRouter(); + const incoming = (messageId: string) => f.send('channels/incoming', { + messages: [{ channelId: CH, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], + }); + try { + await f.send('channels/register', { channels: [{ id: CH, type: 'discord', label: 'dm', initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); + await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', CH) === 'open', 'the channel is open'); + gate.setSleep(3600, 'resting', 'agent'); + const before = f.membrane.calls.length; + await incoming('m1'); + await f.framework.runUntilIdle(); + assert.ok(router().getBinding(CH) !== undefined, 'the message reached a conversation fork'); + assert.strictEqual(f.membrane.calls.length, before, 'no turn while the primary sleeps'); + assert.ok((gate.getSleepState()?.suppressed ?? 0) >= 1, 'the gate held its wake'); + + // Control: awake, the next message wakes the fork. + gate.clearSleep(); + f.membrane.pushResponse(ok()); + await incoming('m2'); + await f.framework.runUntilIdle(); + assert.strictEqual(f.membrane.calls.length, before + 1, 'the fork took a turn'); + } finally { + gate.clearSleep(); + await f.close(); + } + }); + + for (const [what, allowedTools, remedy] of [ + ['can call skip_reply is told its wake_in_seconds is the way back', 'all', ' To come back to this conversation later, call skip_reply with wake_in_seconds.'], + ["can't call skip_reply isn't pointed at it", ['sleep', 'wake'], ''], + ] as const) it(`a fork that ${what}, when its sleep is refused`, async () => { + const CH = 'discord:g1:dm2'; + const f = await fixture({ + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test', allowedTools }], + framework: { + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-fork-remedy.json') }, + conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } }, + } as never, + }); + const refusals: string[] = []; + f.framework.onTrace((e) => { + const failed = e as { type?: string; module?: string; tool?: string; error?: string }; + if (failed.type === 'tool:failed' && failed.module === 'gate') refusals.push(`${failed.tool}: ${failed.error}`); + }); + const incoming = (messageId: string) => f.send('channels/incoming', { + messages: [{ channelId: CH, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], + }); + try { + await f.send('channels/register', { channels: [{ id: CH, type: 'discord', label: 'dm2', initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); + await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', CH) === 'open', 'the channel is open'); + await incoming('m1'); // the fork's first turn answers with the fixture's 'ok' + await f.framework.runUntilIdle(); + f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); + f.membrane.pushResponse(ok()); + await incoming('m2'); + await f.framework.runUntilIdle(); + assert.strictEqual(refusals.length, 1, `one refusal: ${JSON.stringify(refusals)}`); + assert.ok(refusals[0]!.endsWith(`: Nothing was set: a sleep quiets the whole agent, not one conversation.${remedy}`), refusals[0]); + } finally { + (f.framework as unknown as { eventGate: EventGate }).eventGate.clearSleep(); + await f.close(); + } + }); + it("a framework stopped while its agent sleeps wakes nothing until it's started again, then wakes the agent", async () => { // A store the caller owns: an owned store closes at stop(), and a start() // after that is refused. From ddfd93a01d296cfa3036e2c3c755124204620563 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 15:04:10 -0400 Subject: [PATCH 2/5] fix(gate): point no closing fork at skip_reply, and test the refusal's results From the internal review of the previous commit: - A fork's refused sleep is pointed at skip_reply's wake_in_seconds only outside its closing turn, whose end cancels the self-wake. - The refusal tests read the result each refused call queued (its refused flag and error) and assert that no tool started; an ephemeral run's wake is refused too, with the primary's sleep left standing; the primary's own wake and sleep pass through; and a fork's end at stop() leaves the primary's sleep. - The changelog fragment for a gone agent names only its self-wake, and the subagent test drops two checks a refused sleep can't reach. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sleep-timer-ends-at-stop.fixed.md | 2 +- src/framework.ts | 5 ++ test/conversation-routing.test.ts | 59 +++++++++++------ test/ephemeral-no-global-fallback.test.ts | 18 ++--- test/event-gate-sleep.test.ts | 65 +++++++++++++------ test/helpers/gone-names.ts | 19 +++++- 6 files changed, 115 insertions(+), 53 deletions(-) diff --git a/changelog.d/sleep-timer-ends-at-stop.fixed.md b/changelog.d/sleep-timer-ends-at-stop.fixed.md index dce43fb60..295b9ea2f 100644 --- a/changelog.d/sleep-timer-ends-at-stop.fixed.md +++ b/changelog.d/sleep-timer-ends-at-stop.fixed.md @@ -1,3 +1,3 @@ - A sleeping agent's wake no longer outlives its framework's `stop()`. The event gate's `dispose()` left the sleep's timer armed, so the stopped framework kept the process running until the sleep's end, and then queued a wake there. `dispose()` now clears that timer and keeps the sleep, and until the next `start()` nothing arms a sleep's or a self-wake's timer, even for a turn a failed `stop()` left running. The next `start()` on the same framework arms the wake again for the sleep's end, or wakes the agent at once if the sleep ended while it was stopped, as the queued wake did before. - A sleep now wakes the agent at its end by the gate's clock, whatever its length. Its timer could fire before the end, and the wake was then dropped, so the agent slept on until something else woke it. On Node a timer can fire a millisecond early: in a probe on Linux with Node 24.22.0, 7 to 10 of every 1,000 timers of 1 to 40 ms, armed the way a sleep arms them, did, and with Bun 1.4.3 none of 2,000 did. A clock set back did the same on any runtime, and a sleep longer than about 24.8 days fired its timer at once. -- A sleep a conversation fork or an ephemeral run set ends when it does, at a fork's idle close or at `stop()`, or at the run's end, and so does a `skip_reply` self-wake it left pending. The gate's sleep holds back every agent's wakes but the heartbeat's and a privileged user's, so they were held back until its end, and its wake, like the self-wake's, then went to a name that no longer existed. +- A `skip_reply` self-wake a conversation fork or an ephemeral run left pending ends when the fork or run does, at a fork's idle close or at `stop()`, or at the run's end, rather than wake a name that no longer exists. diff --git a/src/framework.ts b/src/framework.ts index 7209b4290..42455d7fb 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -27090,7 +27090,12 @@ export class AgentFramework { const fork = this.conversationAgentHomes.has(agentName); if (fork || this.ephemeralRuns.has(agentName)) { const one = fork ? 'conversation' : 'run'; + // Only where the fork can follow it: skip_reply needs the channel + // registry, which a fork made from an event pushed straight to the + // framework (pushEvent, with no MCPL server) doesn't have, and a + // closing fork's wake ends with it. const comeBack = fork && this.channelRegistry && this.canAgentUseTool(agentName, 'skip_reply') + && !this.closingConversationAgents.has(agentName) ? ' To come back to this conversation later, call skip_reply with wake_in_seconds.' : ''; const error = call.name === 'sleep' diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index b56a4bac9..2688df0c9 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -16,7 +16,7 @@ import type { ContentBlock } from '@animalabs/membrane'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { forkAgentName } from '../src/mcpl/conversation-router.js'; import type { EventGate } from '../src/gate/event-gate.js'; -import { goneNames } from './helpers/gone-names.js'; +import { goneNames, gateToolCalls } from './helpers/gone-names.js'; /** The name the router gives a channel's fork, for the `srv` server every * event here comes from. */ @@ -694,14 +694,6 @@ describe('Conversation routing', () => { }); } const gateOf = (framework: AgentFramework) => (framework as unknown as { eventGate: EventGate }).eventGate; - const gateRefusals = (framework: AgentFramework) => { - const refusals: string[] = []; - framework.onTrace((e: TraceEvent) => { - const failed = e as { type: string; module?: string; tool?: string; error?: string }; - if (failed.type === 'tool:failed' && failed.module === 'gate') refusals.push(`${failed.tool}: ${failed.error}`); - }); - return refusals; - }; const ordinaryWake = { eventType: 'external-message', serverId: 'test', channelId: '', content: 'tick' }; it("a fork's sleep is refused, so the primary's ordinary wakes aren't held back", async () => { @@ -709,12 +701,12 @@ describe('Conversation routing', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); const framework = await makeGatedFramework(); const gate = gateOf(framework); - const refusals = gateRefusals(framework); + const calls = gateToolCalls(framework); try { framework.pushEvent(incomingEvent({ channelId: 'slack:D9', text: 'hi', channelType: 'im' })); await framework.runUntilIdle(); - const fork = forkName('slack:D9', 1); - assert.deepStrictEqual(refusals, [`sleep: ${fork}: Nothing was set: a sleep quiets the whole agent, not one conversation.`]); + assert.deepStrictEqual(calls.refused, ['sleep: Nothing was set: a sleep quiets the whole agent, not one conversation.']); + assert.deepStrictEqual(calls.started, [], 'refused before the tool runs'); assert.equal(gate.getSleepState(), null, 'nothing is asleep'); assert.notEqual(gate.evaluate(ordinaryWake).policyName, 'asleep', "the primary's ordinary wake goes on to the policies"); } finally { @@ -729,13 +721,13 @@ describe('Conversation routing', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); const framework = await makeGatedFramework(); const gate = gateOf(framework); - const refusals = gateRefusals(framework); + const calls = gateToolCalls(framework); try { gate.setSleep(3600, 'resting', 'trunk'); framework.pushEvent(incomingEvent({ channelId: 'slack:D10', text: 'hi', channelType: 'im' })); await framework.runUntilIdle(); - const fork = forkName('slack:D10', 1); - assert.deepStrictEqual(refusals, [`wake: ${fork}: Nothing was changed: a sleep is the agent's, so this conversation can't end one.`]); + assert.deepStrictEqual(calls.refused, ["wake: Nothing was changed: a sleep is the agent's, so this conversation can't end one."]); + assert.deepStrictEqual(calls.started, [], 'refused before the tool runs'); assert.ok(gate.getSleepState() !== null, "the primary's sleep stays"); } finally { gate.clearSleep(); @@ -765,17 +757,44 @@ describe('Conversation routing', () => { } }); - it("a fork's end at stop() tells the gate", async () => { + it("a fork's end at stop() tells the gate, and leaves the primary's sleep", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); const framework = await makeGatedFramework(); - const gone = goneNames(gateOf(framework)); + const gate = gateOf(framework); + const gone = goneNames(gate); framework.pushEvent(incomingEvent({ channelId: 'slack:D8', text: 'hi', channelType: 'im' })); await framework.runUntilIdle(); const fork = forkName('slack:D8', 1); assert.ok(framework.getAgent(fork), 'the fork exists'); - await framework.stop(); - assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); - assert.deepStrictEqual(gone, [fork], 'and its end told the gate'); + try { + gate.setSleep(3600, 'resting', 'trunk'); + await framework.stop(); + assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); + assert.deepStrictEqual(gone, [fork], 'and its end told the gate'); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "the primary's sleep is kept through the fork's end and the stop"); + } finally { + gate.clearSleep(); + } + }); + + it("the primary's own wake and sleep still work", async () => { + // One turn: wake (with nothing asleep), then sleep, which ends the turn, + // since a second turn would first need something to rouse the sleeper. + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'w1', name: 'wake', input: {} }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); + const framework = await makeGatedFramework(); + const gate = gateOf(framework); + const calls = gateToolCalls(framework); + try { + assert.strictEqual(framework.nudgeAgent('trunk', 'test').ok, true); + await framework.runUntilIdle(); + assert.deepStrictEqual(calls.succeeded, ['wake', 'sleep']); + assert.deepStrictEqual(calls.refused, []); + assert.ok(gate.getSleepState() !== null, 'the primary slept'); + } finally { + gate.clearSleep(); + await framework.stop(); + } }); it('a write deferred for a fork during its closure turn is stored in the fork before it is disposed', async () => { diff --git a/test/ephemeral-no-global-fallback.test.ts b/test/ephemeral-no-global-fallback.test.ts index 06ff589d3..749295753 100644 --- a/test/ephemeral-no-global-fallback.test.ts +++ b/test/ephemeral-no-global-fallback.test.ts @@ -16,6 +16,7 @@ import assert from 'node:assert/strict'; import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager'; import { fixture, eventually, settles, TS } from './helpers/coalescing-fixture.js'; import { createMockResponse } from './helpers/mock-membrane.js'; +import { gateToolCalls } from './helpers/gone-names.js'; type Fixture = Awaited>; type Registry = { @@ -257,25 +258,18 @@ describe('an ephemeral run takes no global fallback (#551)', () => { } finally { await f.close(); } }); - it('a subagent\'s sleep is refused, so nothing is announced and the gate stays awake', async () => { + it('a subagent\'s sleep is refused, and the gate stays awake', async () => { const f = await fixture({ framework: { gate: { config: { default: 'always' } } } as never }); - const t = speechTraces(f); - const refusals: string[] = []; - f.framework.onTrace((e) => { - const failed = e as { type?: string; module?: string; tool?: string; error?: string }; - if (failed.type === 'tool:failed' && failed.module === 'gate') refusals.push(`${failed.tool}: ${failed.error}`); - }); + const calls = gateToolCalls(f.framework); try { await busyFallback(f); await runSubagent(f, 'sub-sleep', 'locus', true, - createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 60, message: 'subagent going quiet' } }], 'tool_use'), + createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 60 } }], 'tool_use'), text('done')); - assert.deepEqual(refusals, ['sleep: sub-sleep: Nothing was set: a sleep quiets the whole agent, not one run.']); + assert.deepEqual(calls.refused, ['sleep: Nothing was set: a sleep quiets the whole agent, not one run.']); assert.equal((f.framework as unknown as { eventGate: { getSleepState(): unknown } }).eventGate.getSleepState(), null, 'the gate stays awake'); - assert.ok(!publishedText(f).includes('subagent going quiet'), `not published: ${publishedText(f)}`); - assert.deepEqual(t.seen.filter((x) => x.conversationId === 'sub-sleep'), [], 'neither routed nor counted as a failed route'); - } finally { t.off(); await f.close(); } + } finally { await f.close(); } }); it('a channel the subagent opens itself is still where its prose lands', async () => { diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index 0e686bfed..0b85ad4bc 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -23,7 +23,7 @@ import { JsStore } from '@animalabs/chronicle'; import { EventGate } from '../src/gate/event-gate.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -import { goneNames } from './helpers/gone-names.js'; +import { goneNames, gateToolCalls } from './helpers/gone-names.js'; import { fixture, eventually, TS, ok } from './helpers/coalescing-fixture.js'; const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); @@ -218,9 +218,10 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); - it("an ephemeral run's sleep is refused, and its end tells the gate", async () => { + it("an ephemeral run's sleep and wake are refused, and its end tells the gate", async () => { const membrane = new MockMembrane(); membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'w1', name: 'wake', input: {} }], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); const framework = await AgentFramework.create({ storePath: join(TMP_DIR, 'store-ephemeral'), @@ -231,21 +232,21 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { }); const gate = (framework as unknown as { eventGate: EventGate }).eventGate; const gone = goneNames(gate); - const slept: string[] = []; - const refusals: string[] = []; - framework.onTrace((e) => { - const traced = e as { type?: string; eventType?: unknown; module?: string; tool?: string; error?: string }; - if (traced.eventType === 'sleep:set') slept.push('sleep:set'); - if (traced.type === 'tool:failed' && traced.module === 'gate') refusals.push(`${traced.tool}: ${traced.error}`); - }); + const calls = gateToolCalls(framework); try { framework.start(); + // The primary sleeps, so a run's sleep would replace its note and a + // run's wake would end it. + gate.setSleep(3600, 'resting', 'agent'); const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Work.', allowedTools: 'all' }); created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); await framework.runEphemeralToCompletion(created.agent, created.contextManager); - assert.deepStrictEqual(refusals, ['sleep: worker: Nothing was set: a sleep quiets the whole agent, not one run.']); - assert.deepStrictEqual(slept, [], 'nothing slept'); - assert.strictEqual(gate.getSleepState(), null); + assert.deepStrictEqual(calls.refused, [ + 'sleep: Nothing was set: a sleep quiets the whole agent, not one run.', + "wake: Nothing was changed: a sleep is the agent's, so this run can't end one.", + ]); + assert.deepStrictEqual(calls.started, [], 'refused before the tools run'); + assert.strictEqual(gate.getSleepState()?.note, 'resting', "the primary's sleep stands, neither replaced nor ended"); assert.deepStrictEqual(gone, ['worker'], "the run's end told the gate, which cancels its self-wake"); } finally { // So that a run where the sleep takes doesn't hold the file's process @@ -303,11 +304,7 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } }, } as never, }); - const refusals: string[] = []; - f.framework.onTrace((e) => { - const failed = e as { type?: string; module?: string; tool?: string; error?: string }; - if (failed.type === 'tool:failed' && failed.module === 'gate') refusals.push(`${failed.tool}: ${failed.error}`); - }); + const calls = gateToolCalls(f.framework); const incoming = (messageId: string) => f.send('channels/incoming', { messages: [{ channelId: CH, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], }); @@ -320,8 +317,38 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { f.membrane.pushResponse(ok()); await incoming('m2'); await f.framework.runUntilIdle(); - assert.strictEqual(refusals.length, 1, `one refusal: ${JSON.stringify(refusals)}`); - assert.ok(refusals[0]!.endsWith(`: Nothing was set: a sleep quiets the whole agent, not one conversation.${remedy}`), refusals[0]); + assert.deepStrictEqual(calls.refused, [`sleep: Nothing was set: a sleep quiets the whole agent, not one conversation.${remedy}`]); + } finally { + (f.framework as unknown as { eventGate: EventGate }).eventGate.clearSleep(); + await f.close(); + } + }); + + it("a closing fork isn't pointed at skip_reply when its sleep is refused, since its self-wake would end with it", async () => { + const CH = 'discord:g1:dm3'; + const IDLE_TTL_MS = 60_000; + const f = await fixture({ framework: { + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-fork-closing.json') }, + conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' }, idleTtlMs: IDLE_TTL_MS }, + } as never }); + const calls = gateToolCalls(f.framework); + try { + await f.send('channels/register', { channels: [{ id: CH, type: 'discord', label: 'dm3', initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); + await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', CH) === 'open', 'the channel is open'); + await f.send('channels/incoming', { + messages: [{ channelId: CH, messageId: 'm1', timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], + }); + await f.framework.runUntilIdle(); // the fork's first turn answers with the fixture's 'ok' + // As conversation-routing's forceExpiry does: idle past the TTL, and the sweep due. + const binding = (f.framework as unknown as { getConversationRouter(): { getBinding(id: string): { lastActivity: number } | undefined } }).getConversationRouter().getBinding(CH); + assert.ok(binding, 'the fork is bound'); + binding.lastActivity = Date.now() - IDLE_TTL_MS - 1_000; + (f.framework as unknown as { lastConversationSweep: number }).lastConversationSweep = 0; + f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); + f.membrane.pushResponse(ok()); + f.framework.pushEvent({ type: 'external-message', source: 'test', content: 'tick', metadata: {} } as never); + await f.framework.runUntilIdle(); + assert.deepStrictEqual(calls.refused, ['sleep: Nothing was set: a sleep quiets the whole agent, not one conversation.'], 'its closure turn is told no way back'); } finally { (f.framework as unknown as { eventGate: EventGate }).eventGate.clearSleep(); await f.close(); diff --git a/test/helpers/gone-names.ts b/test/helpers/gone-names.ts index 8c9c53c3d..48ad53364 100644 --- a/test/helpers/gone-names.ts +++ b/test/helpers/gone-names.ts @@ -1,10 +1,27 @@ import type { EventGate } from '../../src/gate/event-gate.js'; /** The names the framework tells the gate are gone, through agentGone(), - * whose sleep and self-wake rule the gate's own tests check. */ + * whose self-wake rule the gate's own tests check. */ export function goneNames(gate: EventGate): string[] { const gone: string[] = []; const agentGone = gate.agentGone.bind(gate); gate.agentGone = (name: string) => { gone.push(name); agentGone(name); }; return gone; } + +/** What the gate's sleep and wake tools answered, as the framework queued + * each result: `tool: error` for a refused call, and the calls that started + * (a refusal starts none). */ +export function gateToolCalls(framework: { onTrace(listener: (event: unknown) => void): unknown }): { refused: string[]; succeeded: string[]; started: string[] } { + const calls = { refused: [] as string[], succeeded: [] as string[], started: [] as string[] }; + framework.onTrace((e) => { + const traced = e as { type?: string; module?: string; tool?: string; processEvent?: { type?: string; toolName?: string; moduleName?: string; result?: { refused?: boolean; success?: boolean; error?: string } } }; + if (traced.type === 'tool:started' && traced.module === 'gate') calls.started.push(String(traced.tool)); + const ev = traced.processEvent; + if (traced.type === 'process:received' && ev?.type === 'tool-result' && ev.moduleName === 'gate' && (ev.toolName === 'sleep' || ev.toolName === 'wake')) { + if (ev.result?.refused === true) calls.refused.push(`${ev.toolName}: ${ev.result.error}`); + else if (ev.result?.success === true) calls.succeeded.push(String(ev.toolName)); + } + }); + return calls; +} From c7d8f952fa023fe09b6db899335061a72aaac16d Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 18:45:22 -0400 Subject: [PATCH 3/5] test: give gateToolCalls and forceExpiry helpers of their own, and the fork tests one fixture gateToolCalls() reads the gate tools' queued results, which has nothing to do with goneNames(), so it moves out of test/helpers/gone-names.ts into test/helpers/gate-tool-calls.ts. conversation-routing's forceExpiry() moves to test/helpers/force-expiry.ts, and the closing-fork test uses it instead of repeating its two private-field writes. event-gate-sleep's three fork tests share forkFixture(), which makes the framework, registers and opens the channel, and sends it messages, instead of each repeating that setup. Tests only: each test checks what it checked before, and the one assertion that moved into forceExpiry() keeps its check with that helper's message. Co-Authored-By: Claude Opus 5.5 --- test/conversation-routing.test.ts | 13 +--- test/ephemeral-no-global-fallback.test.ts | 2 +- test/event-gate-sleep.test.ts | 73 +++++++++++------------ test/helpers/force-expiry.ts | 11 ++++ test/helpers/gate-tool-calls.ts | 16 +++++ test/helpers/gone-names.ts | 17 ------ 6 files changed, 65 insertions(+), 67 deletions(-) create mode 100644 test/helpers/force-expiry.ts create mode 100644 test/helpers/gate-tool-calls.ts diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 2688df0c9..5b82c0fdd 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -16,7 +16,9 @@ import type { ContentBlock } from '@animalabs/membrane'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { forkAgentName } from '../src/mcpl/conversation-router.js'; import type { EventGate } from '../src/gate/event-gate.js'; -import { goneNames, gateToolCalls } from './helpers/gone-names.js'; +import { goneNames } from './helpers/gone-names.js'; +import { gateToolCalls } from './helpers/gate-tool-calls.js'; +import { forceExpiry } from './helpers/force-expiry.js'; /** The name the router gives a channel's fork, for the `srv` server every * event here comes from. */ @@ -83,15 +85,6 @@ describe('Conversation routing', () => { }); } - /** Make a binding look idle past the TTL and re-arm the sweep throttle — - * deterministic expiry instead of racing a 1ms TTL against the wall clock. */ - function forceExpiry(framework: AgentFramework, channelId: string, idleTtlMs: number) { - const binding = framework.getConversationRouter()!.getBinding(channelId); - assert.ok(binding, `binding for ${channelId} should exist before forcing expiry`); - binding!.lastActivity = Date.now() - idleTtlMs - 1_000; - (framework as unknown as { lastConversationSweep: number }).lastConversationSweep = 0; - } - it('rejects an unknown template agent at creation', async () => { await assert.rejects( () => AgentFramework.create({ diff --git a/test/ephemeral-no-global-fallback.test.ts b/test/ephemeral-no-global-fallback.test.ts index 749295753..3ffbdfedd 100644 --- a/test/ephemeral-no-global-fallback.test.ts +++ b/test/ephemeral-no-global-fallback.test.ts @@ -16,7 +16,7 @@ import assert from 'node:assert/strict'; import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager'; import { fixture, eventually, settles, TS } from './helpers/coalescing-fixture.js'; import { createMockResponse } from './helpers/mock-membrane.js'; -import { gateToolCalls } from './helpers/gone-names.js'; +import { gateToolCalls } from './helpers/gate-tool-calls.js'; type Fixture = Awaited>; type Registry = { diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index 0b85ad4bc..78853cf4a 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -23,11 +23,37 @@ import { JsStore } from '@animalabs/chronicle'; import { EventGate } from '../src/gate/event-gate.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -import { goneNames, gateToolCalls } from './helpers/gone-names.js'; +import { goneNames } from './helpers/gone-names.js'; +import { gateToolCalls } from './helpers/gate-tool-calls.js'; +import { forceExpiry } from './helpers/force-expiry.js'; import { fixture, eventually, TS, ok } from './helpers/coalescing-fixture.js'; const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); +/** A framework whose agent forks a conversation for each message on the open + * channel `channelId`, with a gate; `incoming` sends that channel a message. */ +async function forkFixture(channelId: string, options: { agents?: unknown[]; idleTtlMs?: number } = {}) { + const f = await fixture({ + ...(options.agents ? { agents: options.agents } : {}), + framework: { + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, `gate-${channelId.replace(/\W/g, '-')}.json`) }, + conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' }, ...(options.idleTtlMs ? { idleTtlMs: options.idleTtlMs } : {}) }, + } as never, + }); + const gate = (f.framework as unknown as { eventGate: EventGate }).eventGate; + const incoming = (messageId: string) => f.send('channels/incoming', { + messages: [{ channelId, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], + }); + try { + await f.send('channels/register', { channels: [{ id: channelId, type: 'discord', label: channelId.split(':').pop(), initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); + await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', channelId) === 'open', 'the channel is open'); + } catch (err) { + await f.close(); + throw err; + } + return { f, gate, incoming }; +} + function makeGate(now?: () => number) { const inferenceRequests: Array<{ agentName: string; reason: string; source: string }> = []; const traces: string[] = []; @@ -260,18 +286,9 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { // The gate decides a channel message's wake before it's routed to a fork, // so the primary's sleep quiets every conversation of the agent. const CH = 'discord:g1:dm'; - const f = await fixture({ framework: { - gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-fork.json') }, - conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } }, - } as never }); - const gate = (f.framework as unknown as { eventGate: EventGate }).eventGate; + const { f, gate, incoming } = await forkFixture(CH); const router = () => (f.framework as unknown as { getConversationRouter(): { getBinding(id: string): { agentName: string } | undefined } }).getConversationRouter(); - const incoming = (messageId: string) => f.send('channels/incoming', { - messages: [{ channelId: CH, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], - }); try { - await f.send('channels/register', { channels: [{ id: CH, type: 'discord', label: 'dm', initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); - await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', CH) === 'open', 'the channel is open'); gate.setSleep(3600, 'resting', 'agent'); const before = f.membrane.calls.length; await incoming('m1'); @@ -297,20 +314,9 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { ["can't call skip_reply isn't pointed at it", ['sleep', 'wake'], ''], ] as const) it(`a fork that ${what}, when its sleep is refused`, async () => { const CH = 'discord:g1:dm2'; - const f = await fixture({ - agents: [{ name: 'agent', model: 'test', systemPrompt: 'test', allowedTools }], - framework: { - gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-fork-remedy.json') }, - conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } }, - } as never, - }); + const { f, gate, incoming } = await forkFixture(CH, { agents: [{ name: 'agent', model: 'test', systemPrompt: 'test', allowedTools }] }); const calls = gateToolCalls(f.framework); - const incoming = (messageId: string) => f.send('channels/incoming', { - messages: [{ channelId: CH, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], - }); try { - await f.send('channels/register', { channels: [{ id: CH, type: 'discord', label: 'dm2', initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); - await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', CH) === 'open', 'the channel is open'); await incoming('m1'); // the fork's first turn answers with the fixture's 'ok' await f.framework.runUntilIdle(); f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); @@ -319,7 +325,7 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { await f.framework.runUntilIdle(); assert.deepStrictEqual(calls.refused, [`sleep: Nothing was set: a sleep quiets the whole agent, not one conversation.${remedy}`]); } finally { - (f.framework as unknown as { eventGate: EventGate }).eventGate.clearSleep(); + gate.clearSleep(); await f.close(); } }); @@ -327,30 +333,19 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { it("a closing fork isn't pointed at skip_reply when its sleep is refused, since its self-wake would end with it", async () => { const CH = 'discord:g1:dm3'; const IDLE_TTL_MS = 60_000; - const f = await fixture({ framework: { - gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-fork-closing.json') }, - conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' }, idleTtlMs: IDLE_TTL_MS }, - } as never }); + const { f, gate, incoming } = await forkFixture(CH, { idleTtlMs: IDLE_TTL_MS }); const calls = gateToolCalls(f.framework); try { - await f.send('channels/register', { channels: [{ id: CH, type: 'discord', label: 'dm3', initiallyOpen: true, metadata: { channelType: 'guild_text' } }] }); - await eventually(() => (f.framework as unknown as { channelRegistry: { getDesiredState(s: string, c: string): string | undefined } }).channelRegistry.getDesiredState('editor', CH) === 'open', 'the channel is open'); - await f.send('channels/incoming', { - messages: [{ channelId: CH, messageId: 'm1', timestamp: TS, author: { id: 'U1', name: 'antra' }, tags: ['chat:from-human'], content: [{ type: 'text', text: 'hello' }] }], - }); + await incoming('m1'); await f.framework.runUntilIdle(); // the fork's first turn answers with the fixture's 'ok' - // As conversation-routing's forceExpiry does: idle past the TTL, and the sweep due. - const binding = (f.framework as unknown as { getConversationRouter(): { getBinding(id: string): { lastActivity: number } | undefined } }).getConversationRouter().getBinding(CH); - assert.ok(binding, 'the fork is bound'); - binding.lastActivity = Date.now() - IDLE_TTL_MS - 1_000; - (f.framework as unknown as { lastConversationSweep: number }).lastConversationSweep = 0; + forceExpiry(f.framework, CH, IDLE_TTL_MS); // idle past the TTL, and the sweep due f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); f.membrane.pushResponse(ok()); f.framework.pushEvent({ type: 'external-message', source: 'test', content: 'tick', metadata: {} } as never); await f.framework.runUntilIdle(); assert.deepStrictEqual(calls.refused, ['sleep: Nothing was set: a sleep quiets the whole agent, not one conversation.'], 'its closure turn is told no way back'); } finally { - (f.framework as unknown as { eventGate: EventGate }).eventGate.clearSleep(); + gate.clearSleep(); await f.close(); } }); diff --git a/test/helpers/force-expiry.ts b/test/helpers/force-expiry.ts new file mode 100644 index 000000000..129517d39 --- /dev/null +++ b/test/helpers/force-expiry.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import type { AgentFramework } from '../../src/index.js'; + +/** Make a binding look idle past the TTL and re-arm the sweep throttle — + * deterministic expiry instead of racing a 1ms TTL against the wall clock. */ +export function forceExpiry(framework: AgentFramework, channelId: string, idleTtlMs: number): void { + const binding = framework.getConversationRouter()!.getBinding(channelId); + assert.ok(binding, `binding for ${channelId} should exist before forcing expiry`); + binding!.lastActivity = Date.now() - idleTtlMs - 1_000; + (framework as unknown as { lastConversationSweep: number }).lastConversationSweep = 0; +} diff --git a/test/helpers/gate-tool-calls.ts b/test/helpers/gate-tool-calls.ts new file mode 100644 index 000000000..621240530 --- /dev/null +++ b/test/helpers/gate-tool-calls.ts @@ -0,0 +1,16 @@ +/** What the gate's sleep and wake tools answered, as the framework queued + * each result: `tool: error` for a refused call, and the calls that started + * (a refusal starts none). */ +export function gateToolCalls(framework: { onTrace(listener: (event: unknown) => void): unknown }): { refused: string[]; succeeded: string[]; started: string[] } { + const calls = { refused: [] as string[], succeeded: [] as string[], started: [] as string[] }; + framework.onTrace((e) => { + const traced = e as { type?: string; module?: string; tool?: string; processEvent?: { type?: string; toolName?: string; moduleName?: string; result?: { refused?: boolean; success?: boolean; error?: string } } }; + if (traced.type === 'tool:started' && traced.module === 'gate') calls.started.push(String(traced.tool)); + const ev = traced.processEvent; + if (traced.type === 'process:received' && ev?.type === 'tool-result' && ev.moduleName === 'gate' && (ev.toolName === 'sleep' || ev.toolName === 'wake')) { + if (ev.result?.refused === true) calls.refused.push(`${ev.toolName}: ${ev.result.error}`); + else if (ev.result?.success === true) calls.succeeded.push(String(ev.toolName)); + } + }); + return calls; +} diff --git a/test/helpers/gone-names.ts b/test/helpers/gone-names.ts index 48ad53364..6b373efbc 100644 --- a/test/helpers/gone-names.ts +++ b/test/helpers/gone-names.ts @@ -8,20 +8,3 @@ export function goneNames(gate: EventGate): string[] { gate.agentGone = (name: string) => { gone.push(name); agentGone(name); }; return gone; } - -/** What the gate's sleep and wake tools answered, as the framework queued - * each result: `tool: error` for a refused call, and the calls that started - * (a refusal starts none). */ -export function gateToolCalls(framework: { onTrace(listener: (event: unknown) => void): unknown }): { refused: string[]; succeeded: string[]; started: string[] } { - const calls = { refused: [] as string[], succeeded: [] as string[], started: [] as string[] }; - framework.onTrace((e) => { - const traced = e as { type?: string; module?: string; tool?: string; processEvent?: { type?: string; toolName?: string; moduleName?: string; result?: { refused?: boolean; success?: boolean; error?: string } } }; - if (traced.type === 'tool:started' && traced.module === 'gate') calls.started.push(String(traced.tool)); - const ev = traced.processEvent; - if (traced.type === 'process:received' && ev?.type === 'tool-result' && ev.moduleName === 'gate' && (ev.toolName === 'sleep' || ev.toolName === 'wake')) { - if (ev.result?.refused === true) calls.refused.push(`${ev.toolName}: ${ev.result.error}`); - else if (ev.result?.success === true) calls.succeeded.push(String(ev.toolName)); - } - }); - return calls; -} From 922404c7f1ef5e875b33ad465a745c47741dc736 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Sat, 24 Oct 2026 07:11:08 -0400 Subject: [PATCH 4/5] test: the fork-sleep pin looks a binding up by server and channel; main's bare sleep harnesses have ephemeralRuns After main's merge (cba968b6), three tests failed at the whole suite: - event-gate-sleep's "the primary's sleep still silences a message to its conversation fork" looked the fork's binding up by a bare channel id, through a cast tsc couldn't check, where #6c4048ed keys bindings by server and channel. It now passes the fixture's { serverId: 'editor', channelId }. - present-while-acting's two sleep-announcement tests and send-into-closed-channel's announcement test call dispatchSleepToolCall on an Object.create(AgentFramework.prototype) harness that set conversationAgentHomes but not ephemeralRuns, which the refusal reads on every call. Both harnesses now set it, as inference-failure-bounds' do. --- test/event-gate-sleep.test.ts | 5 +++-- test/present-while-acting.test.ts | 1 + test/send-into-closed-channel.test.ts | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index 23e89e8ec..9b4ca6f3c 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -26,6 +26,7 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { goneNames } from './helpers/gone-names.js'; import { gateToolCalls } from './helpers/gate-tool-calls.js'; import { forceExpiry } from './helpers/force-expiry.js'; +import type { ChannelRef } from '../src/mcpl/channel-registry.js'; import { fixture, eventually, TS, ok } from './helpers/coalescing-fixture.js'; const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); @@ -287,13 +288,13 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { // so the primary's sleep quiets every conversation of the agent. const CH = 'discord:g1:dm'; const { f, gate, incoming } = await forkFixture(CH); - const router = () => (f.framework as unknown as { getConversationRouter(): { getBinding(id: string): { agentName: string } | undefined } }).getConversationRouter(); + const router = () => (f.framework as unknown as { getConversationRouter(): { getBinding(channel: ChannelRef): { agentName: string } | undefined } }).getConversationRouter(); try { gate.setSleep(3600, 'resting', 'agent'); const before = f.membrane.calls.length; await incoming('m1'); await f.framework.runUntilIdle(); - assert.ok(router().getBinding(CH) !== undefined, 'the message reached a conversation fork'); + assert.ok(router().getBinding({ serverId: 'editor', channelId: CH }) !== undefined, 'the message reached a conversation fork'); assert.strictEqual(f.membrane.calls.length, before, 'no turn while the primary sleeps'); assert.ok((gate.getSleepState()?.suppressed ?? 0) >= 1, 'the gate held its wake'); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index eb256bf5f..0d4f4d6c5 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1790,6 +1790,7 @@ describe('present while acting', () => { framework.emitTrace = () => {}; framework.pushEvent = () => {}; framework.conversationAgentHomes = new Map(); + framework.ephemeralRuns = new Map(); framework.lastAnnouncedLocus = new Map(); framework.activeTurnTriggers = new Map(); framework.openLogical = new Map(); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index ad1e8d309..bacca1401 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -145,8 +145,10 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { setStateJson: (_id: string, value: unknown) => { frameworkState = value; }, }; framework.agents = new Map([['sol', agentStub()]]); - // Conversation agents by their home channel; sol isn't one. + // Conversation agents by their home channel, and ephemeral runs by name; + // sol is neither. framework.conversationAgentHomes = new Map(); + framework.ephemeralRuns = new Map(); const windowMessages: string[] = []; /** Whose window each of those was addressed to: addMessage's forAgent, or * undefined for the primary agent's. */ From 3ea15299d971a20a3444663a363aa855a7e9d763 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Sat, 24 Oct 2026 07:19:56 -0400 Subject: [PATCH 5/5] test: main's closed-channel sleep test no longer pins a conversation agent's announcement send-into-closed-channel's announcement test (from main, written while this change was open) made sol a conversation agent and expected its sleep to be announced with the channel's words. This change refuses a conversation agent's sleep before any announcement, which event-gate-sleep's tests pin, so that segment's premise is gone: it is dropped, with a comment saying why. Nothing in the announcement code reads conversationAgentHomes, so no code went with it. --- test/send-into-closed-channel.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index bacca1401..e46e4f412 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -859,11 +859,8 @@ test("a sleep announced into a channel the agent or a module closed says the age assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:343434', text: quiet }]); routing.locus = ref(channelId); - // A conversation agent's announcement reads the same: the words follow the channel. - framework.conversationAgentHomes.set('sol', channelId); - slept = await sleep({ seconds: 600 }); - framework.conversationAgentHomes.delete('sol'); - assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + // A conversation agent announces nothing: its sleep is refused before the + // announcement (dispatchSleepToolCall; event-gate-sleep.test.ts). // Hybrid routing announces to the locus as locus mode does. framework.agents.set('sol', agentStub({ proseRouting: 'hybrid' }));