From a4bd911b651c83eb1db8fdbeaaa361b07df79115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Thu=E1=BA=ADn=20Ph=C3=A1t?= Date: Thu, 23 Apr 2026 12:00:13 +0700 Subject: [PATCH 1/2] feat: [ENG-2326] session-end banner for accepted harness refinements --- src/agent/core/domain/agent-events/types.ts | 2 + .../infra/harness/harness-synthesizer.ts | 2 + .../infra/session/harness-banner-listener.ts | 65 ++++++ src/agent/infra/session/session-manager.ts | 51 +++++ .../session/harness-banner-listener.test.ts | 189 ++++++++++++++++++ .../agent/types/agent-events/types.test.ts | 2 + 6 files changed, 311 insertions(+) create mode 100644 src/agent/infra/session/harness-banner-listener.ts create mode 100644 test/unit/agent/session/harness-banner-listener.test.ts 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..478cf049b --- /dev/null +++ b/src/agent/infra/session/harness-banner-listener.ts @@ -0,0 +1,65 @@ +/** + * 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 + +// --------------------------------------------------------------------------- +// 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( + eventBus: AgentEventBus, + writeLine: (s: string) => void, + isTty: boolean, + harnessEnabled: boolean, + ) { + this.eventBus = eventBus + this.writeLine = writeLine + this.isTty = isTty + this.harnessEnabled = 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..bc751628a 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,9 @@ export class SessionManager { // Remove from memory only - history remains in storage const ended = this.sessions.delete(id) if (ended) { + // Print harness banner before refinement trigger fires + this.endBannerListener(id) + // Fire harness refinement trigger (fire-and-forget) this.triggerHarnessRefinement(id) @@ -536,6 +557,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(agentBus, writeLine, isTty, harnessEnabled) + this.bannerListeners.set(sessionId, listener) + } + /** * Internal session creation logic. * @@ -562,6 +600,7 @@ export class SessionManager { this.sessionLastActivity.set(id, now) this.sessions.set(id, session) + this.createBannerListener(id) return session } @@ -633,9 +672,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..51456fc70 --- /dev/null +++ b/test/unit/agent/session/harness-banner-listener.test.ts @@ -0,0 +1,189 @@ +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', + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HarnessBannerListener', () => { + afterEach(() => { + sinon.restore() + }) + + // Test 1: Accepted refinement + TTY + enabled → banner prints + it('prints banner when accepted refinement fires with TTY and enabled', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(1) + expect(writeLine.firstCall.args[0]).to.include('harness updated') + }) + + // Test 2: Accepted refinement + TTY + disabled → no print + it('does not print when harness is disabled', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, false) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + // Test 3: Accepted refinement + not TTY → no print + it('does not print when not a TTY', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, false, true) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + // Test 4: Rejected refinement → no print + it('does not print for rejected refinements', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + eventBus.emit('harness:refinement-completed', makeRejectedEvent()) + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + // Test 5: Multiple accepteds → only last one prints + it('prints only the last accepted refinement when multiple fire', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + 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') + }) + + // Test 6: Zero refinements → no print + it('does not print when no refinements occurred', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(0) + }) + + // Test 7: Banner format matches spec + it('formats banner as v{from} → v{to} (H: {fromH} → {toH})', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + 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') + }) + + // Test 8: onSessionEnd is idempotent — second call does not re-print + it('does not re-print on second onSessionEnd call', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) + listener.onSessionEnd() + listener.onSessionEnd() + + expect(writeLine.callCount).to.equal(1) + }) + + // Test 9: Listener unsubscribes on session end — events after don't accumulate + it('stops listening to events after onSessionEnd', () => { + const eventBus = new AgentEventBus() + const writeLine = sinon.stub() + const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + + listener.onSessionEnd() + + // Event after session end should not be captured + 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) { From 31b4b5938bfb8876278048db38684de9c4c5442b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Thu=E1=BA=ADn=20Ph=C3=A1t?= Date: Thu, 23 Apr 2026 12:10:00 +0700 Subject: [PATCH 2/2] refactor: [ENG-2326] address review-agent feedback on banner listener --- .../infra/session/harness-banner-listener.ts | 22 +++++----- src/agent/infra/session/session-manager.ts | 7 +++- .../session/harness-banner-listener.test.ts | 40 ++++++++++--------- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/agent/infra/session/harness-banner-listener.ts b/src/agent/infra/session/harness-banner-listener.ts index 478cf049b..9aa844539 100644 --- a/src/agent/infra/session/harness-banner-listener.ts +++ b/src/agent/infra/session/harness-banner-listener.ts @@ -19,6 +19,13 @@ import type {AgentEventBus} from '../events/event-emitter.js' type AcceptedRefinement = Extract +export type HarnessBannerListenerOptions = { + readonly eventBus: AgentEventBus + readonly harnessEnabled: boolean + readonly isTty: boolean + readonly writeLine: (s: string) => void +} + // --------------------------------------------------------------------------- // HarnessBannerListener // --------------------------------------------------------------------------- @@ -36,16 +43,11 @@ export class HarnessBannerListener { private lastAccepted: AcceptedRefinement | undefined private readonly writeLine: (s: string) => void - constructor( - eventBus: AgentEventBus, - writeLine: (s: string) => void, - isTty: boolean, - harnessEnabled: boolean, - ) { - this.eventBus = eventBus - this.writeLine = writeLine - this.isTty = isTty - this.harnessEnabled = harnessEnabled + 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) } diff --git a/src/agent/infra/session/session-manager.ts b/src/agent/infra/session/session-manager.ts index bc751628a..3a43c767d 100644 --- a/src/agent/infra/session/session-manager.ts +++ b/src/agent/infra/session/session-manager.ts @@ -416,7 +416,10 @@ export class SessionManager { // Remove from memory only - history remains in storage const ended = this.sessions.delete(id) if (ended) { - // Print harness banner before refinement trigger fires + // 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) @@ -570,7 +573,7 @@ export class SessionManager { const isTty = this.bannerOverrides?.isTty ?? (process.stderr.isTTY ?? false) const harnessEnabled = this.sharedServices.harnessConfig?.enabled ?? false - const listener = new HarnessBannerListener(agentBus, writeLine, isTty, harnessEnabled) + const listener = new HarnessBannerListener({eventBus: agentBus, harnessEnabled, isTty, writeLine}) this.bannerListeners.set(sessionId, listener) } diff --git a/test/unit/agent/session/harness-banner-listener.test.ts b/test/unit/agent/session/harness-banner-listener.test.ts index 51456fc70..d95f4b2ee 100644 --- a/test/unit/agent/session/harness-banner-listener.test.ts +++ b/test/unit/agent/session/harness-banner-listener.test.ts @@ -42,6 +42,18 @@ function makeRejectedEvent(overrides: { } } +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 // --------------------------------------------------------------------------- @@ -51,11 +63,10 @@ describe('HarnessBannerListener', () => { sinon.restore() }) - // Test 1: Accepted refinement + TTY + enabled → banner prints it('prints banner when accepted refinement fires with TTY and enabled', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) listener.onSessionEnd() @@ -64,11 +75,10 @@ describe('HarnessBannerListener', () => { expect(writeLine.firstCall.args[0]).to.include('harness updated') }) - // Test 2: Accepted refinement + TTY + disabled → no print it('does not print when harness is disabled', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, false) + const listener = makeListener(eventBus, writeLine, {harnessEnabled: false}) eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) listener.onSessionEnd() @@ -76,11 +86,10 @@ describe('HarnessBannerListener', () => { expect(writeLine.callCount).to.equal(0) }) - // Test 3: Accepted refinement + not TTY → no print it('does not print when not a TTY', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, false, true) + const listener = makeListener(eventBus, writeLine, {isTty: false}) eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) listener.onSessionEnd() @@ -88,11 +97,10 @@ describe('HarnessBannerListener', () => { expect(writeLine.callCount).to.equal(0) }) - // Test 4: Rejected refinement → no print it('does not print for rejected refinements', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) eventBus.emit('harness:refinement-completed', makeRejectedEvent()) listener.onSessionEnd() @@ -100,11 +108,10 @@ describe('HarnessBannerListener', () => { expect(writeLine.callCount).to.equal(0) }) - // Test 5: Multiple accepteds → only last one prints it('prints only the last accepted refinement when multiple fire', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) eventBus.emit('harness:refinement-completed', makeAcceptedEvent({ fromHeuristic: 0.4, @@ -128,22 +135,20 @@ describe('HarnessBannerListener', () => { expect(output).to.not.include('v1') }) - // Test 6: Zero refinements → no print it('does not print when no refinements occurred', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) listener.onSessionEnd() expect(writeLine.callCount).to.equal(0) }) - // Test 7: Banner format matches spec it('formats banner as v{from} → v{to} (H: {fromH} → {toH})', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) eventBus.emit('harness:refinement-completed', makeAcceptedEvent({ fromHeuristic: 0.58, @@ -159,11 +164,10 @@ describe('HarnessBannerListener', () => { expect(output).to.equal('harness updated: v3 → v4 (H: 0.58 → 0.64)\n') }) - // Test 8: onSessionEnd is idempotent — second call does not re-print it('does not re-print on second onSessionEnd call', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) listener.onSessionEnd() @@ -172,15 +176,13 @@ describe('HarnessBannerListener', () => { expect(writeLine.callCount).to.equal(1) }) - // Test 9: Listener unsubscribes on session end — events after don't accumulate it('stops listening to events after onSessionEnd', () => { const eventBus = new AgentEventBus() const writeLine = sinon.stub() - const listener = new HarnessBannerListener(eventBus, writeLine, true, true) + const listener = makeListener(eventBus, writeLine) listener.onSessionEnd() - // Event after session end should not be captured eventBus.emit('harness:refinement-completed', makeAcceptedEvent()) listener.onSessionEnd()