diff --git a/src/agent/core/domain/agent-events/types.ts b/src/agent/core/domain/agent-events/types.ts index 80a5a2e9c..c98e5aa67 100644 --- a/src/agent/core/domain/agent-events/types.ts +++ b/src/agent/core/domain/agent-events/types.ts @@ -302,8 +302,10 @@ export interface AgentEventMap { | { accepted: true commandType: string + fromHeuristic: number fromVersion: number projectId: string + toHeuristic: number toVersion: number } diff --git a/src/agent/infra/harness/harness-synthesizer.ts b/src/agent/infra/harness/harness-synthesizer.ts index 4c106f692..a6278fe23 100644 --- a/src/agent/infra/harness/harness-synthesizer.ts +++ b/src/agent/infra/harness/harness-synthesizer.ts @@ -213,8 +213,10 @@ export class HarnessSynthesizer { this.eventBus.emit('harness:refinement-completed', { accepted: true, commandType: parent.commandType, + fromHeuristic: parent.heuristic, fromVersion: parent.version, projectId: parent.projectId, + toHeuristic: candidateHeuristic, toVersion: candidateVersion.version, }) diff --git a/src/agent/infra/session/harness-banner-listener.ts b/src/agent/infra/session/harness-banner-listener.ts new file mode 100644 index 000000000..9aa844539 --- /dev/null +++ b/src/agent/infra/session/harness-banner-listener.ts @@ -0,0 +1,67 @@ +/** + * Listens on `harness:refinement-completed` per session. Buffers + * accepted refinements; on session-end, prints a single banner + * summarising the latest accepted refinement (if any). + * + * Suppression rules: + * - harnessEnabled === false → never print + * - isTty === false → never print + * - No accepted refinement in the session → never print + * - Multiple refinements → print only the last accepted + */ + +import type {AgentEventMap} from '../../core/domain/agent-events/types.js' +import type {AgentEventBus} from '../events/event-emitter.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type AcceptedRefinement = Extract + +export type HarnessBannerListenerOptions = { + readonly eventBus: AgentEventBus + readonly harnessEnabled: boolean + readonly isTty: boolean + readonly writeLine: (s: string) => void +} + +// --------------------------------------------------------------------------- +// HarnessBannerListener +// --------------------------------------------------------------------------- + +export class HarnessBannerListener { + private ended = false + private readonly eventBus: AgentEventBus + private readonly handleEvent = (event: AgentEventMap['harness:refinement-completed']): void => { + if (event.accepted) { + this.lastAccepted = event + } + } + private readonly harnessEnabled: boolean + private readonly isTty: boolean + private lastAccepted: AcceptedRefinement | undefined + private readonly writeLine: (s: string) => void + + constructor(opts: HarnessBannerListenerOptions) { + this.eventBus = opts.eventBus + this.writeLine = opts.writeLine + this.isTty = opts.isTty + this.harnessEnabled = opts.harnessEnabled + this.eventBus.on('harness:refinement-completed', this.handleEvent) + } + + /** Called by SessionManager on session end. Idempotent. */ + onSessionEnd(): void { + if (this.ended) return + this.ended = true + this.eventBus.off('harness:refinement-completed', this.handleEvent) + + if (!this.harnessEnabled || !this.isTty || !this.lastAccepted) return + + const {fromHeuristic, fromVersion, toHeuristic, toVersion} = this.lastAccepted + this.writeLine( + `harness updated: v${fromVersion} → v${toVersion} (H: ${fromHeuristic.toFixed(2)} → ${toHeuristic.toFixed(2)})\n`, + ) + } +} diff --git a/src/agent/infra/session/session-manager.ts b/src/agent/infra/session/session-manager.ts index 33e315f79..3a43c767d 100644 --- a/src/agent/infra/session/session-manager.ts +++ b/src/agent/infra/session/session-manager.ts @@ -11,6 +11,7 @@ import {PolicyEngine} from '../tools/policy-engine.js' import {ToolManager} from '../tools/tool-manager.js' import {ToolProvider} from '../tools/tool-provider.js' import {ChatSession} from './chat-session.js' +import {HarnessBannerListener} from './harness-banner-listener.js' import {generateSessionTitle} from './title-generator.js' /** @@ -47,6 +48,11 @@ export interface SessionMetadata { export type SessionRemovalReason = 'deleted' | 'ended' | 'ttl_expired' export interface SessionManagerOptions { + /** Override banner writeLine + TTY detection for testing. */ + bannerOverrides?: { + isTty?: boolean + writeLine?: (s: string) => void + } config?: SessionManagerConfig /** * Optional lifecycle callback fired after a session is removed from memory maps. @@ -65,6 +71,8 @@ export interface SessionManagerOptions { export class SessionManager { /** Grace window before a session's dedup entry expires (ms). */ private static readonly ENDED_SESSION_GRACE_MS = 60_000 + private readonly bannerListeners = new Map() + private readonly bannerOverrides?: {isTty?: boolean; writeLine?: (s: string) => void} private cleanupTimer?: ReturnType private readonly config: Required private readonly endedSessions = new Set() @@ -138,6 +146,7 @@ export class SessionManager { this.sharedServices = sharedServices this.httpConfig = httpConfig this.llmConfig = llmConfig + this.bannerOverrides = options?.bannerOverrides this.onSessionRemoved = options?.onSessionRemoved this.config = { maxSessions: options?.config?.maxSessions ?? 100, @@ -326,6 +335,8 @@ export class SessionManager { // Remove from memory const deleted = this.sessions.delete(id) if (deleted) { + this.endBannerListener(id) + try { this.onSessionRemoved?.(id, 'deleted') } catch { @@ -358,6 +369,13 @@ export class SessionManager { this.sessions.clear() this.endedSessions.clear() + // Clean up banner listeners (unsubscribe from agent event bus) + for (const listener of this.bannerListeners.values()) { + listener.onSessionEnd() + } + + this.bannerListeners.clear() + // Clear all metadata maps this.sessionCreatedAt.clear() this.sessionLastActivity.clear() @@ -398,6 +416,12 @@ export class SessionManager { // Remove from memory only - history remains in storage const ended = this.sessions.delete(id) if (ended) { + // End the banner listener (prints any refinement captured during this + // session's lifetime from concurrently-running synthesizers). Must happen + // before triggering THIS session's refinement, which fires async — those + // events land after this listener has already unsubscribed. + this.endBannerListener(id) + // Fire harness refinement trigger (fire-and-forget) this.triggerHarnessRefinement(id) @@ -536,6 +560,23 @@ export class SessionManager { return cleaned } + /** + * Create a HarnessBannerListener for a session. Subscribes to the + * agent-level refinement-completed event; `endBannerListener` + * unsubscribes and prints the banner on session end. + */ + private createBannerListener(sessionId: string): void { + const agentBus = this.sharedServices.agentEventBus + if (!agentBus) return + + const writeLine = this.bannerOverrides?.writeLine ?? ((s: string) => process.stderr.write(s)) + const isTty = this.bannerOverrides?.isTty ?? (process.stderr.isTTY ?? false) + const harnessEnabled = this.sharedServices.harnessConfig?.enabled ?? false + + const listener = new HarnessBannerListener({eventBus: agentBus, harnessEnabled, isTty, writeLine}) + this.bannerListeners.set(sessionId, listener) + } + /** * Internal session creation logic. * @@ -562,6 +603,7 @@ export class SessionManager { this.sessionLastActivity.set(id, now) this.sessions.set(id, session) + this.createBannerListener(id) return session } @@ -633,9 +675,21 @@ export class SessionManager { this.sessionLastActivity.set(id, now) this.sessions.set(id, session) + this.createBannerListener(id) return session } + /** + * End the banner listener for a session — prints banner if applicable + * and unsubscribes from the agent event bus. + */ + private endBannerListener(sessionId: string): void { + const listener = this.bannerListeners.get(sessionId) + if (!listener) return + listener.onSessionEnd() + this.bannerListeners.delete(sessionId) + } + /** * Fire the harness refinement pipeline for each command type the session * touched. Fire-and-forget — the synthesizer's per-pair single-flight diff --git a/test/unit/agent/session/harness-banner-listener.test.ts b/test/unit/agent/session/harness-banner-listener.test.ts new file mode 100644 index 000000000..d95f4b2ee --- /dev/null +++ b/test/unit/agent/session/harness-banner-listener.test.ts @@ -0,0 +1,191 @@ +import {expect} from 'chai' +import sinon from 'sinon' + +import {AgentEventBus} from '../../../../src/agent/infra/events/event-emitter.js' +import {HarnessBannerListener} from '../../../../src/agent/infra/session/harness-banner-listener.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAcceptedEvent(overrides: { + commandType?: string + fromHeuristic?: number + fromVersion?: number + projectId?: string + toHeuristic?: number + toVersion?: number +} = {}) { + return { + accepted: true as const, + commandType: overrides.commandType ?? 'curate', + fromHeuristic: overrides.fromHeuristic ?? 0.58, + fromVersion: overrides.fromVersion ?? 3, + projectId: overrides.projectId ?? 'proj-1', + toHeuristic: overrides.toHeuristic ?? 0.64, + toVersion: overrides.toVersion ?? 4, + } +} + +function makeRejectedEvent(overrides: { + commandType?: string + fromVersion?: number + projectId?: string + reason?: string +} = {}) { + return { + accepted: false as const, + commandType: overrides.commandType ?? 'curate', + fromVersion: overrides.fromVersion ?? 3, + projectId: overrides.projectId ?? 'proj-1', + reason: overrides.reason ?? 'delta H was -0.10, below acceptance threshold', + } +} + +function makeListener(eventBus: AgentEventBus, writeLine: sinon.SinonStub, opts?: { + harnessEnabled?: boolean + isTty?: boolean +}) { + return new HarnessBannerListener({ + eventBus, + harnessEnabled: opts?.harnessEnabled ?? true, + isTty: opts?.isTty ?? true, + writeLine, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HarnessBannerListener', () => { + afterEach(() => { + sinon.restore() + }) + + it('prints banner when accepted refinement fires with TTY and enabled', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(1) + expect(writeLine.firstCall.args[0]).to.include('harness updated') + }) + + it('does not print when harness is disabled', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine, {harnessEnabled: false}) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + it('does not print when not a TTY', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine, {isTty: false}) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + it('does not print for rejected refinements', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + eventBus.emit('harness:refinement-completed', makeRejectedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + it('prints only the last accepted refinement when multiple fire', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent({ + fromHeuristic: 0.4, + fromVersion: 1, + toHeuristic: 0.5, + toVersion: 2, + })) + eventBus.emit('harness:refinement-completed', makeAcceptedEvent({ + fromHeuristic: 0.5, + fromVersion: 2, + toHeuristic: 0.64, + toVersion: 3, + })) + + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(1) + const output = writeLine.firstCall.args[0] as string + expect(output).to.include('v2') + expect(output).to.include('v3') + expect(output).to.not.include('v1') + }) + + it('does not print when no refinements occurred', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + it('formats banner as v{from} → v{to} (H: {fromH} → {toH})', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent({ + fromHeuristic: 0.58, + fromVersion: 3, + toHeuristic: 0.64, + toVersion: 4, + })) + + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(1) + const output = writeLine.firstCall.args[0] as string + expect(output).to.equal('harness updated: v3 → v4 (H: 0.58 → 0.64)\n') + }) + + it('does not re-print on second onSessionEnd call', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(1) + }) + + it('stops listening to events after onSessionEnd', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = makeListener(eventBus, writeLine) + + listener.onSessionEnd() + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) +}) diff --git a/test/unit/agent/types/agent-events/types.test.ts b/test/unit/agent/types/agent-events/types.test.ts index a02927847..c45cfbf1a 100644 --- a/test/unit/agent/types/agent-events/types.test.ts +++ b/test/unit/agent/types/agent-events/types.test.ts @@ -453,8 +453,10 @@ describe('cipher/agent-events', () => { const accepted: AgentEventMap['harness:refinement-completed'] = { accepted: true, commandType: 'curate', + fromHeuristic: 0.5, fromVersion: 2, projectId: 'proj-1', + toHeuristic: 0.6, toVersion: 3, } if (accepted.accepted) {