diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 430db652dd9..2009c4a6c5c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 80 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 81 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -55,6 +55,7 @@ // agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts +// agentsMdReminder.pending src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts // contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts @@ -1038,6 +1039,7 @@ export interface AgentStateSnapshot { // src/agent/agentsMdReminder/agentsMdReminderService.ts 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; + 'agentsMdReminder.pending': Set; 'agentsMdReminder.seeded': boolean; // src/agent/contextMemory/contextOps.ts // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 4a7f65c41cd..cc2e3db0ea2 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -26,6 +26,10 @@ import { import { profileKey } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentReminderService } from '#/features/reminder/reminderService'; +import type { + ContextInjectionContext, + ContextInjectionResult, +} from '#/features/reminder/types'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -42,6 +46,10 @@ export const agentsMdReminderKnownKey = defineState>( 'agentsMdReminder.known', () => new Set(), ); +export const agentsMdReminderPendingKey = defineState>( + 'agentsMdReminder.pending', + () => new Set(), +); export const agentsMdReminderCwdKey = defineState( 'agentsMdReminder.cwd', () => undefined as string | undefined, @@ -68,13 +76,18 @@ export class AgentAgentsMdReminderService @IBashParserService private readonly bashParser: IBashParserService, @ITelemetryService private readonly telemetry: ITelemetryService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentStateService private readonly agentState: IAgentStateService, @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, ) { super(); this.states.contributeState(agentsMdReminderKnownKey); + this.states.contributeState(agentsMdReminderPendingKey); this.states.contributeState(agentsMdReminderCwdKey); this.states.contributeState(agentsMdReminderSeededKey); + this._register( + this.reminder.register('agents_md', (context) => + this.injectReminder(context), + ), + ); this._register( this.instructions.onDidChange((changes) => { this.announceChanged(changes); @@ -82,7 +95,7 @@ export class AgentAgentsMdReminderService ); this._register( this.dispatcher.hooks.onDidRestore.register('agentsMdReminder', async (_ctx, next) => { - const profile = this.agentState.get(profileKey); + const profile = this.states.get(profileKey); const paths = profile.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(profile.systemPrompt); this.seedInjected(paths, this.sessionContext.cwd); @@ -97,9 +110,12 @@ export class AgentAgentsMdReminderService } seedInjected(paths: readonly string[], cwd: string): void { - const known = this.states.get(agentsMdReminderKnownKey); + const known = new Set(this.known); for (const path of paths) known.add(normalize(path)); - this.states.set(agentsMdReminderKnownKey, new Set(known)); + this.states.set(agentsMdReminderKnownKey, known); + const pending = new Set(this.pending); + for (const path of paths) pending.delete(normalize(path)); + this.states.set(agentsMdReminderPendingKey, pending); this.states.set(agentsMdReminderCwdKey, cwd); this.states.set(agentsMdReminderSeededKey, true); } @@ -116,8 +132,14 @@ export class AgentAgentsMdReminderService this.reminder.notify(changeReminderText(list), { variant: 'agents_md_change', }); - this.publishKnown( - list.filter((change) => change.action !== 'deleted').map((change) => change.path), + this.markKnown( + list.filter((change) => change.action === 'modified').map((change) => change.path), + ); + this.markPending( + list.filter((change) => change.action === 'created').map((change) => change.path), + ); + this.markDeleted( + list.filter((change) => change.action === 'deleted').map((change) => change.path), ); } @@ -127,10 +149,30 @@ export class AgentAgentsMdReminderService return this.states.get(agentsMdReminderKnownKey); } + private get pending(): Set { + return this.states.get(agentsMdReminderPendingKey); + } + private get agentCwd(): string { return this.states.get(agentsMdReminderCwdKey) ?? this.sessionContext.cwd; } + private hasPath(path: string): boolean { + return this.known.has(path) || this.pending.has(path); + } + + private injectReminder( + context: ContextInjectionContext, + ): ContextInjectionResult | undefined { + const known = this.known; + const pending = [...this.pending].filter((path) => !known.has(path)); + if (pending.length === 0) return undefined; + const covered = context.lastDisclosure ?? []; + const fresh = pending.filter((path) => !covered.includes(path)); + if (fresh.length === 0) return undefined; + return { content: reminderText(fresh), disclosure: pending }; + } + private async ensureSeeded(): Promise { if (this.states.get(agentsMdReminderSeededKey)) return; const lease = this.runtime.acquire(['fs']); @@ -155,13 +197,13 @@ export class AgentAgentsMdReminderService const selfKnownSet = new Set(selfKnown); for (const dir of dirs) { for (const path of await this.probeDir(dir)) { - if (this.known.has(path) || this.claimed.has(path) || selfKnownSet.has(path)) continue; + if (this.hasPath(path) || this.claimed.has(path) || selfKnownSet.has(path)) continue; this.claimed.add(path); discovered.push(path); } } if (discovered.length === 0) { - this.publishKnown(selfKnown); + this.markKnown(selfKnown); return; } const properties: AgentsMdReminderShownEvent = { @@ -171,20 +213,45 @@ export class AgentAgentsMdReminderService trace_id: ctx.trace?.traceId, }; this.telemetry.track2('agents_md_reminder_shown', properties); - this.reminder.notify(reminderText(discovered), { - variant: 'agents_md', - }); - this.publishKnown([...selfKnown, ...discovered]); + this.markKnown(selfKnown); + this.markPending(discovered); } catch {} finally { for (const path of discovered) this.claimed.delete(path); } } - private publishKnown(paths: readonly string[]): void { + private markKnown(paths: readonly string[]): void { + if (paths.length === 0) return; + const known = new Set(this.known); + const pending = new Set(this.pending); + for (const path of paths) { + known.add(path); + pending.delete(path); + } + this.states.set(agentsMdReminderKnownKey, known); + this.states.set(agentsMdReminderPendingKey, pending); + } + + private markPending(paths: readonly string[]): void { if (paths.length === 0) return; - const merged = new Set(this.known); - for (const path of paths) merged.add(path); - this.states.set(agentsMdReminderKnownKey, merged); + const known = this.known; + const pending = new Set(this.pending); + for (const path of paths) { + if (!known.has(path)) pending.add(path); + } + this.states.set(agentsMdReminderPendingKey, pending); + } + + private markDeleted(paths: readonly string[]): void { + if (paths.length === 0) return; + const known = new Set(this.known); + const pending = new Set(this.pending); + for (const path of paths) { + known.delete(path); + pending.delete(path); + } + this.states.set(agentsMdReminderKnownKey, known); + this.states.set(agentsMdReminderPendingKey, pending); } private targetDirs(ctx: ToolDidExecuteContext): { dirs: string[]; selfKnown: string[] } { @@ -272,7 +339,7 @@ export class AgentAgentsMdReminderService const found: string[] = []; for (const chainDir of chain) { const candidates = agentsMdCandidatePaths(chainDir); - if (candidates.every((candidate) => this.known.has(normalize(candidate)))) continue; + if (candidates.every((candidate) => this.hasPath(normalize(candidate)))) continue; for (const path of await findAgentsMdInDir(deps, chainDir)) { found.push(normalize(path)); } @@ -309,7 +376,7 @@ function reminderText(paths: readonly string[]): string { return ( 'The path(s) touched by a recent tool call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + paths.map((path) => `- ${path}`).join('\n') + - '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' + '\nRead them before making changes in those directories. Files may be suggested again after context compaction unless you have read them.' ); } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index b01eaf6a69a..b17a4a15aa4 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -942,10 +942,10 @@ export const telemetryEventDefinitions = { }), agents_md_reminder_shown: defineAgentTelemetryEvent({ owner: 'kimi-code', - comment: 'An AGENTS.md discovery reminder is appended to a tool result.', + comment: 'An AGENTS.md discovery reminder is queued for context injection after a tool call.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', - tool_name: 'Registered tool name whose result carried the reminder', + tool_name: 'Registered tool name whose execution discovered the file', reminded_count: 'Number of AGENTS.md paths listed in the reminder', trace_id: 'Trace id of the LLM request that produced the tool call; absent for non-Kimi protocols', diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index ad5dfae578b..a4707548f8a 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -45,21 +45,23 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { AgentToolDedupeService } from '#/agent/toolDedupe/toolDedupeService'; -import type { PromptOrigin } from '#/agent/contextMemory/types'; +import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentReminderService } from '#/features/reminder/reminderService'; -import { createReminderStub } from '../../features/reminder/stubs'; +import { createReminderHarness } from '../../features/reminder/stubs'; import { OrderedHookSlot } from '#/hooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { AgentAgentsMdReminderService, - agentsMdReminderKnownKey, + agentsMdReminderPendingKey, } from '#/agent/agentsMdReminder/agentsMdReminderService'; import { extractBashTargetDirs } from '#/agent/agentsMdReminder/bashTargets'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; -import { stubLoopWithHooks } from '../loop/stubs'; +import { runWillBeginStepHooks, stubLoopWithHooks, type StubLoop } from '../loop/stubs'; +import { stubContextMemory, type StubContextMemory } from '../contextMemory/stubs'; import { registerLogServices } from '../../_base/log/stubs'; import { stubAgentContext } from '../agentContext/stubs'; @@ -90,9 +92,12 @@ interface Harness { readonly events: ToolExecutorEventStubs; readonly reminder: IAgentAgentsMdReminderService; readonly dispatcher: IEventDispatcher; + readonly loop: StubLoop; + readonly context: StubContextMemory; readonly telemetryEvents: TelemetryRecord[]; readonly reminders: CapturedReminder[]; readonly instructionsChange: Emitter; + step(): Promise; } function createHarness( @@ -113,6 +118,9 @@ function createHarness( const reminders: CapturedReminder[] = []; const events = stubToolExecutorEvents(); const instructionsChange = disposables.add(new Emitter()); + const loop = stubLoopWithHooks(); + const context = stubContextMemory(); + const reminderRuntime = createReminderHarness(loop, context); const ix = createServices(disposables, { additionalServices: (reg) => { if (options.withRealExecutor === true) { @@ -155,12 +163,14 @@ function createHarness( reg.defineInstance(IAgentStateService, agentState); reg.defineInstance( IAgentReminderService, - createReminderStub({ - notify: (content, notification) => { + Object.assign(reminderRuntime, { + notify: (content: string, notification: { variant: string }) => { reminders.push({ content, origin: { kind: 'injection', ...notification } }); }, }), ); + reg.defineInstance(IAgentLoopService, loop); + reg.defineInstance(IAgentContextMemoryService, context); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', @@ -223,7 +233,6 @@ function createHarness( options.telemetry ?? recordingTelemetry(telemetryEvents), ); if (options.withDedupe === true) { - reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); reg.define(IAgentToolDedupeService, AgentToolDedupeService); } reg.define(IAgentAgentsMdReminderService, AgentAgentsMdReminderService); @@ -232,7 +241,19 @@ function createHarness( }); const reminder = ix.get(IAgentAgentsMdReminderService); const dispatcher = ix.get(IEventDispatcher); - return { ix, events, reminder, dispatcher, telemetryEvents, reminders, instructionsChange }; + const step = (): Promise => runWillBeginStepHooks(loop); + return { + ix, + events, + reminder, + dispatcher, + loop, + context, + telemetryEvents, + reminders, + instructionsChange, + step, + }; } function didCtx( @@ -281,6 +302,7 @@ function testAccesses(name: string, args: unknown): ToolAccessesType | undefined async function fire(h: Harness, ctx: ToolDidExecuteContext): Promise { await h.events.didExecuteSlot.run(ctx); + await h.step(); return ctx.result; } @@ -293,8 +315,18 @@ function outputText(result: ExecutableToolResult): string { .join(''); } +function agentsMdMessages(h: Harness): readonly ContextMessage[] { + return h.context.messages.filter( + (message) => message.origin?.kind === 'injection' && message.origin.variant === 'agents_md', + ); +} + +function messageText(message: ContextMessage): string { + return message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join(''); +} + function reminderText(h: Harness): string { - return h.reminders.map((entry) => entry.content).join('\n'); + return agentsMdMessages(h).map(messageText).join('\n'); } async function writeAgentsMd(dir: string, content = 'instructions'): Promise { @@ -339,7 +371,7 @@ describe('agentsMdReminder instructions change announcements', () => { expect(h.reminders).toHaveLength(0); }); - it('adds announced paths to the known set so discovery does not repeat them', async () => { + it('adds announced created paths to the pending set so the reminder re-injects until read', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); h.reminder.seedInjected([], workDir); @@ -347,8 +379,8 @@ describe('agentsMdReminder instructions change announcements', () => { h.instructionsChange.fire([{ path: rootAgentsMd, action: 'created', kind: 'file' }]); expect(h.reminders).toHaveLength(1); - const known = h.ix.get(IAgentStateService).get(agentsMdReminderKnownKey); - expect(known.has(normalize(rootAgentsMd))).toBe(true); + const pending = h.ix.get(IAgentStateService).get(agentsMdReminderPendingKey); + expect(pending.has(normalize(rootAgentsMd))).toBe(true); }); }); @@ -363,12 +395,14 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(1); - expect(h.reminders[0]?.origin).toEqual({ kind: 'injection', variant: 'agents_md' }); - expect(h.reminders[0]?.content.startsWith('The path(s) touched by a recent tool call')).toBe( - true, + expect(agentsMdMessages(h)).toHaveLength(1); + expect(agentsMdMessages(h)[0]?.origin).toMatchObject({ + kind: 'injection', + variant: 'agents_md', + }); + expect(messageText(agentsMdMessages(h)[0]!)).toContain( + 'The path(s) touched by a recent tool call', ); - expect(h.reminders[0]?.content).not.toContain(''); const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); @@ -384,7 +418,7 @@ describe('agentsMdReminder path-carrying tools', () => { expect(outputText(first)).toBe('original result'); expect(outputText(second)).toBe('original result'); - expect(h.reminders).toHaveLength(1); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -395,11 +429,11 @@ describe('agentsMdReminder path-carrying tools', () => { const direct = await fire(h, didCtx('Read', { path: subAgentsMd })); expect(outputText(direct)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -439,7 +473,7 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Glob', { pattern: '**/*.ts' })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); it('tracks the shown event through telemetry', async () => { @@ -459,6 +493,165 @@ describe('agentsMdReminder path-carrying tools', () => { }); }); +describe('agentsMdReminder re-injection after context loss', () => { + function compact(h: Harness): void { + h.context.applyCompaction({ + summary: 'compaction summary', + compactedCount: 1, + tokensBefore: 100, + }); + } + + it('re-reminds a pending path after compaction drops the reminder', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + + compact(h); + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(subDir, 'other.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + }); + + it('keeps directly-read paths silent across compaction', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: subAgentsMd })); + expect(agentsMdMessages(h)).toHaveLength(0); + + compact(h); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(0); + }); + + it('keeps injected paths silent across compaction', async () => { + const h = createHarness(); + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + h.reminder.seedInjected([rootAgentsMd], workDir); + + compact(h); + + await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(0); + }); + + it('re-reminds a pending path after a full clear', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + + h.context.clear(); + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(subDir, 'other.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + }); + + it('drops a pending path when the file is deleted, so it is not re-reminded', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + + await rm(subAgentsMd); + h.instructionsChange.fire([{ path: subAgentsMd, action: 'deleted', kind: 'file' }]); + compact(h); + + await fire(h, didCtx('Read', { path: join(subDir, 'other.ts') })); + expect(agentsMdMessages(h)).toHaveLength(0); + }); + + it('re-reminds a pending path after an undo removes the reminder', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + h.context.append({ + role: 'user', + content: [{ type: 'text', text: 'prompt' }], + toolCalls: [], + }); + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + + h.context.undo(1); + expect(agentsMdMessages(h)).toHaveLength(0); + + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + }); + + it('does not re-inject while the reminder is still in context', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + await h.step(); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(1); + }); + + it('injects only newly discovered paths while an earlier reminder is in context', async () => { + const h = createHarness(); + const dirA = join(workDir, 'packages', 'a'); + const dirB = join(workDir, 'packages', 'b'); + const agentsMdA = await writeAgentsMd(dirA, 'instructions a'); + const agentsMdB = await writeAgentsMd(dirB, 'instructions b'); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(dirA, 'index.ts') })); + await fire(h, didCtx('Read', { path: join(dirB, 'index.ts') })); + + const messages = agentsMdMessages(h); + expect(messages).toHaveLength(2); + expect(messageText(messages[0]!)).toContain(agentsMdA); + expect(messageText(messages[1]!)).toContain(agentsMdB); + expect(messageText(messages[1]!)).not.toContain(agentsMdA); + }); + + it('re-reminds a created-and-announced path after compaction', async () => { + const h = createHarness(); + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + h.reminder.seedInjected([], workDir); + + h.instructionsChange.fire([{ path: rootAgentsMd, action: 'created', kind: 'file' }]); + expect(h.reminders).toHaveLength(1); + + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); + + compact(h); + expect(agentsMdMessages(h)).toHaveLength(0); + + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(rootAgentsMd); + }); +}); + describe('agentsMdReminder Bash coverage', () => { it('reminds for the directory listed by a plain ls', async () => { const h = createHarness(); @@ -524,7 +717,7 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command })); expect(outputText(result)).toBe('original result'); } - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); }); @@ -543,7 +736,7 @@ describe('agentsMdReminder result shapes and edge cases', () => { ); expect(result.output).toEqual([{ type: 'text', text: 'part one' }]); - expect(h.reminders).toHaveLength(1); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -557,7 +750,7 @@ describe('agentsMdReminder result shapes and edge cases', () => { didCtx('Read', { path: agentsMdPath }, { result: { output: 'not found', isError: true } }), ); expect(outputText(failed)).toBe('not found'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); @@ -577,7 +770,7 @@ describe('agentsMdReminder duplicate calls', () => { expect(outputText(first)).toBe('original result'); expect(outputText(second)).toBe('original result'); - expect(h.reminders).toHaveLength(1); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -616,7 +809,8 @@ describe('agentsMdReminder duplicate calls', () => { for (const item of results) { expect(outputText(item.result)).toBe('file contents'); } - expect(h.reminders).toHaveLength(1); + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); const shown = h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown'); expect(shown).toHaveLength(1); @@ -647,7 +841,7 @@ describe('agentsMdReminder lazy seeding after a restore', () => { const result = await fire(h, didCtx('Read', { path: join(homeDir, 'notes.txt') })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); }); @@ -683,7 +877,7 @@ describe('agentsMdReminder persisted restore provenance', () => { const result = await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); }); @@ -725,7 +919,7 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); it('still reminds when the triggering call ended in an error result', async () => { @@ -752,11 +946,11 @@ describe('agentsMdReminder probing boundaries', () => { const written = await fire(h, didCtx('Write', { path: agentsMdPath, content: 'x' })); expect(outputText(written)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -771,7 +965,7 @@ describe('agentsMdReminder probing boundaries', () => { expect(outputText(first)).toBe('original result'); expect(outputText(second)).toBe('original result'); - expect(h.reminders).toHaveLength(1); + expect(agentsMdMessages(h)).toHaveLength(1); }); it('re-judges the project root at a nested repository', async () => { @@ -839,7 +1033,7 @@ describe('agentsMdReminder round-2 hardening', () => { ); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); @@ -852,7 +1046,7 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Bash', { command: 'true' })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); const listed = await fire(h, didCtx('Bash', { command: 'ls packages' })); expect(outputText(listed)).toBe('original result'); @@ -868,7 +1062,7 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); it('keeps known-sets isolated between agents', async () => { @@ -882,8 +1076,8 @@ describe('agentsMdReminder round-2 hardening', () => { expect(outputText(firstResult)).toBe('original result'); expect(outputText(secondResult)).toBe('original result'); - expect(first.reminders).toHaveLength(1); - expect(second.reminders).toHaveLength(1); + expect(agentsMdMessages(first)).toHaveLength(1); + expect(agentsMdMessages(second)).toHaveLength(1); expect(reminderText(first)).toContain(subAgentsMd); expect(reminderText(second)).toContain(subAgentsMd); }); @@ -902,12 +1096,12 @@ describe('agentsMdReminder round-2 hardening', () => { const failed = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); expect(outputText(failed)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); shouldThrow = false; const retried = await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); expect(outputText(retried)).toBe('original result'); - expect(h.reminders).toHaveLength(1); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -949,7 +1143,8 @@ describe('agentsMdReminder round-2 hardening', () => { expect(text).toContain('output_path:'); expect(text).not.toContain(''); expect(text).not.toContain(subAgentsMd); - expect(h.reminders).toHaveLength(1); + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -990,7 +1185,8 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('home file contents'); - expect(h.reminders).toHaveLength(1); + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(homeAgentsMd); }); @@ -1039,7 +1235,8 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('permission denied'); - expect(h.reminders).toHaveLength(0); + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(0); expect(stat).not.toHaveBeenCalled(); expect(readText).not.toHaveBeenCalled(); expect( @@ -1129,7 +1326,8 @@ describe('agentsMdReminder cancellation outcomes', () => { const results = await pending; const queued = results.find((item) => item.toolCallId === 'call-queued-read'); expect(queued).toBeDefined(); - expect(h.reminders).toHaveLength(0); + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(0); expect( h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), ).toEqual([]); @@ -1152,7 +1350,8 @@ describe('agentsMdReminder cancellation outcomes', () => { real.push(item); } expect(outputText(real[0]!.result)).toBe('read result'); - expect(h.reminders).toHaveLength(1); + await h.step(); + expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -1178,7 +1377,7 @@ describe('agentsMdReminder Bash parse degradation', () => { const result = await fire(h, didCtx('Bash', { command: "ls '" })); expect(outputText(result)).toBe('original result'); - expect(h.reminders).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(0); }); });