diff --git a/docs/plans/2026-07-24-overseer-summary-emission.md b/docs/plans/2026-07-24-overseer-summary-emission.md index 8dcf7d078d..a80a3c4a45 100644 --- a/docs/plans/2026-07-24-overseer-summary-emission.md +++ b/docs/plans/2026-07-24-overseer-summary-emission.md @@ -1,9 +1,15 @@ # Overseer summary emission (Half B) — 2026-07-24 -Status: in progress +Status: both pieces implemented + tested (awaiting operator `hapi-restart-hub` + soup) Owner: feat/overseer-summary-emit (peer of 🔁overseer prep) Scope: FORK-ONLY. Never upstream. The whole overseer feature is fork-private. +- Piece 1 — `feat/overseer-summary-emit` (fork PR #86): CLI Cursor rule overlay. + 11 overlay tests + 19 launcher tests green; typecheck clean. +- Piece 2 — `feat/overseer-summary-fallback` (stacked on Piece 1): hub per-turn + backstop in `overseerEventRecorder`. 6 fallback tests + full hub suite (524) + green; typecheck clean. + ## Why this exists (the real WHY — keep it here, not in product code) The overseer/inbox/session-log is fed by `AGENT_NOTIFY_SUMMARY` lines that agents diff --git a/hub/src/sync/overseerEventRecorder.fallback.test.ts b/hub/src/sync/overseerEventRecorder.fallback.test.ts new file mode 100644 index 0000000000..67bb632733 --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.fallback.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '@hapi/protocol/types' +import { Store } from '../store' +import { OverseerEventRecorder, toSessionSnapshot } from './overseerEventRecorder' + +function makeSession(id: string, flavor: string, overrides?: Partial): Session { + return { + id, + namespace: 'default', + seq: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + active: true, + activeAt: Date.now(), + metadata: { flavor, path: '/tmp', host: 'local' }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + ...overrides + } +} + +function agentText(message: string) { + return { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message } } + } +} + +describe('OverseerEventRecorder turn fallback', () => { + it('synthesizes a session-log-only progress event when no summary line', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('cur', { flavor: 'cursor', path: '/tmp', host: 'local' }, null, 'default') + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'cursor'), session.tag), + 'msg-fb', + agentText('Refactored the parser and added tests.\n\nMore detail here.'), + Date.now() + ) + + expect(event).not.toBeNull() + expect(event?.eventType).toBe('progress') + expect(event?.attentionCandidate).toBe(0) + expect(event?.operatorActionRequired).toBe(0) + expect(event?.summary).toBe('Refactored the parser and added tests.') + expect(event?.provenance).toContain('hub-synthesized') + + const payload = JSON.parse(event!.payloadJson!) as { synthesized?: boolean } + expect(payload.synthesized).toBe(true) + + // Session log gets it; the attention inbox stays empty. + expect(store.events.count()).toBe(1) + expect(store.inbox.count()).toBe(0) + }) + + it('does not synthesize when a real AGENT_NOTIFY_SUMMARY is present', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('cur2', { flavor: 'cursor', path: '/tmp', host: 'local' }, null, 'default') + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'cursor'), session.tag), + 'msg-real', + agentText('All done.\nAGENT_NOTIFY_SUMMARY {"version":1,"status":"done","action":"Review PR","summary":"Shipped"}'), + Date.now() + ) + + expect(event?.provenance).toBe('AGENT_NOTIFY_SUMMARY') + expect(store.events.list({ eventType: 'progress' })).toHaveLength(0) + expect(store.events.count()).toBe(1) + }) + + it('does not synthesize when the summary line is malformed (validation_error wins)', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('cur3', { flavor: 'cursor', path: '/tmp', host: 'local' }, null, 'default') + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'cursor'), session.tag), + 'msg-bad', + agentText('Working.\nAGENT_NOTIFY_SUMMARY {not valid json'), + Date.now() + ) + + expect(event?.eventType).toBe('validation_error') + expect(store.events.list({ eventType: 'progress' })).toHaveLength(0) + }) + + it('is idempotent for a redelivered message id', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('cur4', { flavor: 'cursor', path: '/tmp', host: 'local' }, null, 'default') + const snapshot = toSessionSnapshot(makeSession(session.id, 'cursor'), session.tag) + + recorder.onAgentMessage(snapshot, 'msg-dup', agentText('Progress update.'), Date.now()) + recorder.onAgentMessage(snapshot, 'msg-dup', agentText('Progress update.'), Date.now()) + + expect(store.events.list({ eventType: 'progress' })).toHaveLength(1) + }) + + it('caps a long first line with an ellipsis', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('cur5', { flavor: 'cursor', path: '/tmp', host: 'local' }, null, 'default') + + const longLine = 'x'.repeat(500) + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'cursor'), session.tag), + 'msg-long', + agentText(longLine), + Date.now() + ) + + expect(event?.summary?.length).toBe(200) + expect(event?.summary?.endsWith('\u2026')).toBe(true) + }) + + it('ignores user messages', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('cur6', { flavor: 'cursor', path: '/tmp', host: 'local' }, null, 'default') + + const userContent = { role: 'user', content: { type: 'text', text: 'do the thing' } } + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'cursor'), session.tag), + 'msg-user', + userContent, + Date.now() + ) + + expect(event).toBeNull() + expect(store.events.list({ eventType: 'progress' })).toHaveLength(0) + }) +}) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts index b0e884a447..2030a9d9ad 100644 --- a/hub/src/sync/overseerEventRecorder.ts +++ b/hub/src/sync/overseerEventRecorder.ts @@ -130,6 +130,20 @@ function extractToolFailureSummary(content: unknown): string | null { return null } +const TURN_FALLBACK_SUMMARY_MAX = 200 + +/** First non-empty, trimmed line of text, capped for a one-line summary. */ +function firstNonEmptyLine(text: string): string | null { + for (const rawLine of text.split('\n')) { + const line = rawLine.trim() + if (line.length === 0) continue + return line.length > TURN_FALLBACK_SUMMARY_MAX + ? `${line.slice(0, TURN_FALLBACK_SUMMARY_MAX - 1)}\u2026` + : line + } + return null +} + function buildTags(notify: NotifySummary | null, flavor: string): string | null { const parts: string[] = [] if (notify?.agent) parts.push(`agent:${notify.agent}`) @@ -220,6 +234,16 @@ export class OverseerEventRecorder { }) } } + + // Deterministic backstop: an agent produced visible text but no + // AGENT_NOTIFY_SUMMARY (rule compliance can never be 100%). Synthesize + // a minimal, session-log-only capture so the overseer never has a + // fully blind agent turn. No LLM; attention stays 0 so the inbox is + // untouched. Marked hub-synthesized so it is never mistaken for a + // real self-report. + if (!primary && plainText) { + primary = this.synthesizeTurnFallback(session, messageId, plainText, ts) + } } // Always scoop URLs from any ingestible message text (agent or user). @@ -321,6 +345,42 @@ export class OverseerEventRecorder { return emitted } + /** + * Minimal per-turn fallback event when no AGENT_NOTIFY_SUMMARY was emitted. + * + * Summary is the first non-empty line of the assistant text (deterministic, + * no LLM). Status defaults to `progress` via the empty-status mapping, and + * attention stays 0, so these land in the Session Log only — never the + * attention inbox. This is the safety net under the Cursor rule overlay: + * even a dropped summary line yields a captured turn. + */ + private synthesizeTurnFallback( + session: SessionSnapshot, + messageId: string, + plainText: string, + ts: number + ): StoredSystemEvent | null { + const summary = firstNonEmptyLine(plainText) + if (!summary) return null + + const eventType = mapNotifyStatusToEventType(undefined) + return this.insertSystemEvent(session, { + ts, + sourceKind: 'system', + sourceRef: session.id, + eventType, + attentionCandidate: 0, + operatorActionRequired: 0, + summary, + relatedSessionId: session.id, + provenance: 'hub-synthesized from assistant text (no AGENT_NOTIFY_SUMMARY)', + idempotencyKey: `session:${session.id}:message:${messageId}:turn_fallback`, + payloadFields: { messageId, synthesized: true }, + severity: deriveSeverity(eventType), + tags: buildTags(null, session.flavor) + }) + } + private recordNotifySummary( session: SessionSnapshot, messageId: string,