diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index b8def9ff8fd..c8924cd53cb 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -62,7 +62,7 @@ // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts -// dateChange.seed src/agent/dateChange/dateChangeService.ts +// dateChange.seed src/features/dateChange/dateChangeService.ts // externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts // fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts @@ -999,12 +999,6 @@ export interface AgentStateSnapshot { 'agentsMdReminder.seeded': boolean; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; - // src/agent/dateChange/dateChangeService.ts - 'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts */ { - readonly localDate: string; - readonly timeZone: string; - readonly renderGeneration: number; - } | undefined; // src/agent/externalHooks/externalHooksService.ts 'externalHooks.stopHookContinuationUsed': boolean; // src/agent/fullCompaction/fullCompactionService.ts @@ -1192,6 +1186,12 @@ export interface AgentStateSnapshot { inputCacheCreation: number; } | undefined; 'usage.currentTurnId': number | undefined; + // src/features/dateChange/dateChangeService.ts + 'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/features/dateChange/dateChangeService.ts */ { + readonly localDate: string; + readonly timeZone: string; + readonly renderGeneration: number; + } | undefined; // src/features/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; } diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts index 190e8f0b284..2801992dba7 100644 --- a/packages/agent-core-v2/src/_base/state/stateRegistry.ts +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -30,7 +30,7 @@ * Persistence and replay are out of scope here. Scope-agnostic. */ -import { Disposable } from '../di/lifecycle'; +import { Disposable, type IDisposable, toDisposable } from '../di/lifecycle'; import { BugIndicatingError } from '../errors/errors'; import { Emitter, type Event } from '../event'; @@ -55,7 +55,7 @@ export interface StateInspection { } export interface IStateRegistry { - register(key: StateKey): void; + register(key: StateKey): IDisposable; has(key: StateKey): boolean; get(key: StateKey): T; set(key: StateKey, value: T): void; @@ -69,6 +69,7 @@ export interface IStateRegistry { // NOTE: stays Disposable — its own 'get' collides with the Fiber export class StateRegistry extends Disposable implements IStateRegistry { private readonly values = new Map(); + private readonly registrations = new Map(); private readonly keyEmitters = new Map>(); private readonly anyEmitter = this._register(new Emitter()); readonly onDidChangeAny: Event = this.anyEmitter.event; @@ -76,11 +77,20 @@ export class StateRegistry extends Disposable implements IStateRegistry { protected readonly inspectScope: string = 'unknown'; protected inspectParent?: IStateRegistry; - register(key: StateKey): void { + register(key: StateKey): IDisposable { if (this.values.has(key.name)) { throw new BugIndicatingError(`state key '${key.name}' is already registered`); } + const registration = {}; + this.registrations.set(key.name, registration); this.values.set(key.name, key.initial()); + return toDisposable(() => { + if (this.registrations.get(key.name) !== registration) return; + this.registrations.delete(key.name); + this.values.delete(key.name); + this.keyEmitters.get(key.name)?.dispose(); + this.keyEmitters.delete(key.name); + }); } has(key: StateKey): boolean { diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts b/packages/agent-core-v2/src/features/dateChange/dateChange.ts similarity index 100% rename from packages/agent-core-v2/src/agent/dateChange/dateChange.ts rename to packages/agent-core-v2/src/features/dateChange/dateChange.ts diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts new file mode 100644 index 00000000000..ea336a94723 --- /dev/null +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts @@ -0,0 +1,19 @@ +import { ScopeActivation } from '#/_base/di/instantiation'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IAgentDateChangeService } from './dateChange'; +import { AgentDateChangeService } from './dateChangeService'; + +export class DateChangeFeature extends Feature { + static override readonly name = 'dateChange'; + + constructor() { + super(); + this.contributeAgentService(IAgentDateChangeService, AgentDateChangeService, { + activation: ScopeActivation.OnScopeCreated, + }); + } +} + +registerFeature(DateChangeFeature); diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts similarity index 92% rename from packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts rename to packages/agent-core-v2/src/features/dateChange/dateChangeService.ts index ac5e9a2cef4..f39b1734d93 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts @@ -16,15 +16,13 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextInjectorService, type ContextInjectionContext, type ContextInjectionResult, } from '#/agent/contextInjector/contextInjector'; -import { pickDisclosureBaseline } from '#/agent/contextInjector/disclosureBaseline'; +import { pickDisclosureBaseline } from './disclosureBaseline'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { IHostClock } from '#/os/interface/hostClock'; @@ -50,7 +48,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan @ISessionContext private readonly sessionContext: ISessionContext, ) { super(); - this.states.register(dateChangeSeedKey); + this._register(this.states.register(dateChangeSeedKey)); this._register( injector.register( DATE_CHANGE_INJECTION_VARIANT, @@ -135,11 +133,3 @@ function currentDateDisclosure(clock: IHostClock): Omit { expect(() => registry.register(countKey)).toThrow(BugIndicatingError); }); + it('removes the key and value when its registration is disposed', () => { + const registry = new StateRegistry(); + const registration = registry.register(countKey); + registry.set(countKey, 42); + + registration.dispose(); + + expect(registry.has(countKey)).toBe(false); + expect(registry.entries()).toEqual([]); + expect(() => registry.get(countKey)).toThrow(BugIndicatingError); + expect(() => registry.set(countKey, 1)).toThrow(BugIndicatingError); + }); + + it('re-registers with the initial value and ignores stale disposal', () => { + const registry = new StateRegistry(); + const first = registry.register(countKey); + registry.set(countKey, 42); + first.dispose(); + + const second = registry.register(countKey); + expect(registry.get(countKey)).toBe(0); + + first.dispose(); + expect(registry.has(countKey)).toBe(true); + second.dispose(); + expect(registry.has(countKey)).toBe(false); + }); + + it('isolates listeners between registrations', () => { + const registry = new StateRegistry(); + const first = registry.register(countKey); + const oldSeen: number[] = []; + registry.onDidChange(countKey)((value) => oldSeen.push(value)); + registry.set(countKey, 1); + first.dispose(); + + const second = registry.register(countKey); + const newSeen: number[] = []; + registry.onDidChange(countKey)((value) => newSeen.push(value)); + registry.set(countKey, 2); + + expect(oldSeen).toEqual([1]); + expect(newSeen).toEqual([2]); + second.dispose(); + }); + it('rejects get and set on an unregistered key', () => { const registry = new StateRegistry(); expect(() => registry.get(countKey)).toThrow(BugIndicatingError); diff --git a/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts b/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts similarity index 86% rename from packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts rename to packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts index ff52f3c7361..f9e06e8a7a0 100644 --- a/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts +++ b/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts @@ -15,14 +15,20 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FiberState } from '#/_base/di/fiber'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentStateService } from '#/agent/state/agentState'; import { DEFAULT_AGENT_PROFILE_NAME, type EnvironmentDisclosureSnapshot, } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IFeatureManager } from '#/app/feature/featureManager'; +import { IAgentDateChangeService } from '#/features/dateChange/dateChange'; +import { DateChangeFeature } from '#/features/dateChange/dateChangeFeature'; +import { dateChangeSeedKey } from '#/features/dateChange/dateChangeService'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -33,7 +39,7 @@ import { InMemoryWireRecordPersistence, type TestAgentContext, } from '../../harness'; -import { runWillBeginStepHooks } from '../loop/stubs'; +import { runWillBeginStepHooks } from '../../agent/loop/stubs'; const TEST_TIME_ZONE = 'Asia/Shanghai'; const INITIAL_INSTANT = '2026-07-29T04:00:00.000Z'; @@ -443,4 +449,42 @@ describe('AgentDateChangeService', () => { await runWillBeginStepHooks(loop); expect(dateReminders(context)).toHaveLength(0); }); + + it('withdraws and restores the eager service, provider, and seed with the Feature', async () => { + const manager = ctx.get(IFeatureManager); + const states = ctx.get(IAgentStateService); + updateSystemPromptWithoutDate(profile, ctx.get(ISessionContext).cwd); + + expect(manager.units().find((unit) => unit.name === 'dateChange')?.state).toBe( + FiberState.Active, + ); + expect(ctx.get(IAgentDateChangeService)).toBeDefined(); + expect(states.has(dateChangeSeedKey)).toBe(true); + await runWillBeginStepHooks(loop); + expect(states.get(dateChangeSeedKey)).toMatchObject({ localDate: '2026-07-29' }); + + await manager.unprovideUnit('dateChange'); + expect(() => ctx.get(IAgentDateChangeService)).toThrow(); + expect(states.has(dateChangeSeedKey)).toBe(false); + + clock.set('2026-07-30T04:00:00.000Z'); + await runWillBeginStepHooks(loop); + expect(dateReminders(context)).toHaveLength(0); + + manager.provideUnit(DateChangeFeature); + expect(ctx.get(IAgentDateChangeService)).toBeDefined(); + expect(states.has(dateChangeSeedKey)).toBe(true); + expect(states.get(dateChangeSeedKey)).toBeUndefined(); + + await runWillBeginStepHooks(loop); + expect(dateReminders(context)).toHaveLength(0); + expect(states.get(dateChangeSeedKey)).toMatchObject({ localDate: '2026-07-30' }); + + clock.set('2026-07-31T04:00:00.000Z'); + await runWillBeginStepHooks(loop); + expect(dateReminders(context)).toHaveLength(1); + expect(messageText(dateReminders(context)[0] as ContextMessage)).toContain( + "Today's date is now 2026-07-31", + ); + }); });