diff --git a/apps/mobile/src/components/agents/interaction-surface.test.ts b/apps/mobile/src/components/agents/interaction-surface.test.ts new file mode 100644 index 0000000000..eb6343c2cb --- /dev/null +++ b/apps/mobile/src/components/agents/interaction-surface.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { hasActiveInteraction, pickActiveInteractionSurface } from './interaction-surface'; + +describe('pickActiveInteractionSurface', () => { + it('returns none when no interaction is active', () => { + expect( + pickActiveInteractionSurface({ + activeQuestion: null, + activePermission: null, + activeSuggestion: null, + }) + ).toEqual({ kind: 'none' }); + }); + + it('prefers question over permission and suggestion', () => { + expect( + pickActiveInteractionSurface({ + activeQuestion: { requestId: 'q-1' }, + activePermission: { requestId: 'p-1' }, + activeSuggestion: { requestId: 's-1' }, + }) + ).toEqual({ kind: 'question' }); + }); + + it('prefers permission over suggestion', () => { + expect( + pickActiveInteractionSurface({ + activeQuestion: null, + activePermission: { requestId: 'p-1' }, + activeSuggestion: { requestId: 's-1' }, + }) + ).toEqual({ kind: 'permission' }); + }); + + it('returns suggestion when no question or permission is active', () => { + expect( + pickActiveInteractionSurface({ + activeQuestion: null, + activePermission: null, + activeSuggestion: { requestId: 's-1' }, + }) + ).toEqual({ kind: 'suggestion' }); + }); +}); + +describe('hasActiveInteraction', () => { + it('is true when only suggestion is active so the composer is hidden', () => { + expect( + hasActiveInteraction({ + activeQuestion: null, + activePermission: null, + activeSuggestion: { requestId: 's-1' }, + }) + ).toBe(true); + }); + + it('is false when no interaction is active so the composer can render', () => { + expect( + hasActiveInteraction({ + activeQuestion: null, + activePermission: null, + activeSuggestion: null, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/interaction-surface.ts b/apps/mobile/src/components/agents/interaction-surface.ts new file mode 100644 index 0000000000..3923fc2a0f --- /dev/null +++ b/apps/mobile/src/components/agents/interaction-surface.ts @@ -0,0 +1,42 @@ +/** + * Pure derivation of the active interaction surface for `SessionDetailContent`. + * + * Exactly one interaction surface is visible at a time. The precedence is + * fixed: question > permission > suggestion. The composer is hidden for + * any active interaction so the user can only act on the surface they are + * being asked to respond to. + */ + +type ActiveInteractionLike = { requestId: string } | null | undefined; + +export function hasActiveInteraction(args: { + activeQuestion: ActiveInteractionLike; + activePermission: ActiveInteractionLike; + activeSuggestion: ActiveInteractionLike; +}): boolean { + return ( + Boolean(args.activeQuestion) || Boolean(args.activePermission) || Boolean(args.activeSuggestion) + ); +} + +/** + * Returns the precedence winner for which interaction card to render. + * `kind: 'none'` means no interaction card should render and the composer + * is allowed (subject to its other disabled flags). + */ +export function pickActiveInteractionSurface(args: { + activeQuestion: ActiveInteractionLike; + activePermission: ActiveInteractionLike; + activeSuggestion: ActiveInteractionLike; +}): { kind: 'question' } | { kind: 'permission' } | { kind: 'suggestion' } | { kind: 'none' } { + if (args.activeQuestion) { + return { kind: 'question' }; + } + if (args.activePermission) { + return { kind: 'permission' }; + } + if (args.activeSuggestion) { + return { kind: 'suggestion' }; + } + return { kind: 'none' }; +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 34df5f1330..fb8c102e19 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -14,6 +14,7 @@ import { MessageBubble } from '@/components/agents/message-bubble'; import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector'; import { PermissionCard } from '@/components/agents/permission-card'; import { QuestionCard } from '@/components/agents/question-card'; +import { SuggestionCard } from '@/components/agents/suggestion-card'; import { getSessionKeyboardContainerKind } from '@/components/agents/session-keyboard-container-state'; import { useSessionManager } from '@/components/agents/session-provider'; import { SessionStatusIndicator } from '@/components/agents/session-status-indicator'; @@ -23,11 +24,16 @@ import { } from '@/components/agents/session-working-state'; import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; +import { useAgentSessionPresence } from '@/components/kilo-chat/hooks/use-agent-session-presence'; import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers'; import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll'; import { useSessionConfigSync } from '@/components/agents/use-session-config-sync'; import { WorkingIndicator } from '@/components/agents/working-indicator'; import { ChildSessionSheet } from '@/components/agents/child-session-sheet'; +import { + hasActiveInteraction, + pickActiveInteractionSurface, +} from '@/components/agents/interaction-surface'; import { PartRenderer } from '@/components/agents/part-renderer'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; @@ -91,6 +97,7 @@ export function SessionDetailContent({ const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments); const activeQuestion = useAtomValue(manager.atoms.activeQuestion); const activePermission = useAtomValue(manager.atoms.activePermission); + const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion); const totalCost = useAtomValue(manager.atoms.totalCost); const getChildMessages = useAtomValue(manager.atoms.childMessages); const getChildSessionHydrationState = useAtomValue(manager.atoms.childSessionHydrationState); @@ -103,6 +110,12 @@ export function SessionDetailContent({ const { isConnected } = useAppLifecycle(); const { bottom } = useSafeAreaInsets(); + // Hold exact-session presence while the user is actively viewing this + // session. The hook gates on app-active + route-focused and releases the + // context as soon as we navigate away so notifications for this session + // (and others) resume correctly. + useAgentSessionPresence(sessionId); + const analyticsSurface: AnalyticsSurface = fetchedData?.cloudAgentSessionId ? 'cloud-agent' : 'remote-session'; @@ -271,6 +284,26 @@ export function SessionDetailContent({ } }, [manager]); + // SuggestionCard owns safe error copy. We forward the manager call and + // let the error propagate to the card, which renders the friendly retry + // text; we deliberately avoid a second toast here. + const handleAcceptSuggestion = useCallback( + async (index: number) => { + if (!activeSuggestion) { + return; + } + await manager.acceptSuggestion(activeSuggestion.requestId, index); + }, + [activeSuggestion, manager] + ); + + const handleDismissSuggestion = useCallback(async () => { + if (!activeSuggestion) { + return; + } + await manager.dismissSuggestion(activeSuggestion.requestId); + }, [activeSuggestion, manager]); + const handleBackToSessions = useCallback(() => { router.replace('/(app)/(tabs)/(2_agents)' as Href); }, [router]); @@ -331,14 +364,27 @@ export function SessionDetailContent({ const title = fetchedData?.kiloSessionId === sessionId ? (fetchedData.title ?? 'Session') : 'Session'; const requiresModel = Boolean(fetchedData?.cloudAgentSessionId); + // Exactly one interaction surface is ever visible: question wins, then + // permission, then suggestion. The composer is hidden while any of them + // is active so the user can only act on the surface they're being asked + // to respond to. + const hasInteraction = hasActiveInteraction({ + activeQuestion, + activePermission, + activeSuggestion, + }); + const activeInteractionSurface = pickActiveInteractionSurface({ + activeQuestion, + activePermission, + activeSuggestion, + }); const isComposerDisabled = isReadOnly || !canSend || shouldShowLoading || Boolean(error) || - Boolean(activeQuestion) || + hasInteraction || (requiresModel && !currentModel); - const showInteractionCards = activeQuestion ?? activePermission; const composerPlaceholder = (cloudStatus && COMPOSER_PLACEHOLDERS[cloudStatus.type]) ?? 'Message...'; const keyboardContainerKind = getSessionKeyboardContainerKind(Platform.OS); @@ -429,7 +475,7 @@ export function SessionDetailContent({ <> {renderContent()} - {activeQuestion ? ( + {activeInteractionSurface.kind === 'question' && activeQuestion ? ( { @@ -442,7 +488,7 @@ export function SessionDetailContent({ /> ) : null} - {activePermission ? ( + {activeInteractionSurface.kind === 'permission' && activePermission ? ( ) : null} - {!showInteractionCards && + {activeInteractionSurface.kind === 'suggestion' && activeSuggestion ? ( + { + await handleAcceptSuggestion(index); + }} + onDismiss={async () => { + await handleDismissSuggestion(); + }} + /> + ) : null} + + {!hasInteraction && (isReadOnly && messages.length > 0 ? ( diff --git a/apps/mobile/src/components/agents/suggestion-card.test.tsx b/apps/mobile/src/components/agents/suggestion-card.test.tsx new file mode 100644 index 0000000000..2025f4cff0 --- /dev/null +++ b/apps/mobile/src/components/agents/suggestion-card.test.tsx @@ -0,0 +1,358 @@ +import * as React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SuggestionCard } from './suggestion-card'; + +const mockHaptics = vi.hoisted(() => vi.fn()); + +const MockButton = vi.hoisted(() => () => null); +const MockText = vi.hoisted(() => () => null); +const MockView = vi.hoisted(() => () => null); +const MockScrollView = vi.hoisted(() => () => null); +const MockPressable = vi.hoisted(() => () => null); +const MockActivityIndicator = vi.hoisted(() => () => null); + +vi.mock('expo-haptics', () => ({ + ImpactFeedbackStyle: { Light: 'Light' }, + impactAsync: (style: string) => mockHaptics(style), +})); + +vi.mock('@/components/ui/button', () => ({ Button: MockButton })); +vi.mock('@/components/ui/text', () => ({ Text: MockText })); + +vi.mock('react-native', () => ({ + ActivityIndicator: MockActivityIndicator, + Pressable: MockPressable, + ScrollView: MockScrollView, + Text: MockText, + View: MockView, +})); + +type ReactInternals = { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { + H: unknown; + }; +}; + +type SuggestionCardProps = React.ComponentProps; + +type MockProps = { children?: React.ReactNode; [key: string]: unknown }; +type MockElement = React.ReactElement; + +function createRenderer() { + const reactInternals = React as typeof React & ReactInternals; + const hookState: unknown[] = []; + let hookIndex = 0; + + // Mirrors the repo's existing React 19 test-only dispatcher harness; pinned + // to the current React internals shape and not intended for production use. + const dispatcher = { + useCallback: (fn: T) => fn, + useEffect: () => void 0, + useMemo: (factory: () => T) => factory(), + useRef: (initialValue: T) => { + const stateIndex = hookIndex; + hookIndex += 1; + if (hookState[stateIndex] === undefined) { + hookState[stateIndex] = { current: initialValue }; + } + return hookState[stateIndex] as { current: T }; + }, + useState: (initialValue: T) => { + const stateIndex = hookIndex; + hookIndex += 1; + if (hookState[stateIndex] === undefined) { + hookState[stateIndex] = initialValue; + } + const setState = (value: T | ((prev: T) => T)) => { + const prev = hookState[stateIndex] as T; + hookState[stateIndex] = + typeof value === 'function' ? (value as (prev: T) => T)(prev) : value; + }; + return [hookState[stateIndex] as T, setState] as const; + }, + }; + + function render(props: SuggestionCardProps) { + hookIndex = 0; + const previousDispatcher = + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H; + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher; + try { + const suggestionCard = SuggestionCard; + return suggestionCard(props) as React.ReactElement; + } finally { + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = + previousDispatcher; + } + } + + return { render }; +} + +function findAll( + node: React.ReactNode, + predicate: (element: MockElement) => boolean +): MockElement[] { + const results: MockElement[] = []; + function traverse(current: React.ReactNode) { + if (!React.isValidElement(current)) { + return; + } + const element = current as unknown as MockElement; + if (predicate(element)) { + results.push(element); + } + const children = element.props.children; + if (Array.isArray(children)) { + for (const child of children as React.ReactNode[]) { + traverse(child); + } + } else if (children !== undefined && children !== null) { + traverse(children); + } + } + traverse(node); + return results; +} + +function getTextContent(node: React.ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (!React.isValidElement(node)) { + return ''; + } + const element = node as unknown as MockElement; + const children = element.props.children; + if (Array.isArray(children)) { + return (children as React.ReactNode[]).map(child => getTextContent(child)).join(''); + } + if (children === undefined || children === null) { + return ''; + } + return getTextContent(children); +} + +function throwDeferredUninitialized(): never { + throw new Error('Deferred promise was not initialized'); +} + +function createDeferredPromise() { + let resolve: (value: T) => void = throwDeferredUninitialized; + let reject: (reason?: unknown) => void = throwDeferredUninitialized; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { promise, reject, resolve }; +} + +const baseProps: SuggestionCardProps = { + actions: [ + { description: 'Use Prettier for formatting', label: 'Apply Prettier', prompt: 'p1' }, + { description: 'Use Biome for formatting', label: 'Apply Biome', prompt: 'p2' }, + ], + onAccept: vi.fn(), + onDismiss: vi.fn(), + text: 'Pick a formatter', +}; + +async function callOnPress(button: MockElement): Promise { + const onPress = button.props.onPress as (() => void | Promise) | undefined; + if (onPress === undefined) { + throw new Error('Button is missing onPress'); + } + await onPress(); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('SuggestionCard', () => { + it('renders action labels and only one footer button', () => { + const renderer = createRenderer(); + + const tree = renderer.render(baseProps); + + const buttons = findAll(tree, node => node.type === MockButton); + const buttonTexts = buttons.map(button => getTextContent(button.props.children)); + expect(buttonTexts).toContain('Apply Prettier'); + expect(buttonTexts).toContain('Apply Biome'); + expect(buttonTexts).toContain('Dismiss suggestion'); + expect(buttons).toHaveLength(baseProps.actions.length + 1); + }); + + it('second action calls onAccept(1)', async () => { + const renderer = createRenderer(); + const onAccept = vi.fn().mockResolvedValue(undefined); + const onDismiss = vi.fn(); + + const tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const actionButtons = findAll(tree, node => node.type === MockButton); + const secondButton = actionButtons[1]; + if (secondButton === undefined) { + throw new Error('Expected second action button'); + } + await callOnPress(secondButton); + + expect(onAccept).toHaveBeenCalledTimes(1); + expect(onAccept).toHaveBeenCalledWith(1); + expect(mockHaptics).toHaveBeenCalledWith('Light'); + }); + + it('dismiss calls onDismiss', async () => { + const renderer = createRenderer(); + const onAccept = vi.fn(); + const onDismiss = vi.fn().mockResolvedValue(undefined); + + const tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const dismissButton = findAll(tree, node => node.type === MockButton).find( + button => getTextContent(button.props.children) === 'Dismiss suggestion' + ); + if (dismissButton === undefined) { + throw new Error('Expected dismiss button'); + } + await callOnPress(dismissButton); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('prevents same-frame double taps', async () => { + const renderer = createRenderer(); + const onAccept = vi.fn().mockResolvedValue(undefined); + const onDismiss = vi.fn(); + + const tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const [firstButton] = findAll(tree, node => node.type === MockButton); + if (firstButton === undefined) { + throw new Error('Expected first action button'); + } + void callOnPress(firstButton); + void callOnPress(firstButton); + + await new Promise(resolve => { + setImmediate(resolve); + }); + + expect(onAccept).toHaveBeenCalledTimes(1); + }); + + it('deferred pending disables all buttons and shows loading only on tapped action', async () => { + const renderer = createRenderer(); + const { promise, resolve } = createDeferredPromise(); + const onAccept = vi.fn().mockReturnValue(promise); + const onDismiss = vi.fn(); + + let tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const actionButtons = findAll(tree, node => node.type === MockButton); + const secondButton = actionButtons[1]; + if (secondButton === undefined) { + throw new Error('Expected second action button'); + } + const pressPromise = callOnPress(secondButton); + expect(onAccept).toHaveBeenCalledTimes(1); + + tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const buttonsAfterPending = findAll(tree, node => node.type === MockButton); + const [firstButton, secondButtonAfterPending, dismissButton] = buttonsAfterPending; + if ( + firstButton === undefined || + secondButtonAfterPending === undefined || + dismissButton === undefined + ) { + throw new Error('Expected buttons after pending'); + } + for (const button of buttonsAfterPending) { + expect(button.props.disabled).toBe(true); + } + expect(secondButtonAfterPending.props.loading).toBe(true); + expect(firstButton.props.loading).toBe(false); + expect(dismissButton.props.loading).toBe(false); + + await callOnPress(secondButtonAfterPending); + expect(onAccept).toHaveBeenCalledTimes(1); + + resolve(undefined); + await pressPromise; + }); + + it('accept failure shows exact safe copy and not raw error', async () => { + const renderer = createRenderer(); + const onAccept = vi.fn().mockRejectedValue(new Error('Raw upstream error')); + const onDismiss = vi.fn(); + + let tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const [firstButton] = findAll(tree, node => node.type === MockButton); + if (firstButton === undefined) { + throw new Error('Expected first action button'); + } + await callOnPress(firstButton); + + tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const allText = findAll(tree, node => node.type === MockText) + .map(text => getTextContent(text)) + .join(' '); + expect(allText).toContain("Couldn't apply this suggestion. Try again."); + expect(allText).not.toContain('Raw upstream error'); + }); + + it('dismiss failure shows exact safe copy and not raw error', async () => { + const renderer = createRenderer(); + const onAccept = vi.fn(); + const onDismiss = vi.fn().mockRejectedValue(new Error('Raw dismiss error')); + + let tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const dismissButton = findAll(tree, node => node.type === MockButton).find( + button => getTextContent(button.props.children) === 'Dismiss suggestion' + ); + if (dismissButton === undefined) { + throw new Error('Expected dismiss button'); + } + await callOnPress(dismissButton); + + tree = renderer.render({ ...baseProps, onAccept, onDismiss }); + + const allText = findAll(tree, node => node.type === MockText) + .map(text => getTextContent(text)) + .join(' '); + expect(allText).toContain("Couldn't dismiss this suggestion. Try again."); + expect(allText).not.toContain('Raw dismiss error'); + }); + + it('sets accessibility label and hint on action buttons and hides description text', () => { + const renderer = createRenderer(); + + const tree = renderer.render(baseProps); + + const actionButtons = findAll(tree, node => node.type === MockButton); + const [firstButton] = actionButtons; + if (firstButton === undefined) { + throw new Error('Expected first action button'); + } + expect(firstButton.props.accessibilityLabel).toBe('Apply Prettier'); + expect(firstButton.props.accessibilityHint).toBe('Use Prettier for formatting'); + + const descriptionTexts = findAll( + tree, + node => + node.type === MockText && + typeof node.props.children === 'string' && + node.props.accessible === false + ); + const descriptionContents = descriptionTexts.map( + (text): React.ReactNode => text.props.children + ); + expect(descriptionContents).toContain('Use Prettier for formatting'); + expect(descriptionContents).toContain('Use Biome for formatting'); + }); +}); diff --git a/apps/mobile/src/components/agents/suggestion-card.tsx b/apps/mobile/src/components/agents/suggestion-card.tsx new file mode 100644 index 0000000000..34f6b4eef0 --- /dev/null +++ b/apps/mobile/src/components/agents/suggestion-card.tsx @@ -0,0 +1,160 @@ +import { useCallback, useRef, useState } from 'react'; +import { ScrollView, View } from 'react-native'; +import * as Haptics from 'expo-haptics'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +// Minimal shape of `StandaloneSuggestion['actions'][number]`. We re-declare +// the relevant fields here (rather than importing the SDK type) so the +// card stays a pure presentational component and can be rendered in tests +// with a small literal. The runtime shape comes from the SDK manager. +type SuggestionAction = { + label: string; + description?: string; + prompt: string; +}; + +type SuggestionCardProps = { + text: string; + actions: SuggestionAction[]; + onAccept: (index: number) => Promise; + onDismiss: () => Promise; +}; + +type PendingState = { kind: 'accept'; index: number } | { kind: 'dismiss' }; + +// Friendly inline error copy. Keep these user-facing strings local to the +// card so retries stay actionable without exposing upstream error text. +const ACCEPT_ERROR_COPY = "Couldn't apply this suggestion. Try again."; +const DISMISS_ERROR_COPY = "Couldn't dismiss this suggestion. Try again."; + +export function SuggestionCard({ + text, + actions, + onAccept, + onDismiss, +}: Readonly) { + const [pending, setPending] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const pendingRef = useRef(null); + + const handleAccept = useCallback( + async (index: number) => { + // Immediate ref lock prevents same-frame double-taps + if (pendingRef.current) { + return; + } + pendingRef.current = { kind: 'accept', index }; + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setPending({ kind: 'accept', index }); + setErrorMessage(null); + try { + await onAccept(index); + // Success: the manager resolves activeSuggestion and the parent + // unmounts this card, so we don't need to clear pending here. + } catch { + pendingRef.current = null; + setErrorMessage(ACCEPT_ERROR_COPY); + setPending(null); + } + }, + [onAccept] + ); + + const handleDismiss = useCallback(async () => { + // Immediate ref lock prevents same-frame double-taps + if (pendingRef.current) { + return; + } + pendingRef.current = { kind: 'dismiss' }; + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setPending({ kind: 'dismiss' }); + setErrorMessage(null); + try { + await onDismiss(); + } catch { + pendingRef.current = null; + setErrorMessage(DISMISS_ERROR_COPY); + setPending(null); + } + }, [onDismiss]); + + const isDismissing = pending?.kind === 'dismiss'; + const isPending = pending !== null; + + return ( + + + Agent suggestion + + + + + {text} + + {actions.length > 0 ? ( + + {actions.map((action, index) => ( + + + {action.description ? ( + + {action.description} + + ) : null} + + ))} + + ) : null} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.test.ts new file mode 100644 index 0000000000..a374bf2b14 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAgentSessionPresence } from './use-agent-session-presence'; + +const testState = vi.hoisted(() => ({ + context: null as string | null, + active: false, + appActiveAndFocused: true, +})); + +vi.mock('@kilocode/kilo-chat-hooks', () => ({ + usePresenceSubscription: (context: string | null, active: boolean) => { + testState.context = context; + testState.active = active; + }, +})); + +vi.mock('./use-app-active-and-focused', () => ({ + useAppActiveAndFocused: () => testState.appActiveAndFocused, +})); + +describe('useAgentSessionPresence', () => { + beforeEach(() => { + testState.context = null; + testState.active = false; + testState.appActiveAndFocused = true; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('subscribes to the exact agent-session context when active and focused', () => { + useAgentSessionPresence('session-123'); + expect(testState.context).toBe('/presence/agent-session/session-123'); + expect(testState.active).toBe(true); + }); + + it('passes null context and disabled flag when no session id is provided', () => { + useAgentSessionPresence(undefined); + expect(testState.context).toBeNull(); + expect(testState.active).toBe(false); + }); + + it('disables the subscription when the app is not active and focused', () => { + testState.appActiveAndFocused = false; + useAgentSessionPresence('session-123'); + expect(testState.context).toBe('/presence/agent-session/session-123'); + expect(testState.active).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.ts new file mode 100644 index 0000000000..3715059ee1 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.ts @@ -0,0 +1,25 @@ +import { presenceContextForAgentSession } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +import { useAppActiveAndFocused } from './use-app-active-and-focused'; + +/** + * Exact-session presence for the Cloud Agent / remote CLI session the user + * is actively viewing. Mirrors the gating of `useConversationPresence`: + * holds the presence context only while the app is in the foreground AND + * the current expo-router route is focused. When the user is on any other + * session (or no session at all), the context is released so notifications + * can reach the device. + * + * The wrapper's policy filter already suppresses auto-approved and + * non-actionable upstream events, so we only need to gate on + * "is the user looking at this session right now" — not on transport + * state, message stream status, or any other internal flag. + */ +export function useAgentSessionPresence(sessionId: string | undefined) { + const activeAndFocused = useAppActiveAndFocused(); + usePresenceSubscription( + sessionId ? presenceContextForAgentSession(sessionId) : null, + Boolean(sessionId) && activeAndFocused + ); +} diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index c48690fa15..8112d49a8d 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ 'src/lib/kilo-pass/**/*.test.tsx', 'src/lib/onboarding/**/*.test.ts', 'src/components/**/*.test.ts', + 'src/components/**/*.test.tsx', ], }, }); diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 27416a0920..0fa775e807 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -46,6 +46,7 @@ import { ContextUsageIndicator } from './ContextUsageIndicator'; import { resolveContextWindow } from './model-context-lengths'; import { useSlashCommandSets } from '@/hooks/useSlashCommandSets'; import { useCelebrationSound } from '@/hooks/useCelebrationSound'; +import { useAgentSessionPresence } from '@/hooks/useAgentSessionPresence'; import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import { SetPageTitle } from '@/components/SetPageTitle'; @@ -204,6 +205,13 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { } }, [sessionIdFromParams, manager]); + // Exact-session presence. Holds the per-session context only while the + // tab is visible and the user is on a session. We do not gate on the + // stream/socket state — the wrapper's policy filter and the outbox own + // upstream suppression; this hook only reflects "is the user looking + // at this session right now." + useAgentSessionPresence(sessionIdFromParams); + // -- Manager atoms -------------------------------------------------------- const isStreaming = useAtomValue(manager.atoms.isStreaming); const isLoading = useAtomValue(manager.atoms.isLoading); diff --git a/apps/web/src/hooks/useAgentSessionPresence.test.ts b/apps/web/src/hooks/useAgentSessionPresence.test.ts new file mode 100644 index 0000000000..2a94de9455 --- /dev/null +++ b/apps/web/src/hooks/useAgentSessionPresence.test.ts @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { useAgentSessionPresence } from './useAgentSessionPresence'; + +const mockUsePresenceSubscription = jest.fn(); +const mockUseDocumentVisible = jest.fn(); + +jest.mock('@kilocode/kilo-chat-hooks', () => ({ + usePresenceSubscription: (...args: unknown[]) => mockUsePresenceSubscription(...args), +})); + +jest.mock('./useDocumentVisible', () => ({ + useDocumentVisible: () => mockUseDocumentVisible(), +})); + +function TestHarness({ sessionId }: { sessionId: string | null }) { + useAgentSessionPresence(sessionId); + return null; +} + +beforeEach(() => { + mockUsePresenceSubscription.mockClear(); + mockUseDocumentVisible.mockClear(); + mockUseDocumentVisible.mockReturnValue(true); +}); + +describe('useAgentSessionPresence', () => { + it('subscribes to the exact agent-session context when visible and id is present', () => { + renderToString(React.createElement(TestHarness, { sessionId: 'session-123' })); + + expect(mockUsePresenceSubscription).toHaveBeenCalledWith( + '/presence/agent-session/session-123', + true + ); + }); + + it('passes null context and disabled flag when no session id is provided', () => { + renderToString(React.createElement(TestHarness, { sessionId: null })); + + expect(mockUsePresenceSubscription).toHaveBeenCalledWith(null, false); + }); + + it('disables the subscription when the document is not visible', () => { + mockUseDocumentVisible.mockReturnValue(false); + + renderToString(React.createElement(TestHarness, { sessionId: 'session-123' })); + + expect(mockUsePresenceSubscription).toHaveBeenCalledWith( + '/presence/agent-session/session-123', + false + ); + }); + + it('sends a new context when the session id changes', () => { + renderToString(React.createElement(TestHarness, { sessionId: 'session-123' })); + + expect(mockUsePresenceSubscription).toHaveBeenCalledWith( + '/presence/agent-session/session-123', + true + ); + + mockUsePresenceSubscription.mockClear(); + + renderToString(React.createElement(TestHarness, { sessionId: 'session-456' })); + + expect(mockUsePresenceSubscription).toHaveBeenCalledWith( + '/presence/agent-session/session-456', + true + ); + }); +}); diff --git a/apps/web/src/hooks/useAgentSessionPresence.ts b/apps/web/src/hooks/useAgentSessionPresence.ts new file mode 100644 index 0000000000..38f9074469 --- /dev/null +++ b/apps/web/src/hooks/useAgentSessionPresence.ts @@ -0,0 +1,27 @@ +'use client'; + +import { presenceContextForAgentSession } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +import { useDocumentVisible } from './useDocumentVisible'; + +/** + * Exact-session presence for the Cloud Agent / remote CLI session the user + * is actively viewing. Holds the `/presence/agent-session/{sessionId}` + * context only while the tab is visible and a session is loaded, so + * notifications routed through this context are suppressed only when the + * user is on that specific session. + * + * `kiloSessionId` is the Kilo-side session id (not the Cloud Agent + * session id) — the wrapper's policy filter and the outbox downstream + * already gate what reaches this hook, so we only need to gate on + * "is the user looking at this session right now" and document + * visibility. + */ +export function useAgentSessionPresence(kiloSessionId: string | null) { + const visible = useDocumentVisible(); + usePresenceSubscription( + kiloSessionId ? presenceContextForAgentSession(kiloSessionId) : null, + Boolean(kiloSessionId) && visible + ); +} diff --git a/packages/event-service/src/__tests__/presence.test.ts b/packages/event-service/src/__tests__/presence.test.ts new file mode 100644 index 0000000000..d5a89ba45c --- /dev/null +++ b/packages/event-service/src/__tests__/presence.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + presenceContextForAgentSession, + presenceContextForConversation, + presenceContextForInstance, + presenceContextForPlatform, +} from '../presence'; + +describe('presence contexts', () => { + it('builds the platform presence path', () => { + expect(presenceContextForPlatform('app')).toBe('/presence/app'); + expect(presenceContextForPlatform('web')).toBe('/presence/web'); + }); + + it('builds the instance presence path under /presence', () => { + expect(presenceContextForInstance('sandbox-1')).toBe('/presence/kiloclaw/sandbox-1'); + }); + + it('builds the conversation presence path under /presence', () => { + expect(presenceContextForConversation('sandbox-1', 'conv-1')).toBe( + '/presence/kiloclaw/sandbox-1/conv-1' + ); + }); + + it('builds the per-agent-session presence path under /presence', () => { + expect(presenceContextForAgentSession('ses_1')).toBe('/presence/agent-session/ses_1'); + }); + + it('builds distinct per-session paths for different cliSessionIds', () => { + expect(presenceContextForAgentSession('ses_1')).not.toBe( + presenceContextForAgentSession('ses_2') + ); + }); +}); diff --git a/packages/event-service/src/presence.ts b/packages/event-service/src/presence.ts index a267aa667c..3bebae6ac9 100644 --- a/packages/event-service/src/presence.ts +++ b/packages/event-service/src/presence.ts @@ -19,3 +19,13 @@ export const presenceContextForInstance = (sandboxId: string) => export const presenceContextForConversation = (sandboxId: string, conversationId: string) => `/presence${kiloclawConversationContext(sandboxId, conversationId)}` as const; + +/** + * Exact-session presence context for Cloud Agent / remote CLI sessions. + * Subscribed while the user is actively viewing the matching session, so + * notifications routed through this context are suppressed only when the + * user is on that specific session (not any other session). The + * notifications pipeline queries it via event-service.isUserInContext. + */ +export const presenceContextForAgentSession = (cliSessionId: string) => + `/presence/agent-session/${cliSessionId}` as const; diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index ae06ef1008..3ffc339b4d 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -172,6 +172,44 @@ export type SendSessionReadyNotificationResult = z.infer< typeof sendSessionReadyNotificationOutputSchema >; +// ── sendSessionAttentionNotification ──────────────────────────────── + +/** + * Transport-neutral notification intent for actionable human waits on + * Cloud Agent / remote CLI sessions. The notifications service uses this + * to render fixed, lock-screen-safe copy and to look up exact-session + * presence before dispatching the push. + * + * The producer never supplies a body or title — copy is determined by + * `reason` and must not include prompt text, permission metadata, command + * args, paths, auth details, or arbitrary upstream messages. + */ +export const sessionAttentionReasonSchema = z.enum([ + 'question', + 'permission', + 'blocking_suggestion', + 'action_required', +]); +export type SessionAttentionReason = z.infer; + +export const sendSessionAttentionNotificationInputSchema = z.object({ + userId: z.string().min(1), + cliSessionId: z.string().min(1), + requestId: z.string().min(1), + reason: sessionAttentionReasonSchema, +}); +export type SendSessionAttentionNotificationParams = z.infer< + typeof sendSessionAttentionNotificationInputSchema +>; + +export const sendSessionAttentionNotificationOutputSchema = z.object({ + dispatched: z.boolean(), + reason: z.enum(['missing_session', 'dispatch_failed', 'suppressed_presence']).optional(), +}); +export type SendSessionAttentionNotificationResult = z.infer< + typeof sendSessionAttentionNotificationOutputSchema +>; + // ── dispatchPush (internal DO RPC) ────────────────────────────────── export const dispatchPushInputSchema = z.object({ diff --git a/packages/notifications/src/rpc-schemas.type-test.ts b/packages/notifications/src/rpc-schemas.type-test.ts index 0513e670ce..2f7d8fd2e8 100644 --- a/packages/notifications/src/rpc-schemas.type-test.ts +++ b/packages/notifications/src/rpc-schemas.type-test.ts @@ -2,6 +2,9 @@ import type { ScheduledActionEvent, SendScheduledActionNoticeParams, SendScheduledActionNoticeResult, + SendSessionAttentionNotificationParams, + SendSessionAttentionNotificationResult, + SessionAttentionReason, } from './rpc-schemas'; const scheduledActionEvent = 'scheduled_restart_notice' satisfies ScheduledActionEvent; @@ -25,3 +28,25 @@ const scheduledActionResult = { void scheduledActionParams; void scheduledActionResult; + +const attentionReasons = [ + 'question', + 'permission', + 'blocking_suggestion', + 'action_required', +] as const satisfies readonly SessionAttentionReason[]; + +const attentionParams = { + userId: 'user-1', + cliSessionId: 'ses_1', + requestId: 'req_1', + reason: 'question', +} satisfies SendSessionAttentionNotificationParams; + +const attentionResult = { + dispatched: true, +} satisfies SendSessionAttentionNotificationResult; + +void attentionReasons; +void attentionParams; +void attentionResult; diff --git a/packages/session-ingest-contracts/package.json b/packages/session-ingest-contracts/package.json index 74206aaf6e..042443e725 100644 --- a/packages/session-ingest-contracts/package.json +++ b/packages/session-ingest-contracts/package.json @@ -10,13 +10,15 @@ }, "scripts": { "typecheck": "tsgo --noEmit", - "lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/session-ingest-contracts/src" + "lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/session-ingest-contracts/src", + "test": "vitest run" }, "dependencies": { "zod": "catalog:" }, "devDependencies": { "@typescript/native-preview": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/session-ingest-contracts/src/rpc-contract.test.ts b/packages/session-ingest-contracts/src/rpc-contract.test.ts new file mode 100644 index 0000000000..989ccf4650 --- /dev/null +++ b/packages/session-ingest-contracts/src/rpc-contract.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for the Cloud Agent attention RPC contract. + * + * The schema intentionally permits only `question` and `permission` reasons — + * `blocking_suggestion` is excluded because the wrapper's policy filter + * suppresses suggestions before they reach the worker, and the per-session + * outbox handles them separately. The contract also rejects any extra body + * fields so the producer cannot smuggle prompt text or permission arguments + * into the RPC surface. + */ +import { describe, expect, it } from 'vitest'; + +import { recordCloudAgentSessionAttentionSchema, sessionIdSchema } from './index'; + +const VALID_KILO_SESSION_ID = 'ses_12345678901234567890123456'; + +function validRaise() { + return { + kiloUserId: 'usr_owner', + kiloSessionId: VALID_KILO_SESSION_ID, + requestId: 'req_attn_01', + intent: { kind: 'raise', reason: 'question' }, + }; +} + +function validResolve() { + return { + kiloUserId: 'usr_owner', + kiloSessionId: VALID_KILO_SESSION_ID, + requestId: 'req_attn_01', + intent: { kind: 'resolve', reason: 'question' }, + }; +} + +describe('recordCloudAgentSessionAttentionSchema', () => { + it('accepts a valid raise intent with reason "question"', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse(validRaise()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validRaise()); + } + }); + + it('accepts a valid raise intent with reason "permission"', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + intent: { kind: 'raise', reason: 'permission' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts a valid resolve intent with reason "question"', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse(validResolve()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validResolve()); + } + }); + + it('accepts a valid resolve intent with reason "permission"', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validResolve(), + intent: { kind: 'resolve', reason: 'permission' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects a resolve intent without a reason', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validResolve(), + intent: { kind: 'resolve' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a resolve intent with reason "blocking_suggestion" (wrapper never raises suggestions)', () => { + // The Cloud Agent boundary is intentionally narrower than the + // session-ingest input schema: the wrapper's policy filter suppresses + // suggestions before they reach the worker, so the boundary must not + // accept them — the suggestion resolve must be produced only by the + // remote CLI / direct session-ingest path. + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validResolve(), + intent: { kind: 'resolve', reason: 'blocking_suggestion' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a resolve intent with an unknown reason string', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validResolve(), + intent: { kind: 'resolve', reason: 'action_required' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a raise intent with the blocking_suggestion reason', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + intent: { kind: 'raise', reason: 'blocking_suggestion' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an unknown reason string', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + intent: { kind: 'raise', reason: 'urgent' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a non-`ses_` kilo session ID before the DO is reached', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + kiloSessionId: 'not-a-session', + }); + expect(result.success).toBe(false); + expect(sessionIdSchema.safeParse('not-a-session').success).toBe(false); + }); + + it('rejects a kilo session ID of the wrong length', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + kiloSessionId: 'ses_short', + }); + expect(result.success).toBe(false); + }); + + it('rejects an empty requestId', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + requestId: '', + }); + expect(result.success).toBe(false); + }); + + it('rejects an empty kiloUserId', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + kiloUserId: '', + }); + expect(result.success).toBe(false); + }); + + it('rejects an unknown intent kind', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + intent: { kind: 'cancel' }, + }); + expect(result.success).toBe(false); + }); + + it('strips any raw body or envelope payload from the contract', () => { + const result = recordCloudAgentSessionAttentionSchema.safeParse({ + ...validRaise(), + body: 'raw prompt text that must be stripped', + promptText: 'another raw field', + envelope: { id: 'e_1', payload: 'secret' }, + permissionArgs: { command: 'rm -rf /' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('body'); + expect(result.data).not.toHaveProperty('promptText'); + expect(result.data).not.toHaveProperty('envelope'); + expect(result.data).not.toHaveProperty('permissionArgs'); + expect(result.data).toEqual(validRaise()); + } + }); +}); diff --git a/packages/session-ingest-contracts/src/rpc-contract.ts b/packages/session-ingest-contracts/src/rpc-contract.ts index 95bb454f06..26288baa3f 100644 --- a/packages/session-ingest-contracts/src/rpc-contract.ts +++ b/packages/session-ingest-contracts/src/rpc-contract.ts @@ -634,6 +634,37 @@ export type CloudAgentRootSessionMessages = { }; export type GetCloudAgentRootSessionMessagesResult = CloudAgentRootSessionMessages | null; +/** + * Attention intent for the Cloud Agent boundary RPC. The schema intentionally + * permits only `question` and `permission` reasons — `blocking_suggestion` is + * excluded because the wrapper's policy filter suppresses suggestions before + * they reach the worker, and the per-session outbox handles them separately. + * + * The resolve intent must carry the same reason the matching raise would + * have. Without a reason, an out-of-order resolve arriving before a raise + * cannot insert a tombstone with the correct `reason`, and the outbox-level + * "preserve original reason" rule has nothing to compare against. + */ +const cloudAgentAttentionReasonSchema = z.enum(['question', 'permission']); +export const cloudAgentAttentionIntentSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('raise'), reason: cloudAgentAttentionReasonSchema }), + z.object({ kind: z.literal('resolve'), reason: cloudAgentAttentionReasonSchema }), +]); +export type CloudAgentAttentionIntent = z.infer; + +export const recordCloudAgentSessionAttentionSchema = z.object({ + kiloUserId: z.string().min(1), + kiloSessionId: sessionIdSchema, + requestId: z.string().min(1), + intent: cloudAgentAttentionIntentSchema, +}); +export type RecordCloudAgentSessionAttentionParams = z.input< + typeof recordCloudAgentSessionAttentionSchema +>; +export type RecordCloudAgentSessionAttentionResult = + | { accepted: true } + | { accepted: false; reason: 'invalid_input' | 'deleted' }; + export type SessionIngestRpcMethods = { createSessionForCloudAgent: (params: CreateSessionForCloudAgentParams) => Promise; deleteSessionForCloudAgent: (params: DeleteSessionForCloudAgentParams) => Promise; @@ -649,4 +680,7 @@ export type SessionIngestRpcMethods = { getCloudAgentRootSessionMessages: ( params: GetCloudAgentRootSessionMessagesParams ) => Promise; + recordCloudAgentSessionAttention: ( + params: RecordCloudAgentSessionAttentionParams + ) => Promise; }; diff --git a/packages/session-ingest-contracts/vitest.config.ts b/packages/session-ingest-contracts/vitest.config.ts new file mode 100644 index 0000000000..7dd13254e7 --- /dev/null +++ b/packages/session-ingest-contracts/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ab8de0f67..fb8ed374d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1363,6 +1363,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) packages/trpc: dependencies: diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.test.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.test.ts new file mode 100644 index 0000000000..d87c161b62 --- /dev/null +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.test.ts @@ -0,0 +1,330 @@ +/** + * Focused unit tests for the CloudAgentSession attention scheduling helper. + * + * The orchestrator's task wires attention events through `getIngestHandler` as + * a synchronous `ctx.waitUntil` handoff. The helper under test here is + * `scheduleCloudAgentAttention(ctx, deps, event)`, which is exactly what + * `getIngestHandler` calls. This keeps the private-field-heavy DO out of the + * test surface while still proving the full contract: + * + * - scheduling: the helper is synchronous and hands the IO to ctx.waitUntil; + * - safe payload: only kiloUserId, kiloSessionId, requestId, and intent + * are forwarded to the binding; no reason/error message leaks; + * - missing metadata: the IO is skipped, the binding is never called; + * - error privacy: thrown errors and accepted:false outcomes emit a logger + * warning that does NOT contain requestId, reason, or error.message; + * - duplicates pass through: the same (requestId, intent) is forwarded + * every time — the outbox downstream owns dedup. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; + +vi.mock('../logger.js', () => { + const logger = { + setTags: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + withFields: vi.fn(), + }; + logger.withFields.mockReturnValue(logger); + return { logger }; +}); + +import { logger } from '../logger.js'; +import { + scheduleCloudAgentAttention, + type CloudAgentAttentionDeps, +} from './cloud-agent-attention-scheduler.js'; +import type { AttentionEvent } from '../websocket/ingest-attention-classifier.js'; +import type { SessionMetadata } from './session-metadata.js'; +import type { + RecordCloudAgentSessionAttentionParams, + RecordCloudAgentSessionAttentionResult, +} from '@kilocode/session-ingest-contracts'; + +type RecordAttentionFn = ( + params: RecordCloudAgentSessionAttentionParams +) => Promise; + +type AttentionEnv = CloudAgentAttentionDeps['env']; + +const ROOT_KILO_SESSION_ID = 'kilo_root_session'; + +function makeAttentionEvent( + intent: AttentionEvent['intent'], + requestId = 'req_1', + sourceKiloSessionId: string = ROOT_KILO_SESSION_ID +): AttentionEvent { + return { requestId, intent, sourceKiloSessionId }; +} + +function makeMetadata(overrides?: { userId?: string; kiloSessionId?: string }): { + identity: { userId?: string }; + auth: { kiloSessionId?: string }; +} { + return { + identity: { + userId: overrides && 'userId' in overrides ? overrides.userId : 'user_1', + }, + auth: { + kiloSessionId: + overrides && 'kiloSessionId' in overrides ? overrides.kiloSessionId : ROOT_KILO_SESSION_ID, + }, + }; +} + +function makeDeps(overrides?: { + metadata?: ReturnType | null; + recordCloudAgentSessionAttention?: Mock; + sessionId?: string; +}): { + deps: CloudAgentAttentionDeps; + recordCloudAgentSessionAttention: Mock; + getMetadata: ReturnType; +} { + const recordCloudAgentSessionAttention = + overrides?.recordCloudAgentSessionAttention ?? + vi.fn().mockResolvedValue({ accepted: true }); + const env: AttentionEnv = { + SESSION_INGEST: { recordCloudAgentSessionAttention }, + }; + const getMetadata = vi + .fn() + .mockResolvedValue( + overrides?.metadata === null ? null : (overrides?.metadata ?? makeMetadata()) + ); + const deps: CloudAgentAttentionDeps = { + sessionId: (overrides?.sessionId ?? 'sess_test') as CloudAgentAttentionDeps['sessionId'], + getMetadata: getMetadata as unknown as () => Promise, + env, + }; + return { deps, recordCloudAgentSessionAttention, getMetadata }; +} + +async function flushWaitUntil(promise: Promise): Promise { + await promise; + await new Promise(resolve => setImmediate(resolve)); +} + +function getWarnCalls(): string { + const calls = vi.mocked(logger.warn).mock.calls; + return calls.map((args: unknown[]) => args.map(String).join(' ')).join('\n'); +} + +describe('scheduleCloudAgentAttention', () => { + beforeEach(() => { + vi.mocked(logger.warn).mockClear(); + }); + + afterEach(() => { + vi.mocked(logger.warn).mockClear(); + }); + + it('forwards the safe payload shape to the binding', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps(); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention( + { waitUntil }, + deps, + makeAttentionEvent({ raise: 'question' }, 'req_q') + ); + + expect(waitUntil).toHaveBeenCalledTimes(1); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + expect(recordCloudAgentSessionAttention).toHaveBeenCalledTimes(1); + expect(recordCloudAgentSessionAttention).toHaveBeenCalledWith({ + kiloUserId: 'user_1', + kiloSessionId: ROOT_KILO_SESSION_ID, + requestId: 'req_q', + intent: { kind: 'raise', reason: 'question' }, + }); + }); + + it('maps a permission raise to { kind: "raise", reason: "permission" }', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps(); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention( + { waitUntil }, + deps, + makeAttentionEvent({ raise: 'permission' }, 'req_p') + ); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(recordCloudAgentSessionAttention).toHaveBeenCalledWith( + expect.objectContaining({ intent: { kind: 'raise', reason: 'permission' } }) + ); + }); + + it.each([ + { intent: { resolve: 'question' } as const, reason: 'question' as const }, + { intent: { resolve: 'permission' } as const, reason: 'permission' as const }, + ])( + 'maps a resolve intent for $reason to { kind: "resolve", reason: "$reason" }', + async ({ intent, reason }) => { + const { deps, recordCloudAgentSessionAttention } = makeDeps(); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention( + { waitUntil }, + deps, + makeAttentionEvent(intent, `req_r_${reason}`) + ); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(recordCloudAgentSessionAttention).toHaveBeenCalledWith({ + kiloUserId: 'user_1', + kiloSessionId: ROOT_KILO_SESSION_ID, + requestId: `req_r_${reason}`, + intent: { kind: 'resolve', reason }, + }); + } + ); + + it('forwards the payload exactly once per call and does not coalesce duplicates', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps(); + const waitUntil = vi.fn((p: Promise) => p); + + const event = makeAttentionEvent({ raise: 'question' }, 'req_dup'); + scheduleCloudAgentAttention({ waitUntil }, deps, event); + scheduleCloudAgentAttention({ waitUntil }, deps, event); + scheduleCloudAgentAttention({ waitUntil }, deps, event); + + expect(waitUntil).toHaveBeenCalledTimes(3); + await Promise.all(waitUntil.mock.calls.map(([p]) => flushWaitUntil(p as Promise))); + expect(recordCloudAgentSessionAttention).toHaveBeenCalledTimes(3); + expect(recordCloudAgentSessionAttention).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + requestId: 'req_dup', + intent: { kind: 'raise', reason: 'question' }, + }) + ); + expect(recordCloudAgentSessionAttention).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + requestId: 'req_dup', + intent: { kind: 'raise', reason: 'question' }, + }) + ); + }); + + it('is a no-op when metadata is missing and never calls the binding', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps({ metadata: null }); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' })); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('is a no-op when kiloSessionId is missing and never calls the binding', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps({ + metadata: makeMetadata({ kiloSessionId: undefined }), + }); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' })); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('is a no-op when userId is missing and never calls the binding', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps({ + metadata: makeMetadata({ userId: undefined }), + }); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' })); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('is a no-op when event sourceKiloSessionId does not match metadata and never calls the binding', async () => { + const { deps, recordCloudAgentSessionAttention } = makeDeps(); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention( + { waitUntil }, + deps, + makeAttentionEvent({ raise: 'question' }, 'req_child', 'kilo_child_session') + ); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + const joined = getWarnCalls(); + expect(joined).not.toContain('req_child'); + expect(joined).not.toContain('kilo_child_session'); + expect(joined).not.toContain(ROOT_KILO_SESSION_ID); + }); + + it('emits a privacy-safe warn log when the binding throws — no requestId, no reason, no error message', async () => { + const secretMessage = 'provider-secret-token=abc123 stack=deep'; + const recordCloudAgentSessionAttention = vi + .fn() + .mockRejectedValue(new Error(`Boom: ${secretMessage}`)); + const { deps } = makeDeps({ recordCloudAgentSessionAttention }); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention( + { waitUntil }, + deps, + makeAttentionEvent({ raise: 'question' }, 'req_secret') + ); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(logger.warn).toHaveBeenCalled(); + const joined = getWarnCalls(); + expect(joined).not.toContain('req_secret'); + expect(joined).not.toContain('question'); + expect(joined).not.toContain('permission'); + expect(joined).not.toContain('resolve'); + expect(joined).not.toContain(secretMessage); + expect(joined).not.toContain('Boom'); + expect(joined).not.toContain('provider-secret-token'); + }); + + it('emits a privacy-safe warn log when the binding returns accepted:false — no requestId or reason', async () => { + const recordCloudAgentSessionAttention = vi + .fn() + .mockResolvedValue({ accepted: false, reason: 'deleted' }); + const { deps } = makeDeps({ recordCloudAgentSessionAttention }); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention( + { waitUntil }, + deps, + makeAttentionEvent({ raise: 'permission' }, 'req_declined') + ); + await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise); + + expect(logger.warn).toHaveBeenCalled(); + const joined = getWarnCalls(); + expect(joined).not.toContain('req_declined'); + expect(joined).not.toContain('permission'); + expect(joined).not.toContain('deleted'); + }); + + it('does not throw to the caller when the binding rejects (non-fatal)', async () => { + const recordCloudAgentSessionAttention = vi + .fn() + .mockRejectedValue(new Error('upstream gone')); + const { deps } = makeDeps({ recordCloudAgentSessionAttention }); + const waitUntil = vi.fn((p: Promise) => p); + + scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' })); + + await expect( + flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise) + ).resolves.toBeUndefined(); + }); +}); diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index 001c1eefb2..c97a46d865 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -21,6 +21,7 @@ import { logger } from '../logger.js'; import { BUILTIN_AGENT_MODES, Limits } from '../schema.js'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; import migrations from '../../drizzle/migrations'; +import { scheduleCloudAgentAttention } from './cloud-agent-attention-scheduler.js'; import { createExecutionQueries, createEventQueries, @@ -826,6 +827,21 @@ export class CloudAgentSession extends DurableObject { : params ); }, + // Synchronous waitUntil ownership: the ingest callback runs on the + // WS request path, so we hand any external IO to ctx.waitUntil and + // resolve immediately. Duplicates replay each time; the outbox + // downstream of recordCloudAgentSessionAttention owns dedup. + onAttentionEvent: event => { + scheduleCloudAgentAttention( + this.ctx, + { + sessionId: this.sessionId, + getMetadata: () => this.getMetadata(), + env: this.env, + }, + event + ); + }, }; this.ingestHandler = createIngestHandler( diff --git a/services/cloud-agent-next/src/persistence/cloud-agent-attention-scheduler.ts b/services/cloud-agent-next/src/persistence/cloud-agent-attention-scheduler.ts new file mode 100644 index 0000000000..e4ef4823bf --- /dev/null +++ b/services/cloud-agent-next/src/persistence/cloud-agent-attention-scheduler.ts @@ -0,0 +1,94 @@ +/** + * Synchronous scheduling helper for Cloud Agent attention events. + * + * This module owns the thin adapter that forwards classified wrapper + * attention events (question/permission raises and resolves) to the + * SESSION_INGEST service. It is kept separate from CloudAgentSession so + * the private-field-heavy DO can be excluded from unit tests: the contract + * only needs a session identity, a metadata accessor, and the service binding. + */ +import type { + RecordCloudAgentSessionAttentionParams, + RecordCloudAgentSessionAttentionResult, +} from '@kilocode/session-ingest-contracts'; +import { logger } from '../logger.js'; +import type { AttentionEvent } from '../websocket/ingest-attention-classifier.js'; +import type { SessionId } from '../types/ids.js'; +import type { SessionMetadata } from './session-metadata.js'; + +export type CloudAgentAttentionDeps = { + sessionId: SessionId | undefined; + getMetadata: () => Promise; + env: { + SESSION_INGEST: { + recordCloudAgentSessionAttention: ( + params: RecordCloudAgentSessionAttentionParams + ) => Promise; + }; + }; +}; + +/** + * Synchronously hand an attention event off to `ctx.waitUntil`. The async body + * fetches session metadata, validates identity, maps the classified intent to + * the ingest contract shape, and calls `recordCloudAgentSessionAttention`. + * + * Failures are non-fatal and privacy-safe: no `requestId`, reason, payload, + * or error message is ever logged — only the session identity, which the worker + * already knows. Duplicates are forwarded each time; the outbox downstream owns + * deduplication. + */ +export function scheduleCloudAgentAttention( + ctx: { waitUntil: (promise: Promise) => void }, + deps: CloudAgentAttentionDeps, + event: AttentionEvent +): void { + ctx.waitUntil(runCloudAgentAttentionEvent(deps, event)); +} + +async function runCloudAgentAttentionEvent( + deps: CloudAgentAttentionDeps, + event: AttentionEvent +): Promise { + try { + const metadata = await deps.getMetadata(); + if (!metadata) { + logger + .withFields({ sessionId: deps.sessionId }) + .warn('Cloud Agent attention event dropped: metadata missing'); + return; + } + const kiloUserId = metadata.identity.userId; + const kiloSessionId = metadata.auth.kiloSessionId; + if (!kiloUserId || !kiloSessionId) { + logger + .withFields({ sessionId: deps.sessionId }) + .warn('Cloud Agent attention event dropped: session identity missing'); + return; + } + if (event.sourceKiloSessionId !== kiloSessionId) { + logger + .withFields({ sessionId: deps.sessionId }) + .warn('Cloud Agent attention event dropped: source session mismatch'); + return; + } + const intent = + 'raise' in event.intent + ? { kind: 'raise' as const, reason: event.intent.raise } + : { kind: 'resolve' as const, reason: event.intent.resolve }; + const result = await deps.env.SESSION_INGEST.recordCloudAgentSessionAttention({ + kiloUserId, + kiloSessionId, + requestId: event.requestId, + intent, + }); + if (!result.accepted) { + logger + .withFields({ sessionId: deps.sessionId }) + .warn('Cloud Agent attention event not accepted'); + } + } catch { + // Privacy-safe: do not log requestId, reason, or error message. + logger.withFields({ sessionId: deps.sessionId }).warn('Cloud Agent attention event failed'); + } +} diff --git a/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts new file mode 100644 index 0000000000..9bc5a77ebd --- /dev/null +++ b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest'; +import { classifyAttentionKilocodeEvent } from './ingest-attention-classifier.js'; + +const SOURCE_SESSION_ID = 'kilo_session_source'; + +describe('classifyAttentionKilocodeEvent', () => { + describe('raise mappings', () => { + it.each([ + ['question.asked', 'question'], + ['permission.asked', 'permission'], + ] as const)('%s with nested properties.id raises %s', (eventName, kind) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + properties: { id: 'req_nested', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toEqual({ + requestId: 'req_nested', + intent: { raise: kind }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it.each([ + ['question.asked', 'question'], + ['permission.asked', 'permission'], + ] as const)('%s with direct data.id fallback raises %s', (eventName, kind) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + id: 'req_direct', + sessionID: SOURCE_SESSION_ID, + }); + expect(result).toEqual({ + requestId: 'req_direct', + intent: { raise: kind }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it('prefers properties.id over data.id for raise', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + id: 'req_direct', + sessionID: 'top_session', + properties: { id: 'req_nested', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toEqual({ + requestId: 'req_nested', + intent: { raise: 'question' }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + }); + + describe('resolve mappings', () => { + it.each([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], + ] as const)('%s with nested properties.requestID resolves as %s', (eventName, reason) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + properties: { requestID: 'req_nested', sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toEqual({ + requestId: 'req_nested', + intent: { resolve: reason }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it.each([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], + ] as const)( + '%s with direct top-level data.requestID fallback resolves as %s', + (eventName, reason) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + requestID: 'req_direct', + sessionID: SOURCE_SESSION_ID, + }); + expect(result).toEqual({ + requestId: 'req_direct', + intent: { resolve: reason }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + } + ); + + it.each([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], + ] as const)( + '%s prefers nested properties.requestID over top-level requestID', + (eventName, reason) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + requestID: 'req_top_requestID', + sessionID: 'top_session', + id: 'req_top_id', + properties: { + id: 'req_nested_id', + requestID: 'req_nested_requestID', + sessionID: SOURCE_SESSION_ID, + }, + }); + expect(result).toEqual({ + requestId: 'req_nested_requestID', + intent: { resolve: reason }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + } + ); + + it.each([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], + ] as const)( + '%s prefers requestID over id when nested properties carries both', + (eventName, reason) => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + properties: { + id: 'req_nested_id', + requestID: 'req_nested_requestID', + sessionID: SOURCE_SESSION_ID, + }, + }); + expect(result).toEqual({ + requestId: 'req_nested_requestID', + intent: { resolve: reason }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + } + ); + + it.each(['question.replied', 'question.rejected', 'permission.replied'])( + '%s returns null when no resolve id is present anywhere', + eventName => { + expect( + classifyAttentionKilocodeEvent({ + event: eventName, + properties: { sessionID: SOURCE_SESSION_ID }, + }) + ).toBeNull(); + } + ); + + it.each(['question.replied', 'question.rejected', 'permission.replied'])( + '%s returns null when source sessionID is missing even with requestID', + eventName => { + expect( + classifyAttentionKilocodeEvent({ + event: eventName, + properties: { requestID: 'req_missing_session' }, + }) + ).toBeNull(); + } + ); + }); + + describe('ignored event types', () => { + it.each([ + 'session.status', + 'session.idle', + 'session.diff', + 'session.completed', + 'session.error', + 'session.network.asked', + 'session.network.restored', + 'message.part.delta', + 'message.part.updated', + 'message.updated', + 'message.part.removed', + 'session.created', + 'session.updated', + 'session.turn.close', + 'permission.ask', // partial + 'question.ask', // partial + 'retry.foo', + 'error.bar', + 'suggestion.shown', + 'suggestion.accepted', + 'suggestion.dismissed', + 'unknown', + ])('ignores %s', eventName => { + const result = classifyAttentionKilocodeEvent({ + event: eventName, + id: 'req_1', + properties: { id: 'req_1' }, + }); + expect(result).toBeNull(); + }); + }); + + describe('missing or invalid ID', () => { + it('ignores qualifying event with no id anywhere', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: { sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event with empty string id', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + id: '', + sessionID: SOURCE_SESSION_ID, + properties: { id: '' }, + }); + expect(result).toBeNull(); + }); + + it('ignores a resolve event with no requestID anywhere', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.replied', + properties: { sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event when source sessionID is missing', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: { id: 'req_present' }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event when source sessionID is empty', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: { id: 'req_present', sessionID: '' }, + }); + expect(result).toBeNull(); + }); + + it('ignores qualifying event with non-string id', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + id: 123, + sessionID: SOURCE_SESSION_ID, + properties: { id: { foo: 'bar' }, sessionID: SOURCE_SESSION_ID }, + }); + expect(result).toBeNull(); + }); + + it('ignores when properties is not an object', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: 'not-an-object', + id: 'req_direct', + sessionID: SOURCE_SESSION_ID, + }); + expect(result).toEqual({ + requestId: 'req_direct', + intent: { raise: 'question' }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it('returns null when properties is null and no top-level id', () => { + const result = classifyAttentionKilocodeEvent({ + event: 'question.asked', + properties: null, + }); + expect(result).toBeNull(); + }); + }); + + describe('non-object input', () => { + it.each([null, undefined, 'string', 42, true, []])('returns null for %s', value => { + expect(classifyAttentionKilocodeEvent(value)).toBeNull(); + }); + }); + + describe('missing event name', () => { + it('returns null when event is missing', () => { + expect(classifyAttentionKilocodeEvent({ id: 'req_1' })).toBeNull(); + }); + + it('returns null when event is not a string', () => { + expect(classifyAttentionKilocodeEvent({ event: 42, id: 'req_1' })).toBeNull(); + }); + }); +}); diff --git a/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts new file mode 100644 index 0000000000..547c40c30d --- /dev/null +++ b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts @@ -0,0 +1,111 @@ +/** + * Attention event classifier for kilocode ingest events. + * + * Classifies question and permission events into raise/resolve intents + * for the session attention system. The classifier is pure and synchronous, + * returning a stable requestId, intent, and (when present) the source + * `kiloSessionId` so the caller can filter out events from child sessions + * of this Cloud Agent run, or null for non-attention events. + * + * Authoritative id source per event family: + * - raises (`question.asked`, `permission.asked`) → `properties.id`, + * top-level `data.id` is the fallback (the wrapper's real-time shape + * spreads properties at the top level of `data`). + * - resolves (`question.replied`, `question.rejected`, + * `permission.replied`) → `properties.requestID`, top-level + * `data.requestID` is the fallback. + * + * The source `kiloSessionId` is extracted from `properties.sessionID` + * (authoritative) or top-level `data.sessionID` (fallback). A qualifying + * event without a non-empty source `sessionID` is ignored because the + * scheduler cannot verify it belongs to the root session. + */ + +export type AttentionIntent = + | { raise: 'question' | 'permission' } + | { resolve: 'question' | 'permission' }; + +export type AttentionEvent = { + requestId: string; + intent: AttentionIntent; + sourceKiloSessionId: string; +}; + +const RAISE_KILO_EVENTS: ReadonlyMap = new Map([ + ['question.asked', 'question'], + ['permission.asked', 'permission'], +]); + +const RESOLVE_KILO_EVENTS: ReadonlyMap = new Map([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], +]); + +function readNonEmptyString( + record: Record | null, + key: string +): string | undefined { + if (!record) return undefined; + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readSessionIdFromRecord(record: Record | null): string | undefined { + if (!record) return undefined; + return readNonEmptyString(record, 'sessionID'); +} + +/** + * Classify a kilocode ingest event for the attention system. + * + * Returns a stable requestId and intent, or null when the event is not + * attention-relevant. The wrapper's real-time shape spreads properties at + * the top level of `data`, so we read the event-specific id from the + * nested `properties` first and fall back to the top-level field. Resolves + * prefer `requestID` over `id` (legacy); raises prefer `id`. + * + * @param data - The kilocode event data (already validated as an object) + * @returns AttentionEvent with requestId and intent, or null + */ +export function classifyAttentionKilocodeEvent(data: unknown): AttentionEvent | null { + if (typeof data !== 'object' || data === null) return null; + const dataRecord = data as Record; + const eventName = typeof dataRecord.event === 'string' ? dataRecord.event : undefined; + if (!eventName) return null; + + const raiseReason = RAISE_KILO_EVENTS.get(eventName); + const resolveReason = RESOLVE_KILO_EVENTS.get(eventName); + if (raiseReason === undefined && resolveReason === undefined) return null; + + const intent: AttentionIntent = + raiseReason !== undefined + ? { raise: raiseReason } + : { resolve: resolveReason as 'question' | 'permission' }; + const isResolve = resolveReason !== undefined; + + const properties = + typeof dataRecord.properties === 'object' && dataRecord.properties !== null + ? (dataRecord.properties as Record) + : null; + + // Authoritative nested id; top-level fallback for the wrapper's + // real-time spread shape. Resolves use requestID only — the upstream + // contract guarantees `properties.requestID` and never the legacy `id`, + // and the wrapper spreads `properties` at the top level so the fallback + // still resolves to the same value. Raises use `id`. + let requestId: string | undefined; + if (isResolve) { + requestId = + readNonEmptyString(properties, 'requestID') ?? readNonEmptyString(dataRecord, 'requestID'); + } else { + requestId = readNonEmptyString(properties, 'id') ?? readNonEmptyString(dataRecord, 'id'); + } + if (!requestId) return null; + + const sourceKiloSessionId = + readSessionIdFromRecord(properties) ?? readSessionIdFromRecord(dataRecord); + if (!sourceKiloSessionId) return null; + + return { requestId, intent, sourceKiloSessionId }; +} diff --git a/services/cloud-agent-next/src/websocket/ingest.test.ts b/services/cloud-agent-next/src/websocket/ingest.test.ts index 03f64f8e55..d37697185e 100644 --- a/services/cloud-agent-next/src/websocket/ingest.test.ts +++ b/services/cloud-agent-next/src/websocket/ingest.test.ts @@ -6,6 +6,7 @@ import type { SessionId } from '../types/ids.js'; const SESSION_ID = 'sess_test' as SessionId; const WRAPPER_RUN_ID = 'wr_test_basic'; +const SOURCE_SESSION_ID = 'kilo_session_test'; const itWithWebSocketPair = typeof WebSocketPair === 'undefined' ? it.skip : it; function createFakeState() { @@ -266,6 +267,321 @@ describe('createIngestHandler', () => { ); }); + // --- kilocode events: attention event classification --- + + function makeKilocodeData( + eventName: string, + properties: Record, + options: { spreadId?: boolean } = {} + ): Record { + // Real-time shape spreads properties at the top level of `data`; + // snapshot/replay shape nests them only under `properties`. + const props = { sessionID: SOURCE_SESSION_ID, ...properties }; + return options.spreadId + ? { ...props, event: eventName, type: eventName, properties: props } + : { event: eventName, type: eventName, properties: props }; + } + + function makeKilocodeMessageFromData(data: Record): string { + return JSON.stringify({ + streamEventType: 'kilocode', + data, + timestamp: new Date().toISOString(), + }); + } + + function createAttentionContext(): { + doContext: IngestDOContext; + onAttentionEvent: ReturnType; + } { + const doContext = createFakeDOContext(); + const onAttentionEvent = vi.fn(); + return { doContext: { ...doContext, onAttentionEvent }, onAttentionEvent }; + } + + async function runAttentionMessage( + doContext: IngestDOContext, + data: Record + ): Promise<{ broadcastFn: ReturnType; eventQueries: EventQueries }> { + const eventQueries = createFakeEventQueries(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + eventQueries, + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage(ws, makeKilocodeMessageFromData(data)); + return { broadcastFn, eventQueries }; + } + + it.each([ + { eventName: 'question.asked', expected: { raise: 'question' } }, + { eventName: 'permission.asked', expected: { raise: 'permission' } }, + ])('raise $eventName with nested properties.id', async ({ eventName, expected }) => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const { broadcastFn, eventQueries } = await runAttentionMessage( + doContext, + makeKilocodeData(eventName, { id: 'req_nested' }) + ); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_nested', + intent: expected, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + expect(eventQueries.insert).not.toHaveBeenCalled(); + expect(eventQueries.upsert).not.toHaveBeenCalled(); + }); + + it.each([ + { eventName: 'question.asked', expected: { raise: 'question' } }, + { eventName: 'permission.asked', expected: { raise: 'permission' } }, + ])( + 'raise $eventName with direct top-level data.id (real-time spread shape)', + async ({ eventName, expected }) => { + const { doContext, onAttentionEvent } = createAttentionContext(); + await runAttentionMessage( + doContext, + makeKilocodeData(eventName, { id: 'req_direct' }, { spreadId: true }) + ); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_direct', + intent: expected, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + } + ); + + it.each([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], + ] as const)('resolve %s with nested properties.requestID', async (eventName, reason) => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const { broadcastFn } = await runAttentionMessage( + doContext, + makeKilocodeData(eventName, { requestID: 'req_nested' }) + ); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_nested', + intent: { resolve: reason }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it.each([ + ['question.replied', 'question'], + ['question.rejected', 'question'], + ['permission.replied', 'permission'], + ] as const)( + 'resolve %s with direct top-level data.requestID (real-time spread shape)', + async (eventName, reason) => { + const { doContext, onAttentionEvent } = createAttentionContext(); + await runAttentionMessage( + doContext, + makeKilocodeData(eventName, { requestID: 'req_direct' }, { spreadId: true }) + ); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_direct', + intent: { resolve: reason }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + } + ); + + it.each([ + 'session.status', + 'session.idle', + 'session.diff', + 'session.completed', + 'session.error', + 'session.network.asked', + 'message.part.delta', + 'message.part.updated', + 'message.updated', + 'message.part.removed', + 'session.created', + 'session.updated', + 'session.turn.close', + ])('does not invoke onAttentionEvent for ignored %s', async eventName => { + const { doContext, onAttentionEvent } = createAttentionContext(); + await runAttentionMessage( + doContext, + makeKilocodeData(eventName, { id: 'req_1' }, { spreadId: true }) + ); + expect(onAttentionEvent).not.toHaveBeenCalled(); + }); + + it('does not invoke onAttentionEvent when qualifying event has no id', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const { broadcastFn } = await runAttentionMessage(doContext, { + event: 'question.asked', + type: 'question.asked', + properties: {}, + }); + expect(onAttentionEvent).not.toHaveBeenCalled(); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it('does not invoke onAttentionEvent when qualifying event has empty string id', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + await runAttentionMessage(doContext, { + event: 'question.asked', + type: 'question.asked', + id: '', + properties: { id: '' }, + }); + expect(onAttentionEvent).not.toHaveBeenCalled(); + }); + + it('invokes callback while broadcast payload remains unchanged', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const eventQueries = createFakeEventQueries(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + eventQueries, + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + const data = makeKilocodeData('question.asked', { id: 'req_nested' }, { spreadId: true }); + await handler.handleIngestMessage(ws, makeKilocodeMessageFromData(data)); + const baselinePayload = JSON.stringify(data); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_nested', + intent: { raise: 'question' }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + const broadcastCall = vi.mocked(broadcastFn).mock.calls[0]?.[0]; + expect(broadcastCall).toBeDefined(); + expect(broadcastCall?.id).toBe(0); + expect(broadcastCall?.stream_event_type).toBe('kilocode'); + // Broadcast payload remains exactly what sanitization produced — + // adding the attention callback did not change what /stream clients receive. + expect(broadcastCall?.payload).toBe(baselinePayload); + expect(eventQueries.insert).not.toHaveBeenCalled(); + expect(eventQueries.upsert).not.toHaveBeenCalled(); + }); + + it('invokes callback on duplicate same qualifying event (outbox owns dedup)', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const eventQueries = createFakeEventQueries(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + eventQueries, + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + const message = makeKilocodeMessageFromData( + makeKilocodeData('question.asked', { id: 'req_dup' }, { spreadId: true }) + ); + + await handler.handleIngestMessage(ws, message); + await handler.handleIngestMessage(ws, message); + + expect(onAttentionEvent).toHaveBeenCalledTimes(2); + expect(onAttentionEvent).toHaveBeenNthCalledWith(1, { + requestId: 'req_dup', + intent: { raise: 'question' }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + expect(onAttentionEvent).toHaveBeenNthCalledWith(2, { + requestId: 'req_dup', + intent: { raise: 'question' }, + sourceKiloSessionId: SOURCE_SESSION_ID, + }); + }); + + it('does not invoke callback when no onAttentionEvent is registered', async () => { + const doContext = createFakeDOContext(); // no onAttentionEvent + const eventQueries = createFakeEventQueries(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + eventQueries, + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + await handler.handleIngestMessage( + ws, + makeKilocodeMessageFromData( + makeKilocodeData('question.asked', { id: 'req_1' }, { spreadId: true }) + ) + ); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + expect(eventQueries.insert).not.toHaveBeenCalled(); + }); + + it('does not invoke onAttentionEvent when source sessionID is missing', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const { broadcastFn } = await runAttentionMessage(doContext, { + event: 'question.asked', + type: 'question.asked', + properties: { id: 'req_no_session' }, + }); + expect(onAttentionEvent).not.toHaveBeenCalled(); + expect(broadcastFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 0, stream_event_type: 'kilocode' }) + ); + }); + + it('invokes callback with child source sessionID while broadcast remains unchanged', async () => { + const { doContext, onAttentionEvent } = createAttentionContext(); + const eventQueries = createFakeEventQueries(); + const broadcastFn = vi.fn(); + const handler = createIngestHandler( + createFakeState(), + eventQueries, + SESSION_ID, + broadcastFn, + doContext + ); + const ws = createFakeWebSocket(makeAttachment()); + const childSessionId = 'kilo_child_session'; + const data = makeKilocodeData( + 'question.asked', + { id: 'req_child', sessionID: childSessionId }, + { spreadId: true } + ); + await handler.handleIngestMessage(ws, makeKilocodeMessageFromData(data)); + const baselinePayload = JSON.stringify(data); + expect(onAttentionEvent).toHaveBeenCalledTimes(1); + expect(onAttentionEvent).toHaveBeenCalledWith({ + requestId: 'req_child', + intent: { raise: 'question' }, + sourceKiloSessionId: childSessionId, + }); + const broadcastCall = vi.mocked(broadcastFn).mock.calls[0]?.[0]; + expect(broadcastCall).toBeDefined(); + expect(broadcastCall?.payload).toBe(baselinePayload); + expect(eventQueries.insert).not.toHaveBeenCalled(); + expect(eventQueries.upsert).not.toHaveBeenCalled(); + }); + // --- non-kilocode: plain insert path (PERSISTED_STREAM_EVENT_TYPES) --- it.each(['complete', 'interrupted', 'error', 'autocommit_started', 'autocommit_completed'])( diff --git a/services/cloud-agent-next/src/websocket/ingest.ts b/services/cloud-agent-next/src/websocket/ingest.ts index ab4b564b40..9a6af4483a 100644 --- a/services/cloud-agent-next/src/websocket/ingest.ts +++ b/services/cloud-agent-next/src/websocket/ingest.ts @@ -36,6 +36,10 @@ import type { WrapperSupervisor, WrapperTerminalEvent } from '../session/wrapper import type { TerminalizeParams } from '../session/session-message-state.js'; import { classifyAssistantFailureMessage } from '../session/safe-failure-projection.js'; import { parseModelNotFoundRuntimeDiagnostics } from '../shared/runtime-model-diagnostics.js'; +import { + classifyAttentionKilocodeEvent, + type AttentionEvent, +} from './ingest-attention-classifier.js'; // --------------------------------------------------------------------------- // Ingest Attachment @@ -253,6 +257,13 @@ export type IngestDOContext = { ) => Promise; /** Persist the slash-command catalog so connecting clients can be hydrated. */ setAvailableCommands: (commands: SlashCommandInfo[]) => Promise; + /** + * Optional callback invoked for qualifying question/permission kilocode + * events. Synchronous or returns void; CloudAgentSession later owns + * waitUntil for any external notification IO. Duplicate snapshots replay + * the callback each time; the outbox owns dedup. + */ + onAttentionEvent?: (event: AttentionEvent) => void; }; // --------------------------------------------------------------------------- @@ -731,6 +742,10 @@ export function createIngestHandler( } ); ws.serializeAttachment(attachment); + const attention = classifyAttentionKilocodeEvent(ingestEvent.data); + if (attention) { + doContext.onAttentionEvent?.(attention); + } } else { console.warn('Invalid kilocode event payload'); } diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index e8bce7b0a8..1656de8e2f 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -17,6 +17,8 @@ import { type DispatchPushOutcome, type SendCloudAgentSessionNotificationParams, type SendCloudAgentSessionNotificationResult, + type SendSessionAttentionNotificationParams, + type SendSessionAttentionNotificationResult, type SendSessionReadyNotificationParams, type SendSessionReadyNotificationResult, type SendInstanceLifecycleNotificationParams, @@ -31,6 +33,7 @@ import { import { authMiddleware, type AuthContext } from './auth'; import { dispatchCloudAgentSessionPush, + dispatchSessionAttentionPush, dispatchSessionReadyPush, type DispatchCloudAgentSessionPushDeps, } from './lib/cloud-agent-session-push'; @@ -237,6 +240,12 @@ export class NotificationsService extends WorkerEntrypoint { return dispatchSessionReadyPush(params, this.cloudAgentSessionPushDeps()); } + async sendSessionAttentionNotification( + params: SendSessionAttentionNotificationParams + ): Promise { + return dispatchSessionAttentionPush(params, this.cloudAgentSessionPushDeps()); + } + private cloudAgentSessionPushDeps(): DispatchCloudAgentSessionPushDeps { let db: ReturnType | undefined; const getDbForCall = () => (db ??= getWorkerDb(this.env.HYPERDRIVE.connectionString)); diff --git a/services/notifications/src/lib/cloud-agent-session-push.ts b/services/notifications/src/lib/cloud-agent-session-push.ts index ce11537916..fcf4ee248f 100644 --- a/services/notifications/src/lib/cloud-agent-session-push.ts +++ b/services/notifications/src/lib/cloud-agent-session-push.ts @@ -1,13 +1,20 @@ -import { presenceContextForPlatform } from '@kilocode/event-service'; +import { + presenceContextForAgentSession, + presenceContextForPlatform, +} from '@kilocode/event-service'; import { sendCloudAgentSessionNotificationInputSchema, + sendSessionAttentionNotificationInputSchema, sendSessionReadyNotificationInputSchema, type DispatchPushInput, type DispatchPushOutcome, type SendCloudAgentSessionNotificationParams, type SendCloudAgentSessionNotificationResult, + type SendSessionAttentionNotificationParams, + type SendSessionAttentionNotificationResult, type SendSessionReadyNotificationParams, type SendSessionReadyNotificationResult, + type SessionAttentionReason, } from '@kilocode/notifications'; type CloudAgentNotificationSession = { @@ -31,22 +38,36 @@ type SessionPushContent = { body: string; }; -async function dispatchSessionPush( +/** + * Resolve the session for `cliSessionId` and confirm the caller still has + * current organization membership if the session belongs to an org. Returns + * `null` when the session is absent or access has been revoked, which the + * callers translate into a terminal `missing_session` result. + */ +async function loadOwnedSession( userId: string, cliSessionId: string, - buildContent: (session: CloudAgentNotificationSession) => SessionPushContent, deps: DispatchCloudAgentSessionPushDeps -): Promise { +): Promise { const session = await deps.getSession(userId, cliSessionId); - - if (!session) { - return { dispatched: false, reason: 'missing_session' }; - } - + if (!session) return null; if ( session.organizationId && !(await deps.hasOrganizationAccess(userId, session.organizationId)) ) { + return null; + } + return session; +} + +async function dispatchSessionPush( + userId: string, + cliSessionId: string, + buildContent: (session: CloudAgentNotificationSession) => SessionPushContent, + deps: DispatchCloudAgentSessionPushDeps +): Promise { + const session = await loadOwnedSession(userId, cliSessionId, deps); + if (!session) { return { dispatched: false, reason: 'missing_session' }; } @@ -112,3 +133,75 @@ export async function dispatchSessionReadyPush( deps ); } + +// ── Session attention (actionable human waits) ────────────────────── + +/** + * Fixed, lock-screen-safe copy per attention reason. Producers must never + * supply a body or title — the notifications service rejects caller-provided + * text to keep pushes safe. + */ +const SESSION_ATTENTION_COPY: Record = { + question: { + title: 'Kilo session needs your input', + body: 'Your Kilo session is asking a question', + }, + permission: { + title: 'Kilo session needs permission', + body: 'Your Kilo session is waiting for permission to continue', + }, + blocking_suggestion: { + title: 'Kilo session needs a decision', + body: 'Your Kilo session has a suggestion that needs your review', + }, + action_required: { + title: 'Kilo session needs you', + body: 'Your Kilo session is waiting for you to take action', + }, +}; + +export type DispatchSessionAttentionPushDeps = DispatchCloudAgentSessionPushDeps; + +/** + * Notification sent when a Cloud Agent / remote CLI session hits a + * human-actionable wait (question, permission, blocking suggestion, or + * other action-required state). The presence context is the exact session, + * so the push is suppressed only when the user is actively viewing that + * session — not the whole app. The idempotency key includes the stable + * requestId so retries on the same upstream request collapse, but distinct + * requests stay independent. + */ +export async function dispatchSessionAttentionPush( + params: SendSessionAttentionNotificationParams, + deps: DispatchSessionAttentionPushDeps +): Promise { + const parsed = sendSessionAttentionNotificationInputSchema.parse(params); + const copy = SESSION_ATTENTION_COPY[parsed.reason]; + const session = await loadOwnedSession(parsed.userId, parsed.cliSessionId, deps); + if (!session) { + return { dispatched: false, reason: 'missing_session' }; + } + + const outcome = await deps.dispatchPush({ + userId: parsed.userId, + presenceContext: presenceContextForAgentSession(parsed.cliSessionId), + idempotencyKey: `cloud-agent:${parsed.cliSessionId}:attention:${parsed.reason}:${parsed.requestId}`, + badge: null, + push: { + title: copy.title, + body: copy.body, + data: { type: 'cloud_agent_session', cliSessionId: parsed.cliSessionId }, + sound: 'default', + priority: 'high', + }, + } satisfies DispatchPushInput); + + if (outcome.kind === 'suppressed_presence') { + return { dispatched: false, reason: 'suppressed_presence' }; + } + if (outcome.kind === 'failed') { + return { dispatched: false, reason: 'dispatch_failed' }; + } + + return { dispatched: true }; +} diff --git a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts index a0bd1f9bdf..a3844a2184 100644 --- a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts +++ b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts @@ -4,6 +4,7 @@ import { type DispatchPushInput, type DispatchPushOutcome } from '@kilocode/noti import { dispatchCloudAgentSessionPush, + dispatchSessionAttentionPush, dispatchSessionReadyPush, type DispatchCloudAgentSessionPushDeps, } from './cloud-agent-session-push'; @@ -277,3 +278,242 @@ describe('dispatchSessionReadyPush', () => { expect(result).toEqual({ dispatched: false, reason: 'dispatch_failed' }); }); }); + +describe('dispatchSessionAttentionPush', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockDispatchPush.mockResolvedValue({ kind: 'delivered', tokenCount: 1 }); + }); + + it.each([ + ['question', 'Kilo session needs your input', 'Your Kilo session is asking a question'], + [ + 'permission', + 'Kilo session needs permission', + 'Your Kilo session is waiting for permission to continue', + ], + [ + 'blocking_suggestion', + 'Kilo session needs a decision', + 'Your Kilo session has a suggestion that needs your review', + ], + [ + 'action_required', + 'Kilo session needs you', + 'Your Kilo session is waiting for you to take action', + ], + ] as const)( + 'dispatches fixed safe copy for reason=%s with exact-session presence', + async (reason, title, body) => { + const deps = createDeps({ session: { title: 'Title', organizationId: null } }); + + const result = await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason }, + deps + ); + + expect(result).toEqual({ dispatched: true }); + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + presenceContext: '/presence/agent-session/ses_1', + idempotencyKey: `cloud-agent:ses_1:attention:${reason}:req_1`, + badge: null, + push: { + title, + body, + data: { type: 'cloud_agent_session', cliSessionId: 'ses_1' }, + sound: 'default', + priority: 'high', + }, + }) + ); + } + ); + + it('ignores any caller-provided body and only uses fixed copy', async () => { + const deps = createDeps({ session: { title: 'Title', organizationId: null } }); + + // The schema has no text/body field and strips unknown keys, so any + // caller-provided text is silently dropped and the fixed copy is used. + await expect( + dispatchSessionAttentionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + requestId: 'req_1', + reason: 'question', + // @ts-expect-error -- callers must not be able to inject free-form copy + body: 'leak this', + }, + deps + ) + ).resolves.toEqual({ dispatched: true }); + + const call = mockDispatchPush.mock.calls[0]?.[0]; + expect(call?.push.body).toBe('Your Kilo session is asking a question'); + expect(call?.push.body).not.toContain('leak this'); + }); + + it('uses distinct idempotency keys for distinct requests and reasons', async () => { + const deps = createDeps(); + + await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' }, + deps + ); + await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_2', reason: 'question' }, + deps + ); + await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'permission' }, + deps + ); + + expect(mockDispatchPush).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_1' }) + ); + expect(mockDispatchPush).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_2' }) + ); + expect(mockDispatchPush).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:permission:req_1' }) + ); + }); + + it('reuses the same idempotency key when the same request is retried', async () => { + const deps = createDeps(); + + const params = { + userId: 'user-1', + cliSessionId: 'ses_1', + requestId: 'req_dup', + reason: 'question', + } as const; + await dispatchSessionAttentionPush(params, deps); + await dispatchSessionAttentionPush(params, deps); + + expect(mockDispatchPush).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_dup' }) + ); + expect(mockDispatchPush).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_dup' }) + ); + }); + + it('returns missing_session without dispatching when the session row is absent', async () => { + const deps = createDeps({ session: null }); + + const result = await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_missing', requestId: 'req_1', reason: 'question' }, + deps + ); + + expect(result).toEqual({ dispatched: false, reason: 'missing_session' }); + expect(mockDispatchPush).not.toHaveBeenCalled(); + }); + + it('returns missing_session when org membership has been revoked', async () => { + const deps = createDeps({ + session: { title: 'Org session', organizationId: 'org-1' }, + hasOrganizationAccess: false, + }); + + const result = await dispatchSessionAttentionPush( + { + userId: 'former-member', + cliSessionId: 'ses_org', + requestId: 'req_1', + reason: 'permission', + }, + deps + ); + + expect(result).toEqual({ dispatched: false, reason: 'missing_session' }); + expect(mockDispatchPush).not.toHaveBeenCalled(); + expect(deps.hasOrganizationAccess).toHaveBeenCalledWith('former-member', 'org-1'); + }); + + it('propagates presence-suppressed outcome from the recipient DO', async () => { + const deps = createDeps(); + mockDispatchPush.mockResolvedValue({ kind: 'suppressed_presence' }); + + const result = await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' }, + deps + ); + + expect(result).toEqual({ dispatched: false, reason: 'suppressed_presence' }); + }); + + it('propagates dispatch failures from the recipient notification channel', async () => { + const deps = createDeps(); + mockDispatchPush.mockResolvedValue({ kind: 'failed', error: 'Expo unavailable' }); + + const result = await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' }, + deps + ); + + expect(result).toEqual({ dispatched: false, reason: 'dispatch_failed' }); + }); + + it('treats delivered, duplicate, and no_tokens as dispatched', async () => { + const outcomes: DispatchPushOutcome[] = [ + { kind: 'delivered', tokenCount: 2 }, + { kind: 'duplicate' }, + { kind: 'no_tokens' }, + ]; + for (const outcome of outcomes) { + const deps = createDeps(); + mockDispatchPush.mockResolvedValueOnce(outcome); + const result = await dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' }, + deps + ); + expect(result).toEqual({ dispatched: true }); + } + }); + + it('rejects invalid params (empty IDs, unknown reason) before reading session data', async () => { + const deps = createDeps(); + + await expect( + dispatchSessionAttentionPush( + { userId: '', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' }, + deps + ) + ).rejects.toThrow(); + await expect( + dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: '', requestId: 'req_1', reason: 'question' }, + deps + ) + ).rejects.toThrow(); + await expect( + dispatchSessionAttentionPush( + { userId: 'user-1', cliSessionId: 'ses_1', requestId: '', reason: 'question' }, + deps + ) + ).rejects.toThrow(); + await expect( + dispatchSessionAttentionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + requestId: 'req_1', + // @ts-expect-error -- unknown reason must be rejected + reason: 'unknown', + }, + deps + ) + ).rejects.toThrow(); + expect(deps.getSession).not.toHaveBeenCalled(); + }); +}); diff --git a/services/session-ingest/drizzle/0003_session_attention_outbox.sql b/services/session-ingest/drizzle/0003_session_attention_outbox.sql new file mode 100644 index 0000000000..6be72cda7f --- /dev/null +++ b/services/session-ingest/drizzle/0003_session_attention_outbox.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS `attention_outbox` ( + `request_id` text PRIMARY KEY NOT NULL, + `reason` text NOT NULL, + `status` text NOT NULL DEFAULT 'pending', + `attempt_count` integer NOT NULL DEFAULT 0, + `next_attempt_at` integer, + `last_error` text, + `raised_at` integer NOT NULL, + `resolved_at` integer +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `attention_outbox_pending_idx` ON `attention_outbox` (`status`, `next_attempt_at`); diff --git a/services/session-ingest/drizzle/meta/0003_snapshot.json b/services/session-ingest/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000000..c5b9b71061 --- /dev/null +++ b/services/session-ingest/drizzle/meta/0003_snapshot.json @@ -0,0 +1,196 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "session-attention-outbox", + "prevId": "7f8bb7f8-414e-4875-bab2-3e6ce4de7a40", + "tables": { + "ingest_items": { + "name": "ingest_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_data": { + "name": "item_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_data_r2_key": { + "name": "item_data_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingested_at": { + "name": "ingested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ingest_items_item_id_unique": { + "name": "ingest_items_item_id_unique", + "columns": ["item_id"], + "isUnique": true + }, + "ingest_items_ingested_at_id_idx": { + "name": "ingest_items_ingested_at_id_idx", + "columns": ["ingested_at", "id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ingest_meta": { + "name": "ingest_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "attention_outbox": { + "name": "attention_outbox", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raised_at": { + "name": "raised_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "attention_outbox_pending_idx": { + "name": "attention_outbox_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/services/session-ingest/drizzle/meta/_journal.json b/services/session-ingest/drizzle/meta/_journal.json index 15e9d70a69..2e071b906d 100644 --- a/services/session-ingest/drizzle/meta/_journal.json +++ b/services/session-ingest/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1780499751344, "tag": "0002_watery_venus", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1781800000000, + "tag": "0003_session_attention_outbox", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/services/session-ingest/drizzle/migrations.js b/services/session-ingest/drizzle/migrations.js index 36e171cb3f..31ddab7ce8 100644 --- a/services/session-ingest/drizzle/migrations.js +++ b/services/session-ingest/drizzle/migrations.js @@ -2,6 +2,7 @@ import journal from './meta/_journal.json'; import m0000 from './0000_redundant_slyde.sql'; import m0001 from './0001_common_blackheart.sql'; import m0002 from './0002_watery_venus.sql'; +import m0003 from './0003_session_attention_outbox.sql'; export default { journal, @@ -9,5 +10,6 @@ export default { m0000, m0001, m0002, + m0003, }, }; diff --git a/services/session-ingest/src/attention-outbox-store.ts b/services/session-ingest/src/attention-outbox-store.ts new file mode 100644 index 0000000000..142fc2191e --- /dev/null +++ b/services/session-ingest/src/attention-outbox-store.ts @@ -0,0 +1,651 @@ +/** + * Durable per-session outbox for human-attention pushes. + * + * One row per stable upstream request id (a question id, permission id, or + * blocking-suggestion id). Rows are created lazily on the first `*.asked` and + * transitioned to a terminal status (`dispatched`, `suppressed_presence`, + * `missing_session`, `failed`, or `resolved`) by the alarm dispatch loop. + * + * Concurrency: this module is called from inside `SessionIngestDO` where the + * platform serializes input gate access per DO, so individual statements are + * safe to issue back-to-back without an explicit transaction. The DO alarm + * fires single-threaded per DO, which is what makes the dispatch loop's + * `pending → in_flight → terminal` sequence atomic at the row level. + * + * The store never stores event bodies, prompt text, or any envelope payload — + * only stable request ids, reasons, and the terminal status of the dispatch + * attempt. The raw event data is not logged or persisted for retries. + * + * Transition rules (enforced by the `apply*` helpers and the drizzle layer): + * - `recordRaiseIntent`: insert a fresh `pending` row; idempotent on + * request id (re-raise of a `resolved`/terminal row is a no-op). + * - `recordResolveIntent`: `pending`/`in_flight` → `resolved`, sets + * `resolvedAt`, clears `nextAttemptAt`/`lastError`. Terminal rows only + * gain `resolvedAt` (the status stays terminal) and stay no-op on a + * repeat resolve. + * - `claimNextDispatchable`: lease the next due `pending` row by flipping + * it to `in_flight`. + * - `markDispatched` / `markSuppressedByPresence` / `markMissingSession`: + * terminal transitions, idempotent. + * - `markRetry`: bump `attemptCount`, schedule next attempt; at + * `MAX_ATTEMPTS` park at terminal `failed` to stop the alarm loop. + * No-op for any terminal row. + * - `recoverStaleInFlightRows`: alarm-restart recovery — any row left in + * `in_flight` is bumped to `pending` with a fresh `nextAttemptAt`, or + * parked at `failed` when the bump hits `MAX_ATTEMPTS`. Downstream + * dispatch is deduped at the request-id level, so re-leasing a row + * that already pushed cannot double-fire a notification. + */ + +import { and, asc, eq, gte, isNotNull, lte, sql } from 'drizzle-orm'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; + +import { attentionOutbox } from './db/sqlite-schema'; +import { + ATTENTION_MAX_DISPATCH_AGE_MS, + computeNextAttemptAt, + dispatchDeadline, + MAX_ATTEMPTS, + type AttentionReason, +} from './attention-outbox'; + +export type OutboxStatus = + | 'pending' + | 'in_flight' + | 'dispatched' + | 'suppressed_presence' + | 'missing_session' + | 'failed' + | 'resolved'; + +const TERMINAL_STATUSES: OutboxStatus[] = [ + 'resolved', + 'dispatched', + 'suppressed_presence', + 'missing_session', + 'failed', +]; + +function isTerminalStatus(status: OutboxStatus): boolean { + return TERMINAL_STATUSES.includes(status); +} + +export type OutboxRow = { + requestId: string; + reason: AttentionReason; + status: OutboxStatus; + attemptCount: number; + nextAttemptAt: number | null; + lastError: string | null; + raisedAt: number; + resolvedAt: number | null; +}; + +export type RecordIntentParams = { + requestId: string; + reason: AttentionReason; + now: number; +}; + +/** + * Pure: build the row for a fresh raise. Does not enforce dedup; the + * drizzle layer checks for an existing row first and short-circuits. + */ +export function applyRaiseIntent(params: RecordIntentParams): OutboxRow { + return { + requestId: params.requestId, + reason: params.reason, + status: 'pending', + attemptCount: 0, + nextAttemptAt: params.now, + lastError: null, + raisedAt: params.now, + resolvedAt: null, + }; +} + +/** + * Idempotently record a raise. Returns the resulting row. A re-raise of the + * same request id is a no-op — the original `raised_at` and reason are + * preserved, so the user only sees one push per upstream request, even if + * the CLI replays the `*.asked` event. A raise against a `resolved` or + * terminal row is a no-op too: the user already saw the outcome, so we do + * not create a new pending push. + */ +export function recordRaiseIntent( + db: DrizzleSqliteDODatabase, + params: RecordIntentParams +): OutboxRow { + const existing = selectByRequestId(db, params.requestId); + if (existing) return existing; + + const row = applyRaiseIntent(params); + db.insert(attentionOutbox) + .values({ + request_id: row.requestId, + reason: row.reason, + status: row.status, + attempt_count: row.attemptCount, + next_attempt_at: row.nextAttemptAt, + last_error: row.lastError, + raised_at: row.raisedAt, + resolved_at: row.resolvedAt, + }) + .run(); + return row; +} + +export type RecordResolveParams = { + requestId: string; + reason: AttentionReason; + now: number; +}; + +/** + * Pure: compute the next row for a resolve intent. `pending` and + * `in_flight` rows collapse to terminal `resolved` and clear their retry + * bookkeeping. Terminal rows only gain `resolvedAt` (when missing) and + * keep their existing status; a repeat resolve is a no-op. + */ +export function applyResolveIntent(existing: OutboxRow, now: number): OutboxRow { + if (existing.status === 'pending' || existing.status === 'in_flight') { + return { + ...existing, + status: 'resolved', + nextAttemptAt: null, + lastError: null, + resolvedAt: now, + }; + } + if (existing.resolvedAt !== null) return existing; + return { ...existing, resolvedAt: now }; +} + +/** + * Pure: build a terminal resolved tombstone for a request id that was + * never raised. The unknown-resolve path is the safe out-of-order case + * where the resolve arrives before the matching raise. The tombstone + * stamps `raisedAt` and `resolvedAt` to the same `now` so a subsequent + * raise cannot pick the row up as a real push — the outbox dispatch + * loop will see `status === 'resolved'` and skip it. + * + * `raisedAt === resolvedAt` and `attemptCount === 0` are the contract + * that distinguishes a tombstone from a real raise-then-resolve row. + */ +export function applyResolveIntentForUnknownRequest(params: { + requestId: string; + reason: AttentionReason; + now: number; +}): OutboxRow { + return { + requestId: params.requestId, + reason: params.reason, + status: 'resolved', + attemptCount: 0, + nextAttemptAt: null, + lastError: null, + raisedAt: params.now, + resolvedAt: params.now, + }; +} + +/** + * Mark a request resolved. When the request id was never raised this + * inserts a terminal resolved tombstone (see + * `applyResolveIntentForUnknownRequest`) so a late raise cannot enqueue + * a notification. When a row already exists, the original `reason` is + * preserved and the row is collapsed to terminal `resolved` — a repeat + * resolve with a different reason is a no-op for the stored reason and + * never re-flaps status. + */ +export function recordResolveIntent( + db: DrizzleSqliteDODatabase, + params: RecordResolveParams +): OutboxRow { + const existing = selectByRequestId(db, params.requestId); + if (!existing) { + const row = applyResolveIntentForUnknownRequest({ + requestId: params.requestId, + reason: params.reason, + now: params.now, + }); + db.insert(attentionOutbox) + .values({ + request_id: row.requestId, + reason: row.reason, + status: row.status, + attempt_count: row.attemptCount, + next_attempt_at: row.nextAttemptAt, + last_error: row.lastError, + raised_at: row.raisedAt, + resolved_at: row.resolvedAt, + }) + .run(); + return row; + } + + // Existing row: the original reason is preserved. A repeat resolve + // with a different reason must not overwrite the stored reason, and + // must not change status away from any terminal state. + const next = applyResolveIntent(existing, params.now); + if (next === existing) return existing; + + db.update(attentionOutbox) + .set({ + status: next.status, + resolved_at: next.resolvedAt, + next_attempt_at: next.nextAttemptAt, + last_error: next.lastError, + }) + .where(eq(attentionOutbox.request_id, params.requestId)) + .run(); + + return selectByRequestId(db, params.requestId) as OutboxRow; +} + +export type ClaimDispatchParams = { + now: number; +}; + +/** + * Park every pending row whose `next_attempt_at` is at/over the + * absolute dispatch window (raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS) + * as terminal failed with `last_error: 'retry_window_expired'`. The + * window is the receiver's idempotency TTL bound + * (NotificationChannelDO = 60min; we cap dispatch at 55min) so any + * attempt at/after 60min could be silently dropped or duplicated. By + * parking first, the rest of the dispatch loop and the + * `earliestScheduledAttemptAt` scheduler see only rows that are still + * safely within the window. + * + * Defense in depth: `claimNextDispatchable` calls this on every lease + * attempt and the DO alarm also calls it at start-up, so a delayed + * alarm that fires long after the row crossed the window can never + * claim a stale row. + */ +export function parkExpiredPendingRows( + db: DrizzleSqliteDODatabase, + _params: ClaimDispatchParams +): OutboxRow[] { + const candidates = db + .select() + .from(attentionOutbox) + .where( + and( + eq(attentionOutbox.status, 'pending'), + isNotNull(attentionOutbox.next_attempt_at), + gte( + attentionOutbox.next_attempt_at, + sql`${attentionOutbox.raised_at} + ${ATTENTION_MAX_DISPATCH_AGE_MS}` + ) + ) + ) + .all(); + + const parked: OutboxRow[] = []; + for (const row of candidates) { + const existing = rowToOutbox(row); + db.update(attentionOutbox) + .set({ + status: 'failed', + next_attempt_at: null, + last_error: 'retry_window_expired', + }) + .where(eq(attentionOutbox.request_id, existing.requestId)) + .run(); + parked.push({ + ...existing, + status: 'failed', + nextAttemptAt: null, + lastError: 'retry_window_expired', + }); + } + return parked; +} + +/** + * Claim the next due pending row for dispatch, transitioning it to + * `in_flight`. Returns the row or null when nothing is due. + * + * Two hard guards ensure the row is still within the receiver's + * idempotency window before any lease is granted: + * 1. `parkExpiredPendingRows` flips any pending row whose + * `next_attempt_at` is at/over the absolute deadline to terminal + * `failed` (defense in depth — also called at alarm start). + * 2. The remaining `pending` candidate must additionally satisfy + * `now < raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS` so a row whose + * deadline has just elapsed between the parking step and the + * claim is never picked. + * + * Atomic within the DO input gate — the SQLite update + select is the + * dispatch lease. + */ +export function claimNextDispatchable( + db: DrizzleSqliteDODatabase, + params: ClaimDispatchParams +): OutboxRow | null { + parkExpiredPendingRows(db, params); + + const candidates = db + .select() + .from(attentionOutbox) + .where( + and( + eq(attentionOutbox.status, 'pending'), + isNotNull(attentionOutbox.next_attempt_at), + lte(attentionOutbox.next_attempt_at, params.now) + ) + ) + .orderBy(asc(attentionOutbox.next_attempt_at)) + .all(); + + for (const candidate of candidates) { + const existing = rowToOutbox(candidate); + if (params.now >= dispatchDeadline(existing.raisedAt)) { + // The candidate is "due" by next_attempt_at but the absolute + // window has elapsed. Park it and continue scanning — the + // earlier `next_attempt_at` does not imply earlier dispatch + // safety. Parking here covers the rare case where + // `next_attempt_at < raisedAt + maxAge` (so the parking step + // didn't catch it) but the wall clock is now past the deadline. + db.update(attentionOutbox) + .set({ + status: 'failed', + next_attempt_at: null, + last_error: 'retry_window_expired', + }) + .where(eq(attentionOutbox.request_id, existing.requestId)) + .run(); + continue; + } + + db.update(attentionOutbox) + .set({ status: 'in_flight' }) + .where(eq(attentionOutbox.request_id, existing.requestId)) + .run(); + + return { ...existing, status: 'in_flight' }; + } + return null; +} + +export type MarkDispatchedParams = { + requestId: string; + now: number; +}; + +/** + * Mark a row terminal-dispatched. The dispatch succeeded, so no further + * retries will fire. + */ +export function markDispatched(db: DrizzleSqliteDODatabase, params: MarkDispatchedParams): void { + db.update(attentionOutbox) + .set({ status: 'dispatched', next_attempt_at: null }) + .where(eq(attentionOutbox.request_id, params.requestId)) + .run(); +} + +export type MarkSuppressedByPresenceParams = { + requestId: string; + now: number; +}; + +/** + * Terminal: the user is actively viewing this session, so the push was + * suppressed upstream. Don't retry — they'll see the result in-app. + */ +export function markSuppressedByPresence( + db: DrizzleSqliteDODatabase, + params: MarkSuppressedByPresenceParams +): void { + db.update(attentionOutbox) + .set({ status: 'suppressed_presence', next_attempt_at: null }) + .where(eq(attentionOutbox.request_id, params.requestId)) + .run(); +} + +export type MarkMissingSessionParams = { + requestId: string; + now: number; +}; + +/** + * Terminal: the session row is gone or the user no longer has access. We + * could re-raise in the future if access is restored, but a stale push + * with the wrong recipient is worse than missing one. + */ +export function markMissingSession( + db: DrizzleSqliteDODatabase, + params: MarkMissingSessionParams +): void { + db.update(attentionOutbox) + .set({ status: 'missing_session', next_attempt_at: null }) + .where(eq(attentionOutbox.request_id, params.requestId)) + .run(); +} + +export type MarkRetryParams = { + requestId: string; + now: number; + reason: string; +}; + +/** + * Pure: compute the next row for a retry outcome. Bumps `attemptCount` + * and either schedules the next attempt or parks the row at terminal + * `failed`. Two independent terminal triggers: + * 1. the attempt cap (`MAX_ATTEMPTS`) is reached, or + * 2. the absolute dispatch window has elapsed — `now` is at/after + * `raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS`, or the candidate + * next attempt timestamp would itself land at/after that deadline. + * Trigger (2) is the receiver's idempotency TTL bound + * (NotificationChannelDO is 60min; we cap dispatch at 55min) and trips + * regardless of the cap. Terminal rows (resolved, dispatched, + * suppressed_presence, missing_session, or failed) win and are returned + * unchanged so retries never resurrect finished work. The terminal + * `failed` row keeps `resolvedAt` null to match the markRetry park + * convention. + */ +export function applyRetry(existing: OutboxRow, now: number, reason: string): OutboxRow { + if (isTerminalStatus(existing.status)) { + return existing; + } + const nextAttemptCount = existing.attemptCount + 1; + const capReached = nextAttemptCount >= MAX_ATTEMPTS; + const deadline = dispatchDeadline(existing.raisedAt); + const candidateNext = computeNextAttemptAt(nextAttemptCount, now); + const windowExpired = now >= deadline || candidateNext >= deadline; + const isTerminal = capReached || windowExpired; + return { + ...existing, + status: isTerminal ? 'failed' : 'pending', + attemptCount: nextAttemptCount, + nextAttemptAt: isTerminal ? null : candidateNext, + lastError: reason.slice(0, 512), + }; +} + +/** + * Mark the row pending again, bumping the attempt counter and scheduling + * the next try. After `MAX_ATTEMPTS` the row is parked at terminal + * `failed` to keep the alarm from hot-looping. A row that has already + * reached a terminal status wins and is returned unchanged. + */ +export function markRetry(db: DrizzleSqliteDODatabase, params: MarkRetryParams): OutboxRow { + const existing = selectByRequestId(db, params.requestId); + if (!existing) { + const error = new Error('markRetry: unknown request_id'); + (error as Error & { code: string }).code = 'ATTENTION_OUTBOX_UNKNOWN_REQUEST'; + throw error; + } + const next = applyRetry(existing, params.now, params.reason); + if (next === existing) { + return existing; + } + + db.update(attentionOutbox) + .set({ + status: next.status, + attempt_count: next.attemptCount, + next_attempt_at: next.nextAttemptAt, + last_error: next.lastError, + }) + .where(eq(attentionOutbox.request_id, params.requestId)) + .run(); + + return selectByRequestId(db, params.requestId) as OutboxRow; +} + +/** + * Pure: compute the next row for a stale `in_flight` recovery (alarm + * restart or crash between lease and ack). The row is bumped to + * `pending` with a fresh `nextAttemptAt`; the same two terminal + * triggers as `applyRetry` apply — the cap or the absolute dispatch + * window. Downstream dispatch is keyed on the stable request id, so a + * recovered row cannot double-push. + */ +export function applyStaleInFlightRecovery(existing: OutboxRow, now: number): OutboxRow { + const nextAttemptCount = existing.attemptCount + 1; + const capReached = nextAttemptCount >= MAX_ATTEMPTS; + const deadline = dispatchDeadline(existing.raisedAt); + const candidateNext = computeNextAttemptAt(nextAttemptCount, now); + const windowExpired = now >= deadline || candidateNext >= deadline; + const isTerminal = capReached || windowExpired; + if (isTerminal) { + return { + ...existing, + status: 'failed', + attemptCount: nextAttemptCount, + nextAttemptAt: null, + lastError: 'stale_in_flight_recovered', + }; + } + return { + ...existing, + status: 'pending', + attemptCount: nextAttemptCount, + nextAttemptAt: candidateNext, + lastError: 'stale_in_flight_recovered', + }; +} + +export type RecoverStaleInFlightParams = { + now: number; +}; + +/** + * Recover any rows still in `in_flight` — typically because the DO was + * restarted (or the alarm was lost) between the lease and the dispatch + * ack. Each stale row is bumped to `pending` with a fresh schedule, or + * parked at `failed` once `MAX_ATTEMPTS` is reached. Returns the rows + * that were recovered (caller can use this to reschedule the alarm). + */ +export function recoverStaleInFlightRows( + db: DrizzleSqliteDODatabase, + params: RecoverStaleInFlightParams +): OutboxRow[] { + const candidates = db + .select() + .from(attentionOutbox) + .where(eq(attentionOutbox.status, 'in_flight')) + .all(); + const recovered: OutboxRow[] = []; + for (const row of candidates) { + const existing = rowToOutbox(row); + const next = applyStaleInFlightRecovery(existing, params.now); + db.update(attentionOutbox) + .set({ + status: next.status, + attempt_count: next.attemptCount, + next_attempt_at: next.nextAttemptAt, + last_error: next.lastError, + }) + .where(eq(attentionOutbox.request_id, existing.requestId)) + .run(); + recovered.push(next); + } + return recovered; +} + +/** + * Earliest `next_attempt_at` across pending rows that are still + * dispatchable (have a `next_attempt_at` set). Returns null when no + * pending row is scheduled. Used by the alarm scheduler to pick a + * non-clobbering alarm time without re-doing the full claim. + */ +export function earliestScheduledAttemptAt(db: DrizzleSqliteDODatabase): number | null { + const row = db + .select({ next_attempt_at: attentionOutbox.next_attempt_at }) + .from(attentionOutbox) + .where(and(eq(attentionOutbox.status, 'pending'), isNotNull(attentionOutbox.next_attempt_at))) + .orderBy(asc(attentionOutbox.next_attempt_at)) + .limit(1) + .get(); + return row?.next_attempt_at ?? null; +} + +/** + * True while at least one row is still pending dispatch (including + * rows currently in_flight that may yet be returned by the alarm loop). + * Used by the DO alarm to keep scheduling attempts until the outbox + * drains. + */ +export function hasPendingAttempts(db: DrizzleSqliteDODatabase): boolean { + const row = db + .select({ request_id: attentionOutbox.request_id }) + .from(attentionOutbox) + .where(eq(attentionOutbox.status, 'pending')) + .limit(1) + .get(); + return Boolean(row); +} + +/** + * True when at least one row is currently leased to the dispatch loop. + * Used to detect a stuck `in_flight` row that the alarm needs to + * recover. (We never use this for hot-loop scheduling — terminal + * failures have a clear `failed` state to prevent that.) + */ +export function hasInFlightRows(db: DrizzleSqliteDODatabase): boolean { + const row = db + .select({ request_id: attentionOutbox.request_id }) + .from(attentionOutbox) + .where(eq(attentionOutbox.status, 'in_flight')) + .limit(1) + .get(); + return Boolean(row); +} + +/** + * Read the current outbox row for a request id. Returns null when the + * request id was never raised. Intended for the orchestration layer + * (e.g. resolving pending pushes from a different signal) and tests. + */ +export function getOutboxRow(db: DrizzleSqliteDODatabase, requestId: string): OutboxRow | null { + return selectByRequestId(db, requestId); +} + +function selectByRequestId(db: DrizzleSqliteDODatabase, requestId: string): OutboxRow | null { + const row = db + .select() + .from(attentionOutbox) + .where(eq(attentionOutbox.request_id, requestId)) + .get(); + return row ? rowToOutbox(row) : null; +} + +function rowToOutbox(row: typeof attentionOutbox.$inferSelect): OutboxRow { + // These values are controlled by this DO's validated writes and + // free-text SQLite columns, so targeted casts are sufficient; no + // per-row Zod reparse is needed. + return { + requestId: row.request_id, + reason: row.reason as AttentionReason, + status: row.status as OutboxStatus, + attemptCount: row.attempt_count, + nextAttemptAt: row.next_attempt_at, + lastError: row.last_error, + raisedAt: row.raised_at, + resolvedAt: row.resolved_at, + }; +} diff --git a/services/session-ingest/src/attention-outbox.test.ts b/services/session-ingest/src/attention-outbox.test.ts new file mode 100644 index 0000000000..94fca641a0 --- /dev/null +++ b/services/session-ingest/src/attention-outbox.test.ts @@ -0,0 +1,596 @@ +import { describe, expect, it } from 'vitest'; + +import { + ATTENTION_MAX_DISPATCH_AGE_MS, + classifyAttentionEvent, + computeNextAttemptAt, + dispatchDeadline, + extractAttentionSignal, + extractStableRequestId, + isActionable, + isResolvedEvent, + MAX_ATTEMPTS, + type AttentionReason, +} from './attention-outbox'; +import { + applyRaiseIntent, + applyResolveIntent, + applyResolveIntentForUnknownRequest, + applyRetry, + applyStaleInFlightRecovery, + type OutboxRow, + type OutboxStatus, +} from './attention-outbox-store'; + +describe('classifyAttentionEvent', () => { + it.each([ + ['question.asked', 'raise', 'question'], + ['question.replied', 'resolve', 'question'], + ['question.rejected', 'resolve', 'question'], + ['permission.asked', 'raise', 'permission'], + ['permission.replied', 'resolve', 'permission'], + ] as const)('maps %s to %s', (event, kind, reason) => { + const result = classifyAttentionEvent(event, {}); + expect(result?.kind).toBe(kind); + expect(result?.reason ?? null).toBe(reason); + }); + + describe('suggestion events', () => { + it('raises a blocking_suggestion when blocking:true is explicit', () => { + const result = classifyAttentionEvent('suggestion.shown', { blocking: true }); + expect(result).toEqual({ kind: 'raise', reason: 'blocking_suggestion' }); + }); + + it('ignores a suggestion.shown without blocking flag', () => { + expect(classifyAttentionEvent('suggestion.shown', {})).toBeNull(); + }); + + it('ignores a suggestion.shown with blocking string "true"', () => { + expect(classifyAttentionEvent('suggestion.shown', { blocking: 'true' })).toBeNull(); + }); + + it('ignores a suggestion.shown with blocking number 1', () => { + expect(classifyAttentionEvent('suggestion.shown', { blocking: 1 })).toBeNull(); + }); + + it('ignores a suggestion.shown with blocking:false', () => { + expect(classifyAttentionEvent('suggestion.shown', { blocking: false })).toBeNull(); + }); + + it('resolves a suggestion.accepted as blocking_suggestion', () => { + expect(classifyAttentionEvent('suggestion.accepted', { id: 's_1' })).toEqual({ + kind: 'resolve', + reason: 'blocking_suggestion', + }); + }); + + it('resolves a suggestion.dismissed as blocking_suggestion', () => { + expect(classifyAttentionEvent('suggestion.dismissed', { id: 's_1' })).toEqual({ + kind: 'resolve', + reason: 'blocking_suggestion', + }); + }); + + it('ignores a suggestion.accepted when blocking is false (advisory, not user-actionable)', () => { + // Advisory (non-blocking) suggestions are noise; the producer + // never raised them, so we should not synthesize a resolve either. + expect( + classifyAttentionEvent('suggestion.accepted', { id: 's_1', blocking: false }) + ).toBeNull(); + }); + }); + + it('ignores session.status updates', () => { + expect(classifyAttentionEvent('session.status', { status: 'busy' })).toBeNull(); + }); + + it('ignores message.updated events', () => { + expect(classifyAttentionEvent('message.updated', { id: 'msg_1' })).toBeNull(); + }); + + it('ignores network wait and offline events', () => { + expect(classifyAttentionEvent('session.network.asked', { id: 'nw_1' })).toBeNull(); + expect(classifyAttentionEvent('session.network.restored', { id: 'nw_1' })).toBeNull(); + expect(classifyAttentionEvent('session.offline', {})).toBeNull(); + }); + + it('ignores automatic retry events', () => { + expect(classifyAttentionEvent('session.retry', { attempt: 2 })).toBeNull(); + }); + + it('ignores action_required typed events (no safe Kilo contract yet)', () => { + // The shared reason stays available in the schema, but the raw Kilo + // contract does not yet expose a stable request id and genuinely + // user-actionable typed action, so the producer ignores it. + expect(classifyAttentionEvent('action_required', { id: 'ar_1' })).toBeNull(); + }); + + it('ignores unknown event names', () => { + expect(classifyAttentionEvent('question.exploded', {})).toBeNull(); + expect(classifyAttentionEvent('', {})).toBeNull(); + }); +}); + +describe('isActionable', () => { + it('returns true for raise events', () => { + expect(isActionable({ kind: 'raise', reason: 'question' })).toBe(true); + expect(isActionable({ kind: 'raise', reason: 'permission' })).toBe(true); + expect(isActionable({ kind: 'raise', reason: 'blocking_suggestion' })).toBe(true); + }); + + it('returns false for resolve events', () => { + expect(isActionable({ kind: 'resolve', reason: 'question' })).toBe(false); + }); +}); + +describe('isResolvedEvent', () => { + it('returns true for resolve intents only', () => { + expect(isResolvedEvent({ kind: 'resolve', reason: 'question' })).toBe(true); + expect(isResolvedEvent({ kind: 'raise', reason: 'question' })).toBe(false); + }); +}); + +describe('computeNextAttemptAt', () => { + const NOW = 1_000_000; + + it('schedules the first attempt immediately', () => { + expect(computeNextAttemptAt(0, NOW)).toBe(NOW); + }); + + it('backs off exponentially up to MAX_ATTEMPTS', () => { + expect(computeNextAttemptAt(1, NOW)).toBe(NOW + 5_000); + expect(computeNextAttemptAt(2, NOW)).toBe(NOW + 15_000); + expect(computeNextAttemptAt(3, NOW)).toBe(NOW + 60_000); + expect(computeNextAttemptAt(4, NOW)).toBe(NOW + 5 * 60_000); + expect(computeNextAttemptAt(5, NOW)).toBe(NOW + 15 * 60_000); + // The helper caps at attempt 6; attempt 7 is parked at terminal failed by + // callers, but the clamped safety-net value is still the cap. + expect(computeNextAttemptAt(7, NOW)).toBe(NOW + 60 * 60_000); + }); + + it('exposes MAX_ATTEMPTS as a stable ceiling for the caller', () => { + expect(MAX_ATTEMPTS).toBeGreaterThan(0); + }); +}); + +describe('AttentionReason re-exports', () => { + it('exposes the supported reasons for downstream code', () => { + const reasons: AttentionReason[] = ['question', 'permission', 'blocking_suggestion']; + expect(reasons).toHaveLength(3); + }); +}); + +/** + * Pure transition helpers extracted from `attention-outbox-store.ts`. + * The drizzle layer in the store is the one that persists these, so we + * exercise the rules here without touching SQLite. Drizzle-backed + * integration coverage is expected to land alongside the DO wiring. + */ +describe('outbox store pure transitions', () => { + const NOW = 1_700_000_000_000; + + function baseRow(overrides: Partial = {}): OutboxRow { + return { + requestId: 'req_1', + reason: 'question', + status: 'pending', + attemptCount: 0, + nextAttemptAt: NOW, + lastError: null, + raisedAt: NOW, + resolvedAt: null, + ...overrides, + }; + } + + describe('applyRaiseIntent', () => { + it('builds a fresh pending row with no retry history', () => { + const row = applyRaiseIntent({ requestId: 'req_1', reason: 'question', now: NOW }); + expect(row).toEqual({ + requestId: 'req_1', + reason: 'question', + status: 'pending', + attemptCount: 0, + nextAttemptAt: NOW, + lastError: null, + raisedAt: NOW, + resolvedAt: null, + }); + }); + }); + + describe('applyResolveIntent', () => { + it('collapses a pending row to terminal resolved and clears retry state', () => { + const next = applyResolveIntent(baseRow({ status: 'pending' }), NOW + 1); + expect(next.status).toBe('resolved'); + expect(next.resolvedAt).toBe(NOW + 1); + expect(next.nextAttemptAt).toBeNull(); + expect(next.lastError).toBeNull(); + // Original raise metadata is preserved for audit. + expect(next.raisedAt).toBe(NOW); + }); + + it('collapses an in_flight row to terminal resolved and clears retry state', () => { + const next = applyResolveIntent( + baseRow({ status: 'in_flight', attemptCount: 2, lastError: 'boom' }), + NOW + 1 + ); + expect(next.status).toBe('resolved'); + expect(next.resolvedAt).toBe(NOW + 1); + expect(next.nextAttemptAt).toBeNull(); + expect(next.lastError).toBeNull(); + }); + + it('keeps a terminal status and only stamps resolvedAt when missing', () => { + const next = applyResolveIntent(baseRow({ status: 'dispatched' }), NOW + 5); + expect(next.status).toBe('dispatched'); + expect(next.resolvedAt).toBe(NOW + 5); + }); + + it('keeps a parked-failed terminal status and only stamps resolvedAt when missing', () => { + const next = applyResolveIntent( + baseRow({ status: 'failed', attemptCount: MAX_ATTEMPTS }), + NOW + 5 + ); + expect(next.status).toBe('failed'); + expect(next.resolvedAt).toBe(NOW + 5); + }); + + it('is a no-op when called twice on a terminal row', () => { + const once = applyResolveIntent(baseRow({ status: 'dispatched' }), NOW + 5); + const twice = applyResolveIntent(once, NOW + 50); + expect(twice).toBe(once); + }); + + it('is a no-op when called twice on a resolved row', () => { + const once = applyResolveIntent(baseRow({ status: 'pending' }), NOW + 5); + const twice = applyResolveIntent(once, NOW + 50); + expect(twice).toBe(once); + expect(twice.status).toBe('resolved'); + expect(twice.resolvedAt).toBe(NOW + 5); + }); + }); + + describe('applyResolveIntentForUnknownRequest', () => { + // The unknown-resolve path handles out-of-order events where the + // resolve arrives before the matching raise. The row must be a + // terminal tombstone: no scheduled dispatch, no attempt count, and + // `raisedAt === resolvedAt` so a subsequent raise cannot pick the row + // up as a real push. + it('produces a terminal resolved tombstone with the supplied reason and now as both timestamps', () => { + const next = applyResolveIntentForUnknownRequest({ + requestId: 'req_unknown', + reason: 'question', + now: NOW, + }); + expect(next).toEqual({ + requestId: 'req_unknown', + reason: 'question', + status: 'resolved', + attemptCount: 0, + nextAttemptAt: null, + lastError: null, + raisedAt: NOW, + resolvedAt: NOW, + }); + }); + + it.each(['question', 'permission', 'blocking_suggestion'] as const)( + 'preserves the supplied resolve reason %s on the tombstone', + reason => { + const next = applyResolveIntentForUnknownRequest({ + requestId: `req_${reason}`, + reason, + now: NOW, + }); + expect(next.reason).toBe(reason); + expect(next.status).toBe('resolved'); + expect(next.raisedAt).toBe(NOW); + expect(next.resolvedAt).toBe(NOW); + } + ); + }); + + describe('applyRetry', () => { + it('bumps attemptCount, schedules the next attempt, and keeps status pending', () => { + const next = applyRetry(baseRow({ attemptCount: 1 }), NOW + 100, 'transient'); + expect(next.status).toBe('pending'); + expect(next.attemptCount).toBe(2); + expect(next.nextAttemptAt).toBe(computeNextAttemptAt(2, NOW + 100)); + expect(next.lastError).toBe('transient'); + }); + + it('preserves retry behavior for in_flight rows', () => { + const next = applyRetry( + baseRow({ status: 'in_flight', attemptCount: 2 }), + NOW + 100, + 'transient' + ); + expect(next.status).toBe('pending'); + expect(next.attemptCount).toBe(3); + expect(next.nextAttemptAt).toBe(computeNextAttemptAt(3, NOW + 100)); + expect(next.lastError).toBe('transient'); + }); + + it('parks at terminal failed when the cap is reached', () => { + const next = applyRetry( + baseRow({ attemptCount: MAX_ATTEMPTS - 1 }), + NOW + 100, + 'still failing' + ); + expect(next.status).toBe('failed'); + expect(next.attemptCount).toBe(MAX_ATTEMPTS); + expect(next.nextAttemptAt).toBeNull(); + expect(next.lastError).toBe('still failing'); + }); + + it.each([ + 'resolved', + 'dispatched', + 'suppressed_presence', + 'missing_session', + 'failed', + ] as OutboxStatus[])('is a no-op for terminal status %s', status => { + const existing = baseRow({ status, attemptCount: 3 }); + const next = applyRetry(existing, NOW + 100, 'should not apply'); + expect(next).toBe(existing); + expect(next.status).toBe(status); + expect(next.attemptCount).toBe(3); + expect(next.lastError).toBeNull(); + expect(next.nextAttemptAt).toBe(existing.nextAttemptAt); + }); + + it('truncates oversized lastError values to keep the row bounded', () => { + const huge = 'x'.repeat(2000); + const next = applyRetry(baseRow({ attemptCount: 0 }), NOW, huge); + expect(next.lastError?.length).toBe(512); + }); + + // Kilobot finding 5: never schedule a dispatch at/after the absolute + // deadline derived from `raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS`. + // The cap is enforced even when the cap-by-attempt-count wouldn't + // trip, because the receiver's TTL is the actual hard limit. + it('parks at terminal failed when `now` reaches the absolute deadline', () => { + const raisedAt = NOW; + const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; + const next = applyRetry(baseRow({ attemptCount: 2, raisedAt }), deadline, 'transient'); + expect(next.status).toBe('failed'); + expect(next.nextAttemptAt).toBeNull(); + expect(next.attemptCount).toBe(3); + expect(next.lastError).toBe('transient'); + }); + + it('parks at terminal failed when `now` is strictly past the absolute deadline', () => { + const raisedAt = NOW; + const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; + const next = applyRetry(baseRow({ attemptCount: 1, raisedAt }), deadline + 1, 'transient'); + expect(next.status).toBe('failed'); + expect(next.nextAttemptAt).toBeNull(); + }); + + it('still schedules normally when the next backoff fits inside the window', () => { + const raisedAt = NOW; + const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; + // attempt 0 → next attempt 1 (5s backoff) lands well before the + // deadline, so we must still schedule normally. + const fitsBefore = deadline - 10_000; + const next = applyRetry(baseRow({ attemptCount: 0, raisedAt }), fitsBefore, 'transient'); + expect(next.status).toBe('pending'); + expect(next.nextAttemptAt).toBe(computeNextAttemptAt(1, fitsBefore)); + expect(next.nextAttemptAt).toBeLessThan(deadline); + }); + + it('parks at terminal failed when the next attempt would cross the deadline', () => { + // The cap-by-attempt-count would still allow attempt 6, but its + // backoff is 60min, which is past the 55min absolute deadline, so + // we park at failed without scheduling. + const raisedAt = NOW; + const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; + const justInside = deadline - 1; + // attempt 5 → next attempt 6 → computeNextAttemptAt(6, justInside) + // = justInside + 60*60_000, which is > deadline. + const next = applyRetry(baseRow({ attemptCount: 5, raisedAt }), justInside, 'transient'); + expect(next.status).toBe('failed'); + expect(next.nextAttemptAt).toBeNull(); + expect(next.attemptCount).toBe(6); + expect(next.lastError).toBe('transient'); + }); + }); + + describe('applyStaleInFlightRecovery', () => { + it('moves an in_flight row back to pending with a fresh schedule', () => { + const next = applyStaleInFlightRecovery( + baseRow({ status: 'in_flight', attemptCount: 0 }), + NOW + 100 + ); + expect(next.status).toBe('pending'); + expect(next.attemptCount).toBe(1); + expect(next.nextAttemptAt).toBe(computeNextAttemptAt(1, NOW + 100)); + expect(next.lastError).toBe('stale_in_flight_recovered'); + }); + + it('parks at terminal failed when the recovery bump hits MAX_ATTEMPTS', () => { + const next = applyStaleInFlightRecovery( + baseRow({ + status: 'in_flight', + attemptCount: MAX_ATTEMPTS - 1, + lastError: 'previous error', + }), + NOW + 100 + ); + expect(next.status).toBe('failed'); + expect(next.attemptCount).toBe(MAX_ATTEMPTS); + expect(next.nextAttemptAt).toBeNull(); + expect(next.lastError).toBe('stale_in_flight_recovered'); + expect(next.resolvedAt).toBeNull(); + }); + + // Kilobot finding 5: stale-recovery must respect the absolute + // deadline. A row past its window must not be rescheduled. + it('parks at terminal failed when `now` is at the absolute deadline', () => { + const raisedAt = NOW; + const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; + const next = applyStaleInFlightRecovery( + baseRow({ status: 'in_flight', attemptCount: 1, raisedAt }), + deadline + ); + expect(next.status).toBe('failed'); + expect(next.nextAttemptAt).toBeNull(); + expect(next.attemptCount).toBe(2); + }); + + it('parks at terminal failed when the recovered schedule would cross the deadline', () => { + const raisedAt = NOW; + const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; + // attempt 5 → next attempt 6 → schedule crosses deadline. + const justInside = deadline - 1; + const next = applyStaleInFlightRecovery( + baseRow({ status: 'in_flight', attemptCount: 5, raisedAt }), + justInside + ); + expect(next.status).toBe('failed'); + expect(next.nextAttemptAt).toBeNull(); + }); + }); + + describe('idempotent raise contract', () => { + it('applyRaiseIntent always produces a fresh pending row (dedup lives in the store)', () => { + // recordRaiseIntent short-circuits on an existing row before this + // helper is called, so the pure helper itself is intentionally + // unconditional. This test documents that contract so future + // refactors do not move dedup into the helper by accident. + const fresh = applyRaiseIntent({ requestId: 'req_1', reason: 'question', now: NOW }); + expect(fresh.status).toBe('pending'); + expect(fresh.resolvedAt).toBeNull(); + expect(fresh.attemptCount).toBe(0); + expect(fresh.lastError).toBeNull(); + }); + }); +}); + +describe('extractStableRequestId', () => { + it('reads a direct id', () => { + expect(extractStableRequestId({ id: 'r_1' })).toBe('r_1'); + }); + + it('reads a direct requestId', () => { + expect(extractStableRequestId({ requestId: 'r_2' })).toBe('r_2'); + }); + + it('reads a direct requestID', () => { + expect(extractStableRequestId({ requestID: 'r_3' })).toBe('r_3'); + }); + + it('falls back to nested properties', () => { + expect(extractStableRequestId({ properties: { id: 'nested_1' } })).toBe('nested_1'); + }); + + it('prefers the top-level id over nested properties', () => { + expect(extractStableRequestId({ id: 'top', properties: { id: 'nested' } })).toBe('top'); + }); + + it('returns null for malformed or missing ids', () => { + expect(extractStableRequestId(null)).toBeNull(); + expect(extractStableRequestId('string')).toBeNull(); + expect(extractStableRequestId([])).toBeNull(); + expect(extractStableRequestId({ id: '' })).toBeNull(); + expect(extractStableRequestId({})).toBeNull(); + expect(extractStableRequestId({ properties: {} })).toBeNull(); + expect(extractStableRequestId({ properties: { id: '' } })).toBeNull(); + }); +}); + +describe('extractAttentionSignal', () => { + it('raises a question', () => { + expect(extractAttentionSignal('question.asked', { id: 'q_1' })).toEqual({ + intent: { kind: 'raise', reason: 'question' }, + requestId: 'q_1', + }); + }); + + it('raises a permission', () => { + expect(extractAttentionSignal('permission.asked', { requestId: 'p_1' })).toEqual({ + intent: { kind: 'raise', reason: 'permission' }, + requestId: 'p_1', + }); + }); + + it('raises an explicit blocking suggestion', () => { + expect(extractAttentionSignal('suggestion.shown', { id: 's_1', blocking: true })).toEqual({ + intent: { kind: 'raise', reason: 'blocking_suggestion' }, + requestId: 's_1', + }); + }); + + it('resolves question.replied and question.rejected', () => { + expect(extractAttentionSignal('question.replied', { id: 'q_1' })).toEqual({ + intent: { kind: 'resolve', reason: 'question' }, + requestId: 'q_1', + }); + expect(extractAttentionSignal('question.rejected', { id: 'q_1' })).toEqual({ + intent: { kind: 'resolve', reason: 'question' }, + requestId: 'q_1', + }); + }); + + it('resolves permission.replied', () => { + expect(extractAttentionSignal('permission.replied', { id: 'p_1' })).toEqual({ + intent: { kind: 'resolve', reason: 'permission' }, + requestId: 'p_1', + }); + }); + + it('resolves suggestion.accepted and suggestion.dismissed as blocking_suggestion', () => { + expect(extractAttentionSignal('suggestion.accepted', { id: 's_1' })).toEqual({ + intent: { kind: 'resolve', reason: 'blocking_suggestion' }, + requestId: 's_1', + }); + expect(extractAttentionSignal('suggestion.dismissed', { id: 's_1' })).toEqual({ + intent: { kind: 'resolve', reason: 'blocking_suggestion' }, + requestId: 's_1', + }); + }); + + it('ignores a nonblocking suggestion', () => { + expect(extractAttentionSignal('suggestion.shown', { id: 's_1', blocking: false })).toBeNull(); + }); + + it('ignores a missing request id', () => { + expect(extractAttentionSignal('question.asked', {})).toBeNull(); + }); + + it('ignores network and retry events', () => { + expect(extractAttentionSignal('session.network.asked', { id: 'nw_1' })).toBeNull(); + expect(extractAttentionSignal('session.retry', { id: 'r_1' })).toBeNull(); + }); + + it('ignores action_required events', () => { + expect(extractAttentionSignal('action_required', { id: 'ar_1' })).toBeNull(); + }); +}); + +const NOW_FOR_DEADLINE_TESTS = 1_700_000_000_000; + +/** + * Kilobot finding 5: notification downstream idempotency is bounded at + * 60min. Producers must not attempt dispatch at/after 55min from the + * raise timestamp so the row can never cross the receiver's TTL + * (NotificationChannelDO). The constant is a service-internal knob and + * is not re-exported across the package boundary; tests below import it + * from the same module so we exercise the actual value without coupling + * sibling packages. + */ +describe('ATTENTION_MAX_DISPATCH_AGE_MS', () => { + it('keeps the absolute dispatch deadline strictly under 60 minutes', () => { + // NotificationChannelDO TTL is 60min; we must never schedule dispatch + // at/after 55min from raisedAt, with a 5min safety margin. + expect(ATTENTION_MAX_DISPATCH_AGE_MS).toBeLessThan(60 * 60_000); + expect(60 * 60_000 - ATTENTION_MAX_DISPATCH_AGE_MS).toBe(5 * 60_000); + }); + + it('exposes a helper that adds the cap to a raise timestamp', () => { + expect(dispatchDeadline(NOW_FOR_DEADLINE_TESTS)).toBe( + NOW_FOR_DEADLINE_TESTS + ATTENTION_MAX_DISPATCH_AGE_MS + ); + }); +}); diff --git a/services/session-ingest/src/attention-outbox.ts b/services/session-ingest/src/attention-outbox.ts new file mode 100644 index 0000000000..e29ddb34bc --- /dev/null +++ b/services/session-ingest/src/attention-outbox.ts @@ -0,0 +1,220 @@ +/** + * Pure classification and backoff helpers for the per-session attention outbox. + * + * Decoupled from drizzle and the DO surface so the rules are cheap to unit-test + * and easy to reason about. The DO reads envelope data through + * `extractAttentionRequest` (also pure) and feeds the resulting pieces to + * `classifyAttentionEvent` and `isActionable` / `isResolvedEvent`. + * + * The outbox records a single row per stable upstream request id, so: + * - Duplicate replays of the same `*.asked` event collapse (no-op). + * - A `rejected`/`replied`/etc. on a never-raised id is a no-op too. + * - Resolving an already-resolved request is a no-op. + * - Re-raising an already-resolved request is a no-op. + * Distinct requests stay independent. + * + * Stable request id sources by reason: + * - `question.asked` / `question.replied` / `question.rejected` → envelope id + * (raw property `id` or `requestId`, mapped by the DO). + * - `permission.asked` / `permission.replied` → envelope id. + * - `suggestion.shown` / `suggestion.accepted` / `suggestion.dismissed` → + * envelope id; raise only when `blocking` is the boolean `true`. String + * or numeric truthy values are ignored because the upstream typed + * contract is boolean. + * + * Events that look actionable to a human but do not currently expose a + * stable request id, or are non-actionable transport noise, are ignored + * outright so we never push on a request we can't reconcile with a + * subsequent resolve/replay. + */ + +import { z } from 'zod'; + +export const attentionReasonSchema = z.enum(['question', 'permission', 'blocking_suggestion']); +export type AttentionReason = z.infer; + +export type AttentionIntent = + | { kind: 'raise'; reason: AttentionReason } + | { kind: 'resolve'; reason: AttentionReason }; + +export const MAX_ATTEMPTS = 7; + +/** + * Maximum age (in ms) between a row's `raisedAt` and the moment a + * dispatch attempt is allowed to fire. The downstream receiver + * (NotificationChannelDO) deduplicates push requests with a 60-minute + * idempotency TTL, so any attempt at/after 60 minutes from `raisedAt` + * can be silently dropped or duplicated by a retry that crosses the + * window. We hold a 5-minute safety margin and refuse to attempt + * dispatch at/after 55 minutes from `raisedAt`. + * + * `dispatchDeadline(raisedAt)` returns the absolute wall-clock + * deadline; the outbox and alarm code must never schedule a row at or + * past that timestamp, and must never claim one whose deadline has + * arrived. The constant is exported so tests in the same package can + * reference it directly without coupling across the package boundary. + */ +export const ATTENTION_MAX_DISPATCH_AGE_MS = 55 * 60_000; + +export function dispatchDeadline(raisedAt: number): number { + return raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS; +} + +/** + * Map a raw event name + envelope data to a raise/resolve intent. Returns + * null when the event is out of scope for human-attention pushes (network + * waits, automatic retries, status pings, message metadata, etc.). + * + * `data` is treated as an opaque object — producers may pass either + * `raw.properties` from the kilocode wrapper payload or the inner body + * of a `cloud_agent` envelope; the classifier only reads the fields it + * needs (`id`, `requestId`, `blocking`). + */ +export function classifyAttentionEvent(eventName: string, data: unknown): AttentionIntent | null { + const props = readRecord(data); + + switch (eventName) { + case 'question.asked': + return { kind: 'raise', reason: 'question' }; + case 'question.replied': + case 'question.rejected': + return { kind: 'resolve', reason: 'question' }; + + case 'permission.asked': + return { kind: 'raise', reason: 'permission' }; + case 'permission.replied': + return { kind: 'resolve', reason: 'permission' }; + + case 'suggestion.shown': + // Only blocking suggestions are user-actionable. Non-blocking + // suggestions are advisory and would spam the lock screen. + return props?.blocking === true ? { kind: 'raise', reason: 'blocking_suggestion' } : null; + case 'suggestion.accepted': + case 'suggestion.dismissed': + // A suggestion explicitly marked `blocking: false` is advisory; the + // producer never raised it, so we must not synthesize a resolve for + // it either. For every other shape (no blocking field, blocking:true, + // or any non-false value) we synthesize a blocking_suggestion resolve + // so a late raise cannot enqueue a notification. + return props?.blocking === false ? null : { kind: 'resolve', reason: 'blocking_suggestion' }; + + default: + return null; + } +} + +export function isActionable(intent: AttentionIntent): boolean { + return intent.kind === 'raise'; +} + +export function isResolvedEvent(intent: AttentionIntent): boolean { + return intent.kind === 'resolve'; +} + +/** + * Compute the next attempt timestamp. `attemptCount` is the number of + * attempts that have already been made (0 before the first try). + * + * Attempts are numbered 0 through 6. Once a caller has made attempt 6 and + * still needs to retry, the row is parked at the terminal `failed` status + * (attempt 7 is terminal), so this helper is never asked to schedule + * attempt 7 or beyond. The clamped return value here is a defensive safety + * net only. + * + * Backoff sequence (ms after `now`): + * attempt 0 → 0 + * attempt 1 → 5_000 + * attempt 2 → 15_000 + * attempt 3 → 60_000 + * attempt 4 → 300_000 + * attempt 5 → 900_000 + * attempt 6 → 3_600_000 (capped) + * + * The intent is to push immediately on first raise (the user just + * reached a wait state), then back off aggressively so retries don't + * pile up during long-lived waits. + */ +export function computeNextAttemptAt(attemptCount: number, now: number): number { + const schedule = [0, 5_000, 15_000, 60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000]; + const idx = Math.min(Math.max(attemptCount, 0), schedule.length - 1); + return now + (schedule[idx] ?? 0); +} + +/** + * Read a stable request id from the raw envelope. Looks at the fields the + * upstream producers actually use (`id` on kilocode payloads, + * `requestId`/`requestID` on cloud-agent envelopes) and returns the + * first non-empty string found, or null when the envelope is missing + * the id. The DO layer never logs the id value — it only forwards it + * to the outbox and notifications service. + */ +export function extractRequestId(data: unknown): string | null { + return extractStableRequestId(data); +} + +/** + * Read a stable request id from a remote CLI event envelope. + * + * Authoritative shape: the kilocode CLI producer forwards + * `{ type: 'event', data: event.properties }` (the event's properties are + * the envelope), so the id lives at the top level of `data` + * (`data.id`, or `data.requestId` / `data.requestID`). + * + * Compatibility shape: the cloud-agent-next wrapper sends + * `{ ...properties, event, type, properties }` and therefore exposes the id + * at both the top level and under `data.properties`. We read the top-level + * first and only fall back to `data.properties` when the top level is + * missing the field, so the two shapes are interchangeable. + * + * Returns the first non-empty string value, or null when the envelope + * does not carry a usable id. The DO layer never logs the id value. + */ +export function extractStableRequestId(data: unknown): string | null { + const id = readIdFromRecord(data); + if (id !== null) return id; + const props = readRecord(data); + if (!props) return null; + const nested = readRecord(props.properties); + if (!nested) return null; + return readIdFromRecord(nested); +} + +function readIdFromRecord(data: unknown): string | null { + const props = readRecord(data); + if (!props) return null; + for (const key of ['requestId', 'requestID', 'id']) { + const value = props[key]; + if (typeof value === 'string' && value.length > 0) return value; + } + return null; +} + +/** + * Resolve a remote CLI event into an attention signal suitable for the + * per-session outbox. Returns `null` when the event is out of scope, the + * envelope is malformed, or the event has no stable request id (we + * never push on a request we can't reconcile with a later resolve). + * + * Pure helper: callers pass the raw `event` name and the `data` envelope + * exactly as the CLI delivered them. The helper reads the id from the + * authoritative direct shape with a nested `properties` fallback for + * producers that wrap the body (see `extractStableRequestId`). + */ +export function extractAttentionSignal( + event: string, + data: unknown +): { + intent: AttentionIntent; + requestId: string; +} | null { + const intent = classifyAttentionEvent(event, data); + if (intent === null) return null; + const requestId = extractStableRequestId(data); + if (requestId === null) return null; + return { intent, requestId }; +} + +function readRecord(value: unknown): Record | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} diff --git a/services/session-ingest/src/db/sqlite-schema.ts b/services/session-ingest/src/db/sqlite-schema.ts index 2b4e145805..05906a750a 100644 --- a/services/session-ingest/src/db/sqlite-schema.ts +++ b/services/session-ingest/src/db/sqlite-schema.ts @@ -21,3 +21,24 @@ export const ingestMeta = sqliteTable('ingest_meta', { export const sessions = sqliteTable('sessions', { session_id: text('session_id').primaryKey(), }); + +/** + * Per-session durable outbox for human-attention pushes. One row per stable + * request id (e.g. a question id from the CLI). Status is set to a terminal + * value once dispatch is no longer needed; `pending` rows drive the alarm + * retry loop. + */ +export const attentionOutbox = sqliteTable( + 'attention_outbox', + { + request_id: text('request_id').primaryKey(), + reason: text('reason').notNull(), + status: text('status').notNull().default('pending'), + attempt_count: integer('attempt_count').notNull().default(0), + next_attempt_at: integer('next_attempt_at'), + last_error: text('last_error'), + raised_at: integer('raised_at').notNull(), + resolved_at: integer('resolved_at'), + }, + table => [index('attention_outbox_pending_idx').on(table.status, table.next_attempt_at)] +); diff --git a/services/session-ingest/src/dos/SessionIngestDO.test.ts b/services/session-ingest/src/dos/SessionIngestDO.test.ts index 75588abda7..42dd5fa2ef 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.test.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.test.ts @@ -38,6 +38,12 @@ describe('SessionIngestDO ingest ordering', () => { from: vi.fn(() => selectQuery), where: vi.fn(() => selectQuery), get: vi.fn(() => undefined), + all: vi.fn(() => [ + { key: 'metricsAlarmAt', value: '9999999999999' }, + { key: 'metricsEmitted', value: 'false' }, + ]), + orderBy: vi.fn(() => selectQuery), + limit: vi.fn(() => selectQuery), }; const db = { select: vi.fn(() => selectQuery), @@ -105,6 +111,12 @@ describe('SessionIngestDO ingest ordering', () => { from: vi.fn(() => selectQuery), where: vi.fn(() => selectQuery), get: vi.fn(() => getResults.shift()), + all: vi.fn(() => [ + { key: 'metricsAlarmAt', value: '9999999999999' }, + { key: 'metricsEmitted', value: 'false' }, + ]), + orderBy: vi.fn(() => selectQuery), + limit: vi.fn(() => selectQuery), }; const db = { select: vi.fn(() => selectQuery), @@ -174,6 +186,12 @@ describe('SessionIngestDO ingest ordering', () => { from: vi.fn(() => selectQuery), where: vi.fn(() => selectQuery), get: vi.fn(() => undefined), + all: vi.fn(() => [ + { key: 'metricsAlarmAt', value: '9999999999999' }, + { key: 'metricsEmitted', value: 'false' }, + ]), + orderBy: vi.fn(() => selectQuery), + limit: vi.fn(() => selectQuery), }; const db = { select: vi.fn(() => selectQuery), diff --git a/services/session-ingest/src/dos/SessionIngestDO.ts b/services/session-ingest/src/dos/SessionIngestDO.ts index 7adc3a7b13..5d28bcaf37 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.ts @@ -24,6 +24,20 @@ import { type TerminationReason, } from './session-metrics'; import migrations from '../../drizzle/migrations'; +import { + claimNextDispatchable, + getOutboxRow, + markDispatched, + markMissingSession, + markRetry, + markSuppressedByPresence, + parkExpiredPendingRows, + recordRaiseIntent, + recordResolveIntent, + recoverStaleInFlightRows, +} from '../attention-outbox-store'; +import { recordAttentionEventInputSchema } from './attention-event-input'; +import { computeNextAlarmTime, shouldEmitMetricsFromAttentionAlarm } from './attention-alarm'; import { readKiloSdkMessages, readKiloSdkSessionSnapshot, @@ -37,9 +51,17 @@ type IngestMetaKey = | 'ingestVersion' | 'closeReason' | 'metricsEmitted' + | 'metricsAlarmAt' | 'deleted' | 'sessionReadyNotified'; +/** + * Bound the outbox dispatch work per alarm so a hot loop can't run the alarm + * out of wallclock. The alarm reschedules itself to the earliest remaining + * `next_attempt_at` when it stops short. + */ +const MAX_DISPATCH_PER_ALARM = 25; + type ExtractableMetaKey = | 'title' | 'parentId' @@ -258,19 +280,19 @@ export class SessionIngestDO extends DurableObject { .delete(ingestMeta) .where(inArray(ingestMeta.key, ['metricsEmitted', 'closeReason'])) .run(); - await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS); + await this.setSharedAlarm(Date.now() + INACTIVITY_TIMEOUT_MS); } else { writeIngestMetaIfChanged(this.db, { key: 'closeReason', incomingValue: event.reason, }); - await this.ctx.storage.setAlarm(Date.now() + POST_CLOSE_DRAIN_MS); + await this.setSharedAlarm(Date.now() + POST_CLOSE_DRAIN_MS); } } // Events without open/close (stragglers) don't touch the alarm. } else { // v0 (legacy): no open/close signals, rely on inactivity timeout. - await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS); + await this.setSharedAlarm(Date.now() + INACTIVITY_TIMEOUT_MS); } const changes: Changes = []; @@ -343,6 +365,66 @@ export class SessionIngestDO extends DurableObject { ); } + /** + * Direct DO RPC: record a human-attention event for this session. + * + * Accepts only IDs and a classified raise/resolve intent — the DO never + * sees prompt text, permission arguments, or any other envelope payload. + * Raise intents are recorded into the per-session outbox; resolve intents + * cancel or stamp a terminal row. The alarm is rescheduled so the outbox + * dispatches on the next tick. + * + * Idempotency: a re-raise of the same request id is a no-op; resolving a + * request that was never raised is a no-op; re-raising a resolved row is + * a no-op (the user already saw the outcome). The dispatch path is the + * sole owner of network IO, so the RPC itself never calls the + * notifications service — failures there are contained to the alarm. + */ + async recordAttentionEvent( + rawParams: unknown + ): Promise<{ accepted: true } | { accepted: false; reason: 'invalid_input' | 'deleted' }> { + const parsed = recordAttentionEventInputSchema.safeParse(rawParams); + if (!parsed.success) { + return { accepted: false, reason: 'invalid_input' }; + } + const params = parsed.data; + + const deletedRow = this.db + .select({ value: ingestMeta.value }) + .from(ingestMeta) + .where(eq(ingestMeta.key, 'deleted')) + .get(); + if (deletedRow?.value === 'true') { + return { accepted: false, reason: 'deleted' }; + } + + writeIngestMetaIfChanged(this.db, { key: 'kiloUserId', incomingValue: params.kiloUserId }); + writeIngestMetaIfChanged(this.db, { key: 'sessionId', incomingValue: params.sessionId }); + + const now = Date.now(); + if (params.intent.kind === 'raise') { + recordRaiseIntent(this.db, { + requestId: params.requestId, + reason: params.intent.reason, + now, + }); + } else { + recordResolveIntent(this.db, { + requestId: params.requestId, + reason: params.intent.reason, + now, + }); + } + + // Always reschedule: a raise wants to fire immediately; a resolve may + // free the alarm entirely (no remaining work) or shift the next attempt + // to an earlier pending row. `rescheduleAlarm` picks the correct + // outcome. + await this.rescheduleAlarm(); + + return { accepted: true }; + } + async readKiloSdkSessionSnapshot(): Promise { return readKiloSdkSessionSnapshot(this.db, this.env.SESSION_INGEST_R2); } @@ -483,7 +565,13 @@ export class SessionIngestDO extends DurableObject { /** * Compute and emit session metrics to the o11y worker. - * Returns true if metrics were emitted, false if already emitted. + * Returns true if metrics were emitted, false if already emitted or no data. + * + * Emits a fresh alarm afterward via `rescheduleAlarm` so a metrics fire does + * not cancel any still-pending outbox work. The metrics alarm time is + * cleared once emission succeeds; the shared alarm scheduler reschedules + * itself to the earliest remaining outbox attempt (or deletes the alarm + * only when no work remains). */ private async emitSessionMetrics( kiloUserId: string, @@ -548,22 +636,134 @@ export class SessionIngestDO extends DurableObject { ...metrics, }); - // Mark metrics as emitted to prevent duplicates + // Mark metrics as emitted to prevent duplicates and clear the metrics + // alarm time so the shared scheduler no longer considers it. this.db .insert(ingestMeta) .values({ key: 'metricsEmitted', value: 'true' }) .onConflictDoUpdate({ target: ingestMeta.key, set: { value: 'true' } }) .run(); - - await this.ctx.storage.deleteAlarm(); + this.db.delete(ingestMeta).where(eq(ingestMeta.key, 'metricsAlarmAt')).run(); return true; } /** - * Alarm fires either after POST_CLOSE_DRAIN_MS (session closed) or - * INACTIVITY_TIMEOUT_MS (no activity). Reads the close reason from - * ingest_meta if present, otherwise falls back to 'abandoned'. + * Schedule (or reschedule) the single DO alarm to fire at the earliest + * pending work: the metrics alarm time when metrics have not yet emitted, + * or the earliest outbox `next_attempt_at`. When both are empty the alarm + * is deleted entirely. + */ + private async rescheduleAlarm(): Promise { + const next = computeNextAlarmTime(this.db); + if (next === null) { + await this.ctx.storage.deleteAlarm(); + } else { + await this.ctx.storage.setAlarm(next); + } + } + + /** + * Persist a new metrics alarm time and re-run the shared alarm scheduler. + * Every place ingest previously called `setAlarm` directly must use this + * helper so the outbox can still preempt the metrics timer. + */ + private async setSharedAlarm(when: number): Promise { + this.db + .insert(ingestMeta) + .values({ key: 'metricsAlarmAt', value: String(when) }) + .onConflictDoUpdate({ target: ingestMeta.key, set: { value: String(when) } }) + .run(); + await this.rescheduleAlarm(); + } + + /** + * Process up to `MAX_DISPATCH_PER_ALARM` outbox rows for the alarm's + * session. Each row is claimed, dispatched via the notifications binding, + * and transitioned to a terminal status. Rows that were resolved + * mid-flight are not overwritten. The caller is responsible for the final + * reschedule. + */ + private async dispatchOutboxBatch(params: { + kiloUserId: string; + sessionId: string; + }): Promise { + for (let i = 0; i < MAX_DISPATCH_PER_ALARM; i++) { + const claimed = claimNextDispatchable(this.db, { now: Date.now() }); + if (!claimed) return; + + let result: { + dispatched: boolean; + reason?: 'missing_session' | 'dispatch_failed' | 'suppressed_presence'; + }; + try { + result = await this.env.NOTIFICATIONS.sendSessionAttentionNotification({ + userId: params.kiloUserId, + cliSessionId: params.sessionId, + requestId: claimed.requestId, + reason: claimed.reason, + }); + } catch (_error) { + // A thrown error is treated like `dispatch_failed`: bounded retry + // through `markRetry` (terminal `failed` once `MAX_ATTEMPTS` is hit). + // We never log the request id or reason, and we never store the raw + // thrown message in the outbox — only fixed safe codes. + console.error('SessionIngestDO attention dispatch threw', { + sessionId: params.sessionId, + kiloUserId: params.kiloUserId, + attemptCount: claimed.attemptCount, + code: 'rpc_error', + }); + // If markRetry throws (unknown row), it is an invariant violation + // intentionally surfaced to the platform alarm retry; do not wrap + // or suppress it here. + markRetry(this.db, { + requestId: claimed.requestId, + now: Date.now(), + reason: 'rpc_error', + }); + continue; + } + + // A resolve that landed between the claim and the await must win. + const fresh = getOutboxRow(this.db, claimed.requestId); + if (fresh?.status === 'resolved') continue; + + if (result.dispatched) { + markDispatched(this.db, { requestId: claimed.requestId, now: Date.now() }); + continue; + } + + switch (result.reason) { + case 'suppressed_presence': + markSuppressedByPresence(this.db, { requestId: claimed.requestId, now: Date.now() }); + break; + case 'missing_session': + markMissingSession(this.db, { requestId: claimed.requestId, now: Date.now() }); + break; + case 'dispatch_failed': + default: + markRetry(this.db, { + requestId: claimed.requestId, + now: Date.now(), + reason: 'dispatch_failed', + }); + break; + } + } + } + + /** + * Alarm fires either after POST_CLOSE_DRAIN_MS (session closed), + * INACTIVITY_TIMEOUT_MS (no activity), or immediately after a + * `recordAttentionEvent` raise. The same alarm is shared between the + * metrics lifecycle and the outbox dispatch loop — every scheduling + * decision goes through `rescheduleAlarm` so the two cannot clobber + * each other. + * + * The body is intentionally resilient: dispatch errors are caught, + * logged, and translated into a bounded retry; only `emitSessionMetrics` + * errors propagate so the platform can retry the alarm itself. */ async alarm(): Promise { const metaRows = this.db @@ -576,38 +776,84 @@ export class SessionIngestDO extends DurableObject { 'closeReason', 'ingestVersion', 'deleted', + 'metricsEmitted', + 'metricsAlarmAt', ]) ) .all(); const meta = Object.fromEntries(metaRows.map(r => [r.key, r.value])); - if (meta['deleted'] === 'true') return; + if (meta['deleted'] === 'true') { + await this.ctx.storage.deleteAlarm(); + return; + } const kiloUserId = meta['kiloUserId']; const sessionId = meta['sessionId']; - if (!kiloUserId || !sessionId) return; + if (!kiloUserId || !sessionId) { + await this.ctx.storage.deleteAlarm(); + return; + } + + // Park any pending rows whose `next_attempt_at` has crossed the + // absolute dispatch window (raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS). + // Doing this at alarm start (and again inside `claimNextDispatchable` + // for defense in depth) keeps `earliestScheduledAttemptAt` from + // pointing the next alarm at a row that can no longer be safely + // dispatched under the receiver's 60min idempotency TTL. + parkExpiredPendingRows(this.db, { now: Date.now() }); + + // Recover any rows left in `in_flight` from a previous alarm that was + // lost (DO restart, crash between claim and ack). Each stale row is + // bumped back to `pending` with a fresh schedule, or parked at + // terminal `failed` if the cap is reached. Downstream dispatch is + // keyed on the stable request id, so a recovered row cannot double + // push. + recoverStaleInFlightRows(this.db, { now: Date.now() }); + + await this.dispatchOutboxBatch({ kiloUserId, sessionId }); + + // Re-read the metrics state AFTER `dispatchOutboxBatch` returns. The + // batch awaits external IO (the notifications RPC), during which a + // concurrent ingest can update `metricsAlarmAt` (e.g. a new + // session_open resetting the inactivity timer) or another alarm path + // can mark `metricsEmitted`. Emitting metrics on stale state read at + // the top of `alarm()` would publish prematurely when the immediate + // outbox alarm preempted a far-future metrics deadline, or would + // double-publish when emission was already done elsewhere. + const freshMeta = this.db + .select({ key: ingestMeta.key, value: ingestMeta.value }) + .from(ingestMeta) + .where(inArray(ingestMeta.key, ['metricsEmitted', 'metricsAlarmAt'])) + .all(); + const freshMetricsEmitted = freshMeta.find(row => row.key === 'metricsEmitted')?.value ?? null; + const freshMetricsAlarmAt = freshMeta.find(row => row.key === 'metricsAlarmAt')?.value ?? null; const closeReason = (meta['closeReason'] ?? 'abandoned') as TerminationReason; const ingestVersion = Number(meta['ingestVersion'] ?? '0') || 0; - // DO alarm exceptions don't populate the Exceptions array in logpush traces, - // so without this catch we get outcome=exception with zero diagnostics. - try { - await this.emitSessionMetrics(kiloUserId, sessionId, closeReason, ingestVersion); - } catch (error) { - console.error('SessionIngestDO alarm failed', { - sessionId, - kiloUserId, - closeReason, - ingestVersion, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); + if (shouldEmitMetricsFromAttentionAlarm(freshMetricsEmitted, freshMetricsAlarmAt, Date.now())) { + // DO alarm exceptions don't populate the Exceptions array in logpush traces, + // so without this catch we get outcome=exception with zero diagnostics. + try { + await this.emitSessionMetrics(kiloUserId, sessionId, closeReason, ingestVersion); + } catch (error) { + console.error('SessionIngestDO alarm failed', { + sessionId, + kiloUserId, + closeReason, + ingestVersion, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); - throw error; + throw error; + } } + + await this.rescheduleAlarm(); } /** Returns true when no ingest data has been stored for this session. */ diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index 83fddbf466..ff094fad78 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -12,6 +12,14 @@ vi.mock('cloudflare:workers', () => ({ }, })); +// Mock withDORetry to a no-retry pass-through so attention recording tests do +// not exercise backoff or internal worker-utils logging. +vi.mock('@kilocode/worker-utils', () => ({ + withDORetry: vi.fn(async (getStub: () => T, operation: (stub: T) => Promise) => + operation(getStub()) + ), +})); + import { MAX_CATALOG_RESULT_BYTES, UserConnectionDO } from './UserConnectionDO'; // --------------------------------------------------------------------------- @@ -153,7 +161,11 @@ function connectWebSocket(doInstance: UserConnectionDO, connectionId: string): M return server; } -function connectCliSocket(doInstance: UserConnectionDO, connectionId: string): MockWS { +function connectCliSocket( + doInstance: UserConnectionDO, + connectionId: string, + kiloUserId?: string +): MockWS { const client = createMockWs(); const server = createMockWs(); vi.stubGlobal( @@ -170,8 +182,11 @@ function connectCliSocket(doInstance: UserConnectionDO, connectionId: string): M } ); + const url = new URL(`http://local/cli?connectionId=${connectionId}`); + if (kiloUserId) url.searchParams.set('kiloUserId', kiloUserId); + doInstance.fetch( - new Request(`http://local/cli?connectionId=${connectionId}`, { + new Request(url.toString(), { headers: { Upgrade: 'websocket' }, }) ); @@ -182,9 +197,11 @@ function connectCliSocket(doInstance: UserConnectionDO, connectionId: string): M function addCliSocket( mockCtx: ReturnType, connectionId: string, - sessions: Array<{ id: string; status: string; title: string }> = [] + sessions: Array<{ id: string; status: string; title: string }> = [], + kiloUserId?: string ): MockWS { - const attachment = { role: 'cli' as const, connectionId, sessions }; + const attachment: Record = { role: 'cli', connectionId, sessions }; + if (kiloUserId) attachment.kiloUserId = kiloUserId; const ws = createMockWs(['cli'], attachment); mockCtx.addSocket(ws); return ws; @@ -2241,4 +2258,245 @@ describe('UserConnectionDO', () => { expect(server.deserializeAttachment()).toMatchObject({ role: 'cli', kiloUserId: 'usr_1' }); }); }); + + // ------------------------------------------------------------------------- + // Attention outbox relay + // ------------------------------------------------------------------------- + + describe('attention outbox relay', () => { + function setupWithAttentionDO() { + const mockCtx = createMockCtx(); + const ctx = mockCtx.build(); + const recordAttentionEvent = vi.fn(); + const env = { + SESSION_INGEST_DO: { + idFromName: vi.fn((name: string) => name), + get: vi.fn(() => ({ recordAttentionEvent })), + }, + }; + const doInstance = new UserConnectionDO(ctx as never, env as never); + return { doInstance, mockCtx, ctx, recordAttentionEvent }; + } + + function sendCliEvent( + doInstance: UserConnectionDO, + cliWs: MockWS, + opts: { sessionId: string; parentSessionId?: string; event: string; data: unknown } + ) { + doInstance.webSocketMessage(cliWs as never, JSON.stringify({ type: 'event', ...opts })); + } + + it('records a root question raise even with no web subscribers', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'question.asked', + data: { id: 'q_1' }, + }); + + expect(recordAttentionEvent).toHaveBeenCalledTimes(1); + expect(recordAttentionEvent).toHaveBeenCalledWith({ + kiloUserId: 'usr_1', + sessionId: 'ses_1', + requestId: 'q_1', + intent: { kind: 'raise', reason: 'question' }, + }); + }); + + it('records a root permission raise even with no web subscribers', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'permission.asked', + data: { requestId: 'p_1' }, + }); + + expect(recordAttentionEvent).toHaveBeenCalledWith({ + kiloUserId: 'usr_1', + sessionId: 'ses_1', + requestId: 'p_1', + intent: { kind: 'raise', reason: 'permission' }, + }); + }); + + it('records a root blocking suggestion raise even with no web subscribers', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'suggestion.shown', + data: { id: 's_1', blocking: true }, + }); + + expect(recordAttentionEvent).toHaveBeenCalledWith({ + kiloUserId: 'usr_1', + sessionId: 'ses_1', + requestId: 's_1', + intent: { kind: 'raise', reason: 'blocking_suggestion' }, + }); + }); + + it.each([ + ['question.replied', 'q_1'], + ['question.rejected', 'q_1'], + ['permission.replied', 'p_1'], + ['suggestion.accepted', 's_1'], + ['suggestion.dismissed', 's_1'], + ] as const)('resolves a %s event', (event, requestId) => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event, + data: { id: requestId }, + }); + + expect(recordAttentionEvent).toHaveBeenCalledWith({ + kiloUserId: 'usr_1', + sessionId: 'ses_1', + requestId, + intent: { kind: 'resolve' }, + }); + }); + + it.each([ + ['nonblocking suggestion', 'suggestion.shown', { id: 's_1', blocking: false }], + ['missing request id', 'question.asked', {}], + ['network event', 'session.network.asked', { id: 'nw_1' }], + ['retry event', 'session.retry', { id: 'r_1' }], + ['action_required event', 'action_required', { id: 'ar_1' }], + ] as const)('skips a %s', (_label, event, data) => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event, + data, + }); + + expect(recordAttentionEvent).not.toHaveBeenCalled(); + }); + + it('skips child events and still relays them to subscribers', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + const webWs = addWebSocket(mockCtx, 'web-1', ['child_1']); + + const eventData = { id: 'q_1', properties: { question: 'what?' } }; + sendCliEvent(doInstance, cliWs, { + sessionId: 'child_1', + parentSessionId: 'parent_1', + event: 'question.asked', + data: eventData, + }); + + expect(recordAttentionEvent).not.toHaveBeenCalled(); + expect(parseSent(webWs)).toEqual({ + type: 'event', + sessionId: 'child_1', + parentSessionId: 'parent_1', + event: 'question.asked', + data: eventData, + }); + }); + + it('skips recording when the CLI attachment has no kiloUserId', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'question.asked', + data: { id: 'q_1' }, + }); + + expect(recordAttentionEvent).not.toHaveBeenCalled(); + expect(parseSent(webWs)).toMatchObject({ event: 'question.asked' }); + }); + + it('relays the exact event and data to web subscribers', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']); + + const eventData = { id: 'q_1', nested: { value: 'keep-me' } }; + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'question.asked', + data: eventData, + }); + + expect(recordAttentionEvent).toHaveBeenCalled(); + expect(parseSent(webWs)).toEqual({ + type: 'event', + sessionId: 'ses_1', + event: 'question.asked', + data: eventData, + }); + }); + + it('does not block relay when recording rejects', () => { + const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']); + recordAttentionEvent.mockRejectedValue(new Error('outbox failure')); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'question.asked', + data: { id: 'q_1' }, + }); + + expect(parseSent(webWs)).toMatchObject({ event: 'question.asked' }); + }); + + it('logs recording failures without request id, payload, or error', async () => { + const { doInstance, mockCtx, ctx, recordAttentionEvent } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + recordAttentionEvent.mockRejectedValue(new Error('secret-outbox-error')); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'question.asked', + data: { id: 'q_1', body: 'secret-payload' }, + }); + + const waitUntilPromise = ctx.waitUntil.mock.calls[0][0] as Promise; + await waitUntilPromise; + + const errorCall = errorSpy.mock.calls.find( + call => call[0] === 'Failed to record attention event (non-fatal)' + ); + expect(errorCall).toBeDefined(); + const loggedArgs = JSON.stringify(errorCall); + expect(loggedArgs).not.toContain('q_1'); + expect(loggedArgs).not.toContain('secret-payload'); + expect(loggedArgs).not.toContain('secret-outbox-error'); + expect(parseSent(webWs)).toMatchObject({ event: 'question.asked' }); + }); + + it('schedules attention recording via waitUntil', () => { + const { doInstance, mockCtx, ctx } = setupWithAttentionDO(); + const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1'); + ctx.waitUntil.mockClear(); + + sendCliEvent(doInstance, cliWs, { + sessionId: 'ses_1', + event: 'question.asked', + data: { id: 'q_1' }, + }); + + expect(ctx.waitUntil).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index e79014dc7f..4fe8a5f6b1 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -1,7 +1,9 @@ import { DurableObject } from 'cloudflare:workers'; +import { withDORetry } from '@kilocode/worker-utils'; import type { Env } from '../env'; import { getSessionIngestDO } from './SessionIngestDO'; +import { extractAttentionSignal, type AttentionIntent } from '../attention-outbox'; import { CLIOutboundMessageSchema, type CLIInboundMessage, @@ -324,7 +326,13 @@ export class UserConnectionDO extends DurableObject { this.handleHeartbeat(ws, attachment, msg.sessions, msg.protocolVersion); break; case 'event': - this.handleCliEvent(msg.sessionId, msg.parentSessionId, msg.event, msg.data); + this.handleCliEvent( + msg.sessionId, + msg.parentSessionId, + msg.event, + msg.data, + attachment.kiloUserId + ); break; case 'response': this.handleCliResponse(ws, msg.id, msg.result, msg.error); @@ -435,8 +443,20 @@ export class UserConnectionDO extends DurableObject { sessionId: string, parentSessionId: string | undefined, event: string, - data: unknown + data: unknown, + kiloUserId: string | undefined ): void { + // Record human-attention signals durably BEFORE the no-subscriber + // early return so a session that briefly has no web subscribers + // (race on subscribe) still gets a push. Child events are skipped: + // mobile cannot safely answer requests originating from a subagent. + if (!parentSessionId) { + const signal = extractAttentionSignal(event, data); + if (signal !== null) { + this.recordAttentionEvent(kiloUserId, sessionId, signal.intent, signal.requestId); + } + } + const childSubs = this.webSubscriptions.get(sessionId); const parentSubs = parentSessionId ? this.webSubscriptions.get(parentSessionId) : undefined; if (!childSubs && !parentSubs) return; @@ -458,6 +478,47 @@ export class UserConnectionDO extends DurableObject { } } + /** + * Fire-and-forget outbox write for a human-attention signal. When + * `kiloUserId` is unavailable (legacy attachment) the relay continues + * unchanged but no push is recorded — the in-session UI is still + * forwarded to any web subscribers by the caller. The DO's `accepted:false` + * outcome is a terminal structural no-op (deleted/invalid session) and + * is intentionally not logged to avoid per-event noise; thrown availability + * failures retain fixed-code logging. The request id, prompt content, and + * arbitrary thrown message text are never written to logs. + */ + private recordAttentionEvent( + kiloUserId: string | undefined, + sessionId: string, + intent: AttentionIntent, + requestId: string + ): void { + if (!kiloUserId) return; + this.ctx.waitUntil( + withDORetry( + () => getSessionIngestDO(this.env, { kiloUserId, sessionId }), + async stub => { + await stub.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId, + intent: + intent.kind === 'raise' + ? { kind: 'raise', reason: intent.reason } + : { kind: 'resolve' }, + }); + }, + 'SessionIngestDO.recordAttentionEvent' + ).catch(() => { + console.error('Failed to record attention event (non-fatal)', { + sessionId, + code: 'record_attention_failed', + }); + }) + ); + } + private handleCliResponse( respondingWs: WebSocket, id: string, diff --git a/services/session-ingest/src/dos/attention-alarm.test.ts b/services/session-ingest/src/dos/attention-alarm.test.ts new file mode 100644 index 0000000000..098130d616 --- /dev/null +++ b/services/session-ingest/src/dos/attention-alarm.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for the shared alarm scheduler that coordinates the attention outbox + * with the session metrics lifecycle. + */ + +import { describe, expect, it } from 'vitest'; + +import { ingestMeta, attentionOutbox } from '../db/sqlite-schema'; +import { + computeNextAlarmTime, + minPendingAlarmTime, + shouldEmitMetricsFromAttentionAlarm, +} from './attention-alarm'; + +describe('minPendingAlarmTime', () => { + it('returns only metrics when outbox is null', () => { + expect(minPendingAlarmTime(1000, false, null)).toBe(1000); + }); + + it('returns only outbox when metrics is null', () => { + expect(minPendingAlarmTime(null, false, 2000)).toBe(2000); + }); + + it('returns the minimum when both are present', () => { + expect(minPendingAlarmTime(5000, false, 2000)).toBe(2000); + expect(minPendingAlarmTime(1000, false, 2000)).toBe(1000); + }); + + it('ignores metrics when metricsEmitted is true', () => { + expect(minPendingAlarmTime(1000, true, null)).toBeNull(); + expect(minPendingAlarmTime(1000, true, 2000)).toBe(2000); + }); + + it('ignores metrics when metricsEmitted is the string "true"', () => { + expect(minPendingAlarmTime(1000, 'true', null)).toBeNull(); + expect(minPendingAlarmTime(1000, 'true', 2000)).toBe(2000); + }); + + it('ignores invalid numeric metricsAlarmAt values', () => { + expect(minPendingAlarmTime(NaN, false, null)).toBeNull(); + expect(minPendingAlarmTime('', false, null)).toBeNull(); + expect(minPendingAlarmTime('invalid', false, null)).toBeNull(); + expect(minPendingAlarmTime(0, false, null)).toBeNull(); + expect(minPendingAlarmTime(-1, false, null)).toBeNull(); + expect(minPendingAlarmTime(undefined, false, null)).toBeNull(); + }); + + it('returns null when both inputs are absent', () => { + expect(minPendingAlarmTime(null, false, null)).toBeNull(); + expect(minPendingAlarmTime(undefined, false, null)).toBeNull(); + }); +}); + +describe('computeNextAlarmTime', () => { + it('wires DB meta and outbox to minPendingAlarmTime', () => { + const metaRows = [ + { key: 'metricsAlarmAt', value: '5000' }, + { key: 'metricsEmitted', value: 'false' }, + ]; + const outboxRow = { next_attempt_at: 2000 }; + + let currentTable: string | null = null; + const selectChain = { + from: (table: unknown) => { + currentTable = + table === ingestMeta + ? 'ingest_meta' + : table === attentionOutbox + ? 'attention_outbox' + : null; + return selectChain; + }, + where: () => selectChain, + orderBy: () => selectChain, + limit: () => selectChain, + all: () => (currentTable === 'ingest_meta' ? metaRows : []), + get: () => (currentTable === 'attention_outbox' ? outboxRow : undefined), + }; + + const db = { + select: () => selectChain, + }; + + expect(computeNextAlarmTime(db as never)).toBe(2000); + }); +}); + +describe('shouldEmitMetricsFromAttentionAlarm', () => { + // Pure helper used by the alarm() body to decide whether the immediate + // attention alarm should also drive a metrics emission. It must ignore + // calls when the persisted metrics deadline is still in the future or + // missing entirely (legacy platform alarms without a persisted deadline). + const NOW = 1_700_000_000_000; + + it('emits when metricsAlarmAt is finite positive and <= now', () => { + expect(shouldEmitMetricsFromAttentionAlarm('false', String(NOW - 1), NOW)).toBe(true); + expect(shouldEmitMetricsFromAttentionAlarm('false', String(NOW), NOW)).toBe(true); + }); + + it('skips when metricsAlarmAt is strictly in the future', () => { + expect(shouldEmitMetricsFromAttentionAlarm('false', String(NOW + 1), NOW)).toBe(false); + expect(shouldEmitMetricsFromAttentionAlarm(null, String(NOW + 60_000), NOW)).toBe(false); + }); + + it('skips when metricsEmitted is already true', () => { + expect(shouldEmitMetricsFromAttentionAlarm('true', String(NOW - 100), NOW)).toBe(false); + }); + + it('skips when metricsAlarmAt is missing (legacy platform alarm)', () => { + expect(shouldEmitMetricsFromAttentionAlarm('false', null, NOW)).toBe(false); + expect(shouldEmitMetricsFromAttentionAlarm(null, null, NOW)).toBe(false); + }); + + it('skips when metricsAlarmAt is non-numeric or non-positive', () => { + expect(shouldEmitMetricsFromAttentionAlarm('false', 'not-a-number', NOW)).toBe(false); + expect(shouldEmitMetricsFromAttentionAlarm('false', '', NOW)).toBe(false); + expect(shouldEmitMetricsFromAttentionAlarm('false', '0', NOW)).toBe(false); + expect(shouldEmitMetricsFromAttentionAlarm('false', '-1', NOW)).toBe(false); + }); + + it('treats the boolean-true metricsEmitted as already emitted (defense in depth)', () => { + // The DO reads metricsEmitted as a string column; this guard makes the + // helper safe if a future refactor passes the raw row value through. + expect( + shouldEmitMetricsFromAttentionAlarm(true as unknown as string, String(NOW - 1), NOW) + ).toBe(false); + }); +}); diff --git a/services/session-ingest/src/dos/attention-alarm.ts b/services/session-ingest/src/dos/attention-alarm.ts new file mode 100644 index 0000000000..bcb8c5e31d --- /dev/null +++ b/services/session-ingest/src/dos/attention-alarm.ts @@ -0,0 +1,99 @@ +/** + * Shared alarm scheduling for the attention outbox and session metrics lifecycle. + * + * The DO uses one alarm for both concerns so the outbox can preempt the + * metrics drain and vice versa. The helpers here are intentionally pure + * except for `computeNextAlarmTime`, which is a thin wrapper that reads the + * current DB state and delegates to the testable `minPendingAlarmTime` rule. + */ + +import { inArray } from 'drizzle-orm'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; + +import { ingestMeta } from '../db/sqlite-schema'; +import { earliestScheduledAttemptAt } from '../attention-outbox-store'; + +/** + * Pure: pick the earliest pending alarm time from the metrics alarm and the + * outbox schedule. Ignores metrics when they have already emitted, and + * ignores non-numeric or non-positive alarm times. + */ +export function minPendingAlarmTime( + metricsAlarmAt: number | string | null | undefined, + metricsEmitted: boolean | string | null | undefined, + outboxAt: number | null +): number | null { + const candidates: number[] = []; + + if (outboxAt !== null && Number.isFinite(outboxAt)) { + candidates.push(outboxAt); + } + + if (metricsEmitted !== true && metricsEmitted !== 'true') { + const parsed = typeof metricsAlarmAt === 'number' ? metricsAlarmAt : Number(metricsAlarmAt); + if (Number.isFinite(parsed) && parsed > 0) { + candidates.push(parsed); + } + } + + if (candidates.length === 0) return null; + return Math.min(...candidates); +} + +/** + * Read the current session state and return the earliest alarm time that + * still has work pending. Returns null when both metrics and outbox are idle. + */ +export function computeNextAlarmTime(db: DrizzleSqliteDODatabase): number | null { + const metaRows = db + .select() + .from(ingestMeta) + .where(inArray(ingestMeta.key, ['metricsAlarmAt', 'metricsEmitted'])) + .all(); + const meta = Object.fromEntries(metaRows.map(r => [r.key, r.value])); + const outboxAt = earliestScheduledAttemptAt(db); + + return minPendingAlarmTime( + meta['metricsAlarmAt'] !== undefined ? Number(meta['metricsAlarmAt']) : null, + meta['metricsEmitted'], + outboxAt + ); +} + +/** + * Decide whether the immediate-attention outbox alarm should additionally + * drive a session-metrics emission on the current tick. + * + * The attention outbox and the session-metrics lifecycle share one DO alarm + * so the outbox can preempt the metrics timer. When the alarm fires + * because a raise was recorded, the metrics deadline is almost always still + * in the future; emitting at that tick would publish metrics prematurely. + * + * The DO re-reads `metricsEmitted` and `metricsAlarmAt` from SQLite after + * `dispatchOutboxBatch` returns so a concurrent update that arrives during + * the awaited notification RPC is respected. This helper encapsulates the + * decision so the alarm body stays small and the rule is unit-testable. + * + * Rules: + * - `metricsEmitted === 'true'` → skip (already published). + * - `metricsAlarmAt` missing or non-numeric → skip (legacy platform alarm + * that reached the DO without a persisted deadline; emitting now would + * be premature). + * - `metricsAlarmAt <= 0` → skip (non-positive deadline is invalid). + * - `metricsAlarmAt > now` → skip (deadline still in the future; preserve). + * - otherwise → emit. + */ +export function shouldEmitMetricsFromAttentionAlarm( + metricsEmitted: string | null | boolean, + metricsAlarmAt: string | null, + now: number +): boolean { + // Defense in depth: the DO reads `metricsEmitted` as a string column, + // but accept the boolean `true` as well so a future refactor that + // passes the parsed value through stays safe. + if (metricsEmitted === 'true' || metricsEmitted === true) return false; + if (metricsAlarmAt === null || metricsAlarmAt === undefined) return false; + const parsed = Number(metricsAlarmAt); + if (!Number.isFinite(parsed) || parsed <= 0) return false; + return parsed <= now; +} diff --git a/services/session-ingest/src/dos/attention-event-input.test.ts b/services/session-ingest/src/dos/attention-event-input.test.ts new file mode 100644 index 0000000000..94268f36cb --- /dev/null +++ b/services/session-ingest/src/dos/attention-event-input.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for the runtime-validated input of `SessionIngestDO.recordAttentionEvent`. + */ + +import { describe, expect, it } from 'vitest'; + +import { recordAttentionEventInputSchema } from './attention-event-input'; + +function validBase() { + return { + kiloUserId: 'u_1', + sessionId: 's_1', + requestId: 'r_1', + }; +} + +describe('recordAttentionEventInputSchema', () => { + it('accepts a valid raise intent', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'raise', reason: 'question' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.intent).toEqual({ kind: 'raise', reason: 'question' }); + } + }); + + it.each(['question', 'permission', 'blocking_suggestion'] as const)( + 'accepts a valid resolve intent with reason %s', + reason => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'resolve', reason }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + ...validBase(), + intent: { kind: 'resolve', reason }, + }); + } + } + ); + + it('rejects a resolve intent without a reason', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'resolve' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a resolve intent with an unknown reason', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'resolve', reason: 'action_required' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a resolve intent with a non-string reason', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'resolve', reason: 123 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an unknown reason', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'raise', reason: 'unknown' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an empty reason', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'raise', reason: '' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid reason type', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'raise', reason: 123 }, + }); + expect(result.success).toBe(false); + }); + + it('strips any raw body or envelope payload', () => { + const result = recordAttentionEventInputSchema.safeParse({ + ...validBase(), + intent: { kind: 'raise', reason: 'question' }, + body: 'raw prompt text that must be stripped', + promptText: 'another raw field', + envelope: { id: 'e_1', payload: 'secret' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('body'); + expect(result.data).not.toHaveProperty('promptText'); + expect(result.data).not.toHaveProperty('envelope'); + expect(result.data).toEqual({ + ...validBase(), + intent: { kind: 'raise', reason: 'question' }, + }); + } + }); +}); diff --git a/services/session-ingest/src/dos/attention-event-input.ts b/services/session-ingest/src/dos/attention-event-input.ts new file mode 100644 index 0000000000..7225ca4f74 --- /dev/null +++ b/services/session-ingest/src/dos/attention-event-input.ts @@ -0,0 +1,34 @@ +/** + * Runtime-validated input for `SessionIngestDO.recordAttentionEvent`. + * + * The DO only accepts IDs and a classified raise/resolve intent — it never + * sees prompt text, permission arguments, or any other envelope payload. The + * notifications service renders copy and looks up presence; this DO is + * responsible for turning the producer's per-request signal into a durable + * outbox row. + */ +import { z } from 'zod'; + +import { attentionReasonSchema } from '../attention-outbox'; + +// The resolve intent must carry the same reason the matching raise would +// have. Without a reason, an out-of-order resolve arriving before a raise +// cannot insert a tombstone with the correct `reason`, and the +// outbox-level "preserve original reason" rule has nothing to compare +// against. `action_required` is intentionally excluded — the shared +// reason stays available in the schema, but the raw Kilo contract does +// not yet expose a stable request id and genuinely user-actionable typed +// action, so the producer ignores it. +export const attentionIntentSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('raise'), reason: attentionReasonSchema }), + z.object({ kind: z.literal('resolve'), reason: attentionReasonSchema }), +]); +export type AttentionIntentInput = z.infer; + +export const recordAttentionEventInputSchema = z.object({ + kiloUserId: z.string().min(1), + sessionId: z.string().min(1), + requestId: z.string().min(1), + intent: attentionIntentSchema, +}); +export type RecordAttentionEventInput = z.infer; diff --git a/services/session-ingest/src/notifications-binding.ts b/services/session-ingest/src/notifications-binding.ts index deb6749e6d..beb5765d56 100644 --- a/services/session-ingest/src/notifications-binding.ts +++ b/services/session-ingest/src/notifications-binding.ts @@ -7,6 +7,8 @@ */ import type { + SendSessionAttentionNotificationParams, + SendSessionAttentionNotificationResult, SendSessionReadyNotificationParams, SendSessionReadyNotificationResult, } from '@kilocode/notifications'; @@ -15,4 +17,7 @@ export type NotificationsBinding = Fetcher & { sendSessionReadyNotification( params: SendSessionReadyNotificationParams ): Promise; + sendSessionAttentionNotification( + params: SendSessionAttentionNotificationParams + ): Promise; }; diff --git a/services/session-ingest/src/session-ingest-rpc.test.ts b/services/session-ingest/src/session-ingest-rpc.test.ts index 0a2c549544..b1f55c448b 100644 --- a/services/session-ingest/src/session-ingest-rpc.test.ts +++ b/services/session-ingest/src/session-ingest-rpc.test.ts @@ -51,6 +51,7 @@ import { } from '@kilocode/session-ingest-contracts'; import { desc, gte, isNotNull, or } from 'drizzle-orm'; import { getSessionIngestDO } from './dos/SessionIngestDO'; +import type { RecordAttentionEventInput } from './dos/attention-event-input'; import { SessionIngestRPC } from './session-ingest-rpc'; const sdkSessionInfoFixture = { @@ -922,3 +923,147 @@ describe('SessionIngestRPC.listCloudAgentRootSessions', () => { expect(db.select).not.toHaveBeenCalled(); }); }); + +describe('SessionIngestRPC.recordCloudAgentSessionAttention', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('maps kiloSessionId to the DO sessionId and forwards only safe fields for a raise', async () => { + const { db } = makeDbFakes([]); + const recordAttentionEvent = vi.fn(async () => ({ accepted: true }) as const); + const ingestStub = { recordAttentionEvent }; + vi.mocked(getSessionIngestDO).mockReturnValue(ingestStub as never); + const rpc = makeRpc(db); + + const result = await rpc.recordCloudAgentSessionAttention({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_01', + intent: { kind: 'raise', reason: 'question' }, + }); + + expect(result).toEqual({ accepted: true }); + expect(getSessionIngestDO).toHaveBeenCalledWith(expect.anything(), { + kiloUserId: 'usr_owner', + sessionId: sdkSessionInfoFixture.id, + }); + expect(recordAttentionEvent).toHaveBeenCalledTimes(1); + expect(recordAttentionEvent).toHaveBeenCalledWith({ + kiloUserId: 'usr_owner', + sessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_01', + intent: { kind: 'raise', reason: 'question' }, + }); + }); + + it('forwards a resolve intent with the reason field', async () => { + const { db } = makeDbFakes([]); + const recordAttentionEvent = vi.fn(async () => ({ accepted: true }) as const); + vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never); + const rpc = makeRpc(db); + + const result = await rpc.recordCloudAgentSessionAttention({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_02', + intent: { kind: 'resolve', reason: 'question' }, + }); + + expect(result).toEqual({ accepted: true }); + expect(recordAttentionEvent).toHaveBeenCalledWith({ + kiloUserId: 'usr_owner', + sessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_02', + intent: { kind: 'resolve', reason: 'question' }, + }); + }); + + it('returns accepted false without calling the DO when IDs are invalid', async () => { + const { db } = makeDbFakes([]); + const recordAttentionEvent = vi.fn(); + vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never); + const rpc = makeRpc(db); + + const result = await rpc.recordCloudAgentSessionAttention({ + kiloUserId: 'usr_owner', + kiloSessionId: 'not-a-session', + requestId: 'req_attn_01', + intent: { kind: 'raise', reason: 'question' }, + }); + + expect(result).toEqual({ accepted: false, reason: 'invalid_input' }); + expect(getSessionIngestDO).not.toHaveBeenCalled(); + expect(recordAttentionEvent).not.toHaveBeenCalled(); + }); + + it('returns accepted false without calling the DO when the reason is invalid', async () => { + const { db } = makeDbFakes([]); + const recordAttentionEvent = vi.fn(); + vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never); + const rpc = makeRpc(db); + + const result = await rpc.recordCloudAgentSessionAttention({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_01', + // Cast through unknown so the test exercises the runtime validator, not + // the compile-time type. + intent: { kind: 'raise', reason: 'blocking_suggestion' }, + } as never); + + expect(result).toEqual({ accepted: false, reason: 'invalid_input' }); + expect(getSessionIngestDO).not.toHaveBeenCalled(); + expect(recordAttentionEvent).not.toHaveBeenCalled(); + }); + + it('does not forward extra body or envelope fields to the DO', async () => { + const { db } = makeDbFakes([]); + const recordAttentionEvent = vi.fn( + async (_input: RecordAttentionEventInput) => ({ accepted: true }) as const + ); + vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never); + const rpc = makeRpc(db); + + const result = await rpc.recordCloudAgentSessionAttention({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_01', + intent: { kind: 'raise', reason: 'question' }, + // The contract schema strips these out — the DO must never see them. + body: 'raw prompt text that must be stripped', + permissionArgs: { command: 'rm -rf /' }, + } as never); + + expect(result).toEqual({ accepted: true }); + expect(recordAttentionEvent).toHaveBeenCalledTimes(1); + const forwarded = recordAttentionEvent.mock.calls[0]?.[0]; + expect(forwarded).toEqual({ + kiloUserId: 'usr_owner', + sessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_01', + intent: { kind: 'raise', reason: 'question' }, + }); + expect(forwarded).not.toHaveProperty('body'); + expect(forwarded).not.toHaveProperty('permissionArgs'); + }); + + it('preserves a DO-returned accepted false reason through the normalized result', async () => { + const { db } = makeDbFakes([]); + const recordAttentionEvent = vi.fn( + async () => ({ accepted: false, reason: 'deleted' }) as const + ); + vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never); + const rpc = makeRpc(db); + + const result = await rpc.recordCloudAgentSessionAttention({ + kiloUserId: 'usr_owner', + kiloSessionId: sdkSessionInfoFixture.id, + requestId: 'req_attn_01', + intent: { kind: 'resolve', reason: 'permission' }, + }); + + expect(result).toEqual({ accepted: false, reason: 'deleted' }); + expect(recordAttentionEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/session-ingest/src/session-ingest-rpc.ts b/services/session-ingest/src/session-ingest-rpc.ts index 34252535a7..67db79963a 100644 --- a/services/session-ingest/src/session-ingest-rpc.ts +++ b/services/session-ingest/src/session-ingest-rpc.ts @@ -9,6 +9,7 @@ import { kiloSdkSessionSnapshotOutcomeSchema, listCloudAgentRootSessionsSchema, persistedKiloSdkMessageHistorySchema, + recordCloudAgentSessionAttentionSchema, resolveCloudAgentRootSessionSchema, type CloudAgentRootSessionSnapshot, type CloudAgentRootSessionSummary, @@ -19,6 +20,8 @@ import { type GetCloudAgentRootSessionSnapshotParams, type GetCloudAgentRootSessionSnapshotResult, type ListCloudAgentRootSessionsParams, + type RecordCloudAgentSessionAttentionParams, + type RecordCloudAgentSessionAttentionResult, type ResolveCloudAgentRootSessionForKiloSessionParams, type ResolveCloudAgentRootSessionForKiloSessionResult, type SessionIngestRpcMethods, @@ -396,4 +399,46 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn }); } } + + /** + * RPC method: record a Cloud Agent attention signal (question/permission + * raise or resolve) for the per-session SessionIngestDO. Used by the + * cloud-agent-next attention adapter to forward wrapper events after + * the wrapper's policy filter has already suppressed auto-approved and + * plan-followup/code-review events. + * + * Maps the Cloud Agent boundary (`kiloSessionId`) to the DO's + * `sessionId` field. The DO validates the rest of the payload against + * `recordAttentionEventInputSchema` and owns the durable outbox plus + * notification dispatch; the RPC is a thin shim that returns the + * accepted-or-not outcome the adapter forwards to its logs. + */ + async recordCloudAgentSessionAttention( + params: RecordCloudAgentSessionAttentionParams + ): Promise { + const parsed = recordCloudAgentSessionAttentionSchema.safeParse(params); + if (!parsed.success) { + return { accepted: false, reason: 'invalid_input' }; + } + return withDORetry( + () => + getSessionIngestDO(this.env, { + kiloUserId: parsed.data.kiloUserId, + sessionId: parsed.data.kiloSessionId, + }), + async stub => { + const result = await stub.recordAttentionEvent({ + kiloUserId: parsed.data.kiloUserId, + sessionId: parsed.data.kiloSessionId, + requestId: parsed.data.requestId, + intent: + parsed.data.intent.kind === 'raise' + ? { kind: 'raise', reason: parsed.data.intent.reason } + : { kind: 'resolve', reason: parsed.data.intent.reason }, + }); + return result.accepted ? { accepted: true } : { accepted: false, reason: result.reason }; + }, + 'SessionIngestDO.recordAttentionEvent' + ); + } } diff --git a/services/session-ingest/test/integration/session-attention-outbox.test.ts b/services/session-ingest/test/integration/session-attention-outbox.test.ts new file mode 100644 index 0000000000..405df6be99 --- /dev/null +++ b/services/session-ingest/test/integration/session-attention-outbox.test.ts @@ -0,0 +1,1351 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import type { + SendSessionAttentionNotificationParams, + SendSessionAttentionNotificationResult, +} from '@kilocode/notifications'; +import { + MAX_ATTEMPTS, + ATTENTION_MAX_DISPATCH_AGE_MS, + dispatchDeadline, +} from '../../src/attention-outbox'; +import { markRetry } from '../../src/attention-outbox-store'; + +type OutboxRow = { + request_id: string; + reason: string; + status: string; + attempt_count: number; + next_attempt_at: number | null; + last_error: string | null; + raised_at: number; + resolved_at: number | null; +}; + +type MockO11Y = { + ingestSessionMetrics: ReturnType; +}; + +type MockNotifications = { + sendSessionAttentionNotification: ReturnType; + sendSessionReadyNotification: ReturnType; +}; + +function getStub(kiloUserId: string, sessionId: string) { + const doKey = `${kiloUserId}/${sessionId}`; + const id = env.SESSION_INGEST_DO.idFromName(doKey); + return env.SESSION_INGEST_DO.get(id); +} + +function readOutboxRows(state: DurableObjectState): OutboxRow[] { + return [ + ...state.storage.sql.exec('SELECT * FROM attention_outbox ORDER BY request_id'), + ]; +} + +function readMeta(state: DurableObjectState, key: string): string | null { + const cursor = state.storage.sql.exec<{ value: string | null }>( + 'SELECT value FROM ingest_meta WHERE key = ?', + key + ); + const row = cursor.next().value; + return row?.value ?? null; +} + +function getStoreDb(instance: DurableObject): DrizzleSqliteDODatabase { + return (instance as unknown as { db: DrizzleSqliteDODatabase }).db; +} + +function makeMockNotifications( + calls: SendSessionAttentionNotificationParams[], + behavior: ( + params: SendSessionAttentionNotificationParams + ) => Promise = async () => ({ + dispatched: true, + }) +): MockNotifications { + return { + sendSessionAttentionNotification: vi.fn( + async (params: SendSessionAttentionNotificationParams) => { + calls.push(params); + return behavior(params); + } + ), + sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })), + }; +} + +function makeMockO11Y(): MockO11Y { + return { + ingestSessionMetrics: vi.fn(async () => {}), + }; +} + +function createDeferred() { + let resolve: (value: T) => void = () => {}; + let reject: (reason: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function installMocks(instance: DurableObject, mock: MockNotifications, o11y: MockO11Y) { + const typed = instance as unknown as { + env: { NOTIFICATIONS: MockNotifications; O11Y: MockO11Y }; + }; + typed.env.NOTIFICATIONS = mock; + typed.env.O11Y = o11y; +} + +function runAlarm(instance: DurableObject): Promise { + return (instance as unknown as { alarm: () => Promise }).alarm(); +} + +const kiloUserId = 'usr_attention_integration'; + +async function seedSession(stub: ReturnType, sessionId: string): Promise { + return runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.ingest( + [{ type: 'session', data: { title: 'Attention Test Session' } }], + kiloUserId, + sessionId, + 1 + ); + await state.storage.deleteAlarm(); + }); +} + +describe('SessionIngestDO attention outbox integration', () => { + const createdSessionIds: string[] = []; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + vi.useRealTimers(); + for (const sessionId of createdSessionIds) { + const stub = getStub(kiloUserId, sessionId); + // Delete any scheduled alarm before clearing so cleanup cannot trigger an + // alarm with real (or absent) bindings installed. + await runInDurableObject(stub, async (_instance, state) => { + await state.storage.deleteAlarm(); + }); + await stub.clear(); + } + createdSessionIds.length = 0; + }); + + function trackSession(sessionId: string) { + createdSessionIds.push(sessionId); + return sessionId; + } + + it('records one outbox row for duplicate raises and dispatches on alarm', async () => { + const sessionId = trackSession('ses_attention_duplicate_resolve'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_dup_resolve', + intent: { kind: 'raise', reason: 'question' }, + }); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_dup_resolve', + intent: { kind: 'raise', reason: 'question' }, + }); + await runAlarm(instance); + const rows = readOutboxRows(state); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + request_id: 'req_dup_resolve', + status: 'dispatched', + attempt_count: 0, + }); + }); + }); + + it('keeps distinct request ids as separate rows', async () => { + const sessionId = trackSession('ses_attention_distinct'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_a', + intent: { kind: 'raise', reason: 'question' }, + }); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_b', + intent: { kind: 'raise', reason: 'permission' }, + }); + await runAlarm(instance); + const rows = readOutboxRows(state); + expect(rows).toHaveLength(2); + expect(rows.map(r => r.request_id)).toEqual(['req_a', 'req_b']); + expect(rows.every(r => r.status === 'dispatched')).toBe(true); + }); + }); + + it('resolves a pending request before dispatch and no-ops on re-raise', async () => { + const sessionId = trackSession('ses_attention_resolve_first'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolved_first', + intent: { kind: 'raise', reason: 'question' }, + }); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolved_first', + intent: { kind: 'resolve', reason: 'question' }, + }); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolved_first', + intent: { kind: 'raise', reason: 'question' }, + }); + await runAlarm(instance); + const rows = readOutboxRows(state); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + request_id: 'req_resolved_first', + status: 'resolved', + resolved_at: expect.any(Number), + }); + expect(calls).toHaveLength(0); + }); + }); + + // Kilobot finding 4: an unknown resolve arriving before a raise must + // persist a terminal resolved tombstone so a later raise cannot notify. + // The original resolve reason is preserved on the tombstone; subsequent + // resolves (even with a different reason) and the late raise are all + // no-ops, and the dispatch loop never sees the row. + it.each(['question', 'permission', 'blocking_suggestion'] as const)( + 'unknown resolve for reason %s persists a tombstone and a later raise is a no-op', + async reason => { + const sessionId = trackSession(`ses_attention_unknown_resolve_${reason}`); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + // 1) Unknown resolve arrives first — no matching raise yet. + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_unknown_first', + intent: { kind: 'resolve', reason }, + }); + const rowsAfterResolve = readOutboxRows(state); + expect(rowsAfterResolve).toHaveLength(1); + expect(rowsAfterResolve[0]).toMatchObject({ + request_id: 'req_unknown_first', + reason, + status: 'resolved', + attempt_count: 0, + next_attempt_at: null, + last_error: null, + }); + // raisedAt and resolvedAt are both the unknown-resolve timestamp + // so the tombstone is unambiguously a "we saw a resolve but never + // a raise" row. + expect(rowsAfterResolve[0]?.raised_at).toBe(rowsAfterResolve[0]?.resolved_at); + + // 2) Late raise arrives — must NOT enqueue a notification. + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_unknown_first', + intent: { kind: 'raise', reason }, + }); + await runAlarm(instance); + + const rowsAfterLateRaise = readOutboxRows(state); + expect(rowsAfterLateRaise).toHaveLength(1); + expect(rowsAfterLateRaise[0]).toMatchObject({ + request_id: 'req_unknown_first', + reason, + status: 'resolved', + attempt_count: 0, + next_attempt_at: null, + }); + // The tombstone's reason is preserved (the late raise's reason + // does not overwrite it) and no notification was ever dispatched. + expect(calls).toHaveLength(0); + }); + } + ); + + it('repeat unknown resolve is stable and does not change the tombstone reason or status', async () => { + const sessionId = trackSession('ses_attention_unknown_resolve_repeat'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_repeat_resolve', + intent: { kind: 'resolve', reason: 'question' }, + }); + // Repeat unknown resolve with a different reason — the original + // reason must be preserved, and the row must not flap status. + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_repeat_resolve', + intent: { kind: 'resolve', reason: 'permission' }, + }); + await runAlarm(instance); + + const rows = readOutboxRows(state); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + request_id: 'req_repeat_resolve', + reason: 'question', + status: 'resolved', + attempt_count: 0, + next_attempt_at: null, + last_error: null, + }); + expect(calls).toHaveLength(0); + }); + }); + + it('unknown resolve does not schedule a fresh outbox alarm of its own', async () => { + const sessionId = trackSession('ses_attention_unknown_resolve_no_alarm'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + // No prior outbox work exists, no metrics deadline. + expect(await state.storage.getAlarm()).toBeNull(); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_unknown_no_alarm', + intent: { kind: 'resolve', reason: 'question' }, + }); + // The tombstone carries no scheduled attempt and no raise, so the + // shared alarm scheduler must NOT reschedule on its own. + expect(await state.storage.getAlarm()).toBeNull(); + }); + }); + + it('returns accepted:false for invalid input or a deleted session', async () => { + const sessionId = trackSession('ses_attention_invalid_deleted'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async instance => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await expect( + instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_1', + intent: { kind: 'raise', reason: 'unknown_reason' }, + } as unknown as Record) + ).resolves.toEqual({ accepted: false, reason: 'invalid_input' }); + }); + + await stub.clear(); + + await runInDurableObject(stub, async instance => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await expect( + instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_1', + intent: { kind: 'raise', reason: 'question' }, + }) + ).resolves.toEqual({ accepted: false, reason: 'deleted' }); + }); + }); + + it('schedules an immediate alarm for a raise while preserving a later metrics deadline', async () => { + const sessionId = trackSession('ses_attention_alarm_preempt'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.ingest( + [{ type: 'session_close', data: { reason: 'completed' } }], + kiloUserId, + sessionId, + 1 + ); + + const metricsAlarmBefore = Number(readMeta(state, 'metricsAlarmAt') ?? '0'); + expect(metricsAlarmBefore).toBeGreaterThan(0); + expect(Number.isFinite(metricsAlarmBefore)).toBe(true); + + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_preempt', + intent: { kind: 'raise', reason: 'question' }, + }); + + const alarmTime = await state.storage.getAlarm(); + expect(alarmTime).not.toBeNull(); + expect(Number.isFinite(alarmTime)).toBe(true); + expect(alarmTime).toBeLessThan(metricsAlarmBefore); + expect(readMeta(state, 'metricsAlarmAt')).toBe(String(metricsAlarmBefore)); + await state.storage.deleteAlarm(); + }); + }); + + it('lets the earlier metrics deadline win when no outbox work is due sooner', async () => { + const sessionId = trackSession('ses_attention_metrics_wins'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.ingest( + [{ type: 'session_close', data: { reason: 'completed' } }], + kiloUserId, + sessionId, + 1 + ); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_metrics_later', + intent: { kind: 'raise', reason: 'question' }, + }); + + const metricsAlarm = Number(readMeta(state, 'metricsAlarmAt') ?? '0'); + expect(metricsAlarm).toBeGreaterThan(0); + expect(Number.isFinite(metricsAlarm)).toBe(true); + + let alarmTime = await state.storage.getAlarm(); + expect(alarmTime).not.toBeNull(); + expect(Number.isFinite(alarmTime)).toBe(true); + expect(alarmTime).toBeLessThan(metricsAlarm); + + // Manually advance the outbox next_attempt_at to after the metrics alarm + // so the metrics deadline is the next candidate. + state.storage.sql.exec( + 'UPDATE attention_outbox SET next_attempt_at = ? WHERE request_id = ?', + metricsAlarm + 10_000, + 'req_metrics_later' + ); + // Trigger a reschedule without changing the outbox state by resolving a + // request id that was never raised. The tombstone carries no + // scheduled attempt, so the alarm should move to the earlier + // metrics deadline because the outbox is now due later. + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_nonexistent_for_reschedule', + intent: { kind: 'resolve', reason: 'question' }, + }); + alarmTime = await state.storage.getAlarm(); + expect(alarmTime).toBe(metricsAlarm); + await state.storage.deleteAlarm(); + }); + }); + + it('alarm sends only userId, cliSessionId, requestId, and reason to notifications', async () => { + const sessionId = trackSession('ses_attention_payload_shape'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_payload_shape', + intent: { kind: 'raise', reason: 'blocking_suggestion' }, + }); + await runAlarm(instance); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + userId: kiloUserId, + cliSessionId: sessionId, + requestId: 'req_payload_shape', + reason: 'blocking_suggestion', + }); + }); + }); + + it('transitions dispatched, suppressed_presence, and missing_session to terminal', async () => { + const sessionId = trackSession('ses_attention_terminal'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_dispatched', + intent: { kind: 'raise', reason: 'question' }, + }); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_suppressed', + intent: { kind: 'raise', reason: 'permission' }, + }); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_missing', + intent: { kind: 'raise', reason: 'blocking_suggestion' }, + }); + + const resultsByRequestId: Record = { + req_dispatched: { dispatched: true }, + req_suppressed: { dispatched: false, reason: 'suppressed_presence' }, + req_missing: { dispatched: false, reason: 'missing_session' }, + }; + const calls: SendSessionAttentionNotificationParams[] = []; + installMocks( + instance, + makeMockNotifications( + calls, + async params => resultsByRequestId[params.requestId] ?? { dispatched: true } + ), + makeMockO11Y() + ); + await runAlarm(instance); + const rows = readOutboxRows(state); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ request_id: 'req_dispatched', status: 'dispatched' }), + expect.objectContaining({ + request_id: 'req_suppressed', + status: 'suppressed_presence', + }), + expect.objectContaining({ request_id: 'req_missing', status: 'missing_session' }), + ]) + ); + }); + }); + + it.each(['resolved', 'dispatched', 'suppressed_presence', 'missing_session', 'failed'])( + 'markRetry is a no-op for terminal status %s', + async status => { + const sessionId = trackSession(`ses_attention_mark_retry_${status}`); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + const db = getStoreDb(instance); + + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_terminal', + intent: { kind: 'raise', reason: 'question' }, + }); + + // Force the row into a terminal status so we can verify retries cannot + // resurrect finished work. + state.storage.sql.exec( + 'UPDATE attention_outbox SET status = ?, attempt_count = ? WHERE request_id = ?', + status, + 3, + 'req_terminal' + ); + + const before = readOutboxRows(state)[0]; + expect(before?.status).toBe(status); + expect(before?.attempt_count).toBe(3); + + markRetry(db, { + requestId: 'req_terminal', + now: Date.now(), + reason: 'should not apply', + }); + + const after = readOutboxRows(state)[0]; + expect(after).toMatchObject({ + request_id: 'req_terminal', + status, + attempt_count: 3, + last_error: before?.last_error ?? null, + }); + }); + } + ); + + it('retries dispatch_failed with bounded backoff and parks at failed after max attempts', async () => { + // Each iteration advances just past the next backoff. The full + // 6-alarm sequence completes in well under the 55min absolute + // dispatch window so the cap-by-attempt-count is the trigger + // (not the window). + const sessionId = trackSession('ses_attention_retry_cap'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks( + instance, + makeMockNotifications(calls, async () => ({ + dispatched: false, + reason: 'dispatch_failed', + })), + makeMockO11Y() + ); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_retry_cap', + intent: { kind: 'raise', reason: 'question' }, + }); + // 6min per alarm × 10 iterations = 60min total. The first 5 + // dispatches happen inside the 55min window; the 6th retry's + // 60min backoff crosses the absolute deadline, so markRetry + // parks the row as failed without scheduling a 7th dispatch. + for (let i = 0; i < MAX_ATTEMPTS + 3; i++) { + vi.advanceTimersByTime(6 * 60 * 1000); + await runAlarm(instance); + } + + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_retry_cap', + status: 'failed', + next_attempt_at: null, + last_error: 'dispatch_failed', + }); + // 6 dispatches total — 7th never happens because the window + // parks the row first. + expect(calls).toHaveLength(6); + expect(row?.attempt_count).toBe(6); + }); + }); + + it('retries a thrown RPC error with bounded backoff and parks at failed after max attempts', async () => { + const sessionId = trackSession('ses_attention_rpc_error'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks( + instance, + { + sendSessionAttentionNotification: vi.fn(async () => { + throw new Error('this sensitive upstream message must not be stored'); + }), + sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })), + }, + makeMockO11Y() + ); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_rpc_error', + intent: { kind: 'raise', reason: 'permission' }, + }); + for (let i = 0; i < MAX_ATTEMPTS + 3; i++) { + vi.advanceTimersByTime(6 * 60 * 1000); + await runAlarm(instance); + } + + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_rpc_error', + status: 'failed', + next_attempt_at: null, + last_error: 'rpc_error', + }); + expect(row?.attempt_count).toBe(6); + }); + }); + + it('recovers stale in_flight rows and retries them safely', async () => { + const sessionId = trackSession('ses_attention_stale_recovery'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_stale', + intent: { kind: 'raise', reason: 'question' }, + }); + // Simulate a crash: mark the row in_flight without dispatching. + state.storage.sql.exec( + "UPDATE attention_outbox SET status = 'in_flight' WHERE request_id = 'req_stale'" + ); + expect(readOutboxRows(state)[0]?.status).toBe('in_flight'); + + // First alarm recovers the stale row to pending with a backoff. + await runAlarm(instance); + const rowAfterRecovery = readOutboxRows(state)[0]; + expect(rowAfterRecovery).toMatchObject({ + request_id: 'req_stale', + status: 'pending', + attempt_count: 1, + }); + expect(calls).toHaveLength(0); + + // Advance past the recovery backoff and retry. + vi.advanceTimersByTime(10_000); + await runAlarm(instance); + + expect(calls).toHaveLength(1); + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_stale', + status: 'dispatched', + attempt_count: 1, + }); + }); + }); + + it('resolve during awaited RPC wins and is not overwritten', async () => { + const sessionId = trackSession('ses_attention_resolve_race'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const deferred = createDeferred(); + const mock: MockNotifications = { + sendSessionAttentionNotification: vi.fn(async () => deferred.promise), + sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })), + }; + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, mock, makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolve_race', + intent: { kind: 'raise', reason: 'question' }, + }); + + const alarmPromise = runAlarm(instance); + // The alarm has claimed the row and is awaiting the RPC promise. + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolve_race', + intent: { kind: 'resolve', reason: 'question' }, + }); + + expect(readOutboxRows(state)[0]?.status).toBe('resolved'); + + deferred.resolve({ dispatched: true }); + await alarmPromise; + + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_resolve_race', + status: 'resolved', + }); + expect(mock.sendSessionAttentionNotification).toHaveBeenCalledTimes(1); + }); + }); + + it('resolve during a thrown RPC is not overwritten by retry', async () => { + const sessionId = trackSession('ses_attention_resolve_race_error'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const deferred = createDeferred(); + const mock: MockNotifications = { + sendSessionAttentionNotification: vi.fn(async () => deferred.promise), + sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })), + }; + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, mock, makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolve_race_error', + intent: { kind: 'raise', reason: 'question' }, + }); + + const alarmPromise = runAlarm(instance); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_resolve_race_error', + intent: { kind: 'resolve', reason: 'question' }, + }); + + deferred.reject(new Error('sensitive upstream message')); + await alarmPromise; + + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_resolve_race_error', + status: 'resolved', + last_error: null, + }); + expect(mock.sendSessionAttentionNotification).toHaveBeenCalledTimes(1); + }); + }); + + it('does not emit metrics from immediate attention alarm when the deadline is in the future', async () => { + // Regression for Kilobot finding 3: the immediate-attention outbox alarm + // used to call `emitSessionMetrics` unconditionally, so a raise fired + // long before the actual metrics deadline would prematurely publish + // session metrics. The alarm must only emit when the persisted + // `metricsAlarmAt` is finite, positive, and <= Date.now(). + const sessionId = trackSession('ses_attention_metrics_future_deadline'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + const o11y = makeMockO11Y(); + installMocks(instance, makeMockNotifications(calls), o11y); + + // Close sets `metricsAlarmAt` to now + POST_CLOSE_DRAIN_MS (5s). + await instance.ingest( + [{ type: 'session_close', data: { reason: 'completed' } }], + kiloUserId, + sessionId, + 1 + ); + + const metricsAlarmBefore = Number(readMeta(state, 'metricsAlarmAt') ?? '0'); + expect(metricsAlarmBefore).toBeGreaterThan(Date.now()); + expect(Number.isFinite(metricsAlarmBefore)).toBe(true); + + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_future_deadline', + intent: { kind: 'raise', reason: 'question' }, + }); + + await runAlarm(instance); + + // Notification dispatched, metrics NOT emitted. + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ requestId: 'req_future_deadline' }); + expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled(); + expect(readMeta(state, 'metricsEmitted')).not.toBe('true'); + // The persisted deadline must be untouched by the immediate alarm. + expect(readMeta(state, 'metricsAlarmAt')).toBe(String(metricsAlarmBefore)); + // The shared alarm is rescheduled to the metrics deadline (no more + // outbox work, future deadline still pending). + const nextAlarm = await state.storage.getAlarm(); + expect(nextAlarm).toBe(metricsAlarmBefore); + await state.storage.deleteAlarm(); + }); + }); + + it('emits metrics from the attention alarm once the deadline arrives', async () => { + const sessionId = trackSession('ses_attention_metrics_at_deadline'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + const o11y = makeMockO11Y(); + installMocks(instance, makeMockNotifications(calls), o11y); + + await instance.ingest( + [{ type: 'session_close', data: { reason: 'completed' } }], + kiloUserId, + sessionId, + 1 + ); + + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_at_deadline', + intent: { kind: 'raise', reason: 'question' }, + }); + + // First alarm at the immediate tick: outbox dispatched, metrics not yet. + await runAlarm(instance); + expect(calls).toHaveLength(1); + expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled(); + + // Advance past the metrics deadline (POST_CLOSE_DRAIN_MS = 5s). + vi.advanceTimersByTime(6_000); + await runAlarm(instance); + + expect(o11y.ingestSessionMetrics).toHaveBeenCalledTimes(1); + expect(readMeta(state, 'metricsEmitted')).toBe('true'); + expect(readMeta(state, 'metricsAlarmAt')).toBeNull(); + await state.storage.deleteAlarm(); + }); + }); + + it('skips metrics when the alarm fires without a persisted deadline (legacy compatibility)', async () => { + // Legacy platform alarms may reach `alarm()` without `metricsAlarmAt` + // ever being written. The fix must not crash, must not emit, and must + // still reschedule the next outbox tick. + const sessionId = trackSession('ses_attention_metrics_legacy_no_deadline'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + const o11y = makeMockO11Y(); + installMocks(instance, makeMockNotifications(calls), o11y); + + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_legacy_no_deadline', + intent: { kind: 'raise', reason: 'question' }, + }); + + // Defensive: ensure no deadline is persisted (none is set by the + // raise path on its own). + expect(readMeta(state, 'metricsAlarmAt')).toBeNull(); + + await runAlarm(instance); + + expect(calls).toHaveLength(1); + expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled(); + expect(readMeta(state, 'metricsEmitted')).not.toBe('true'); + // No more outbox work, so the next alarm is cleared. + const nextAlarm = await state.storage.getAlarm(); + expect(nextAlarm).toBeNull(); + }); + }); + + it('re-reads the metrics deadline after dispatchOutboxBatch (reread on IO await)', async () => { + // Race regression: a prior code path read metricsEmitted/metricsAlarmAt + // once at the top of `alarm()` and then emitted metrics even if the + // deadline had been deleted or pushed during the awaited + // `sendSessionAttentionNotification` call. The fix re-reads after the + // dispatch loop returns so a concurrent update wins. + const sessionId = trackSession('ses_attention_metrics_reread'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const deferred = createDeferred(); + const mock: MockNotifications = { + sendSessionAttentionNotification: vi.fn(async () => deferred.promise), + sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })), + }; + const o11y = makeMockO11Y(); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, mock, o11y); + + // Pre-set a far-future deadline so the OLD code would still try to + // emit (because it had read `metricsEmitted` once at the top of + // alarm() and then ignored the deadline entirely). With the fix, the + // alarm re-reads after the await and sees the future deadline. + await instance.ingest( + [{ type: 'session_close', data: { reason: 'completed' } }], + kiloUserId, + sessionId, + 1 + ); + const initialDeadline = Number(readMeta(state, 'metricsAlarmAt') ?? '0'); + expect(initialDeadline).toBeGreaterThan(Date.now()); + + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_reread', + intent: { kind: 'raise', reason: 'question' }, + }); + + const alarmPromise = runAlarm(instance); + + // While the notification RPC is awaited, simulate a concurrent update + // that pushes the deadline further out (e.g. a new session_open + // resetting the inactivity timer). + const newDeadline = Date.now() + 10 * 60 * 1000; + state.storage.sql.exec( + "UPDATE ingest_meta SET value = ? WHERE key = 'metricsAlarmAt'", + String(newDeadline) + ); + + deferred.resolve({ dispatched: true }); + await alarmPromise; + + expect(mock.sendSessionAttentionNotification).toHaveBeenCalledTimes(1); + // The fix re-reads the deadline after the await; the push must not + // emit metrics, and the deadline must be the concurrent value. + expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled(); + expect(readMeta(state, 'metricsEmitted')).not.toBe('true'); + expect(Number(readMeta(state, 'metricsAlarmAt'))).toBe(newDeadline); + await state.storage.deleteAlarm(); + }); + }); + + it('keeps the outbox retry alarm after metrics emission', async () => { + const sessionId = trackSession('ses_attention_metrics_outbox_alarm'); + const stub = getStub(kiloUserId, sessionId); + + await seedSession(stub, sessionId); + + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications([]), makeMockO11Y()); + await instance.ingest( + [ + { type: 'session', data: { title: 'Outbox Alarm After Metrics' } }, + { type: 'session_close', data: { reason: 'completed' } }, + ], + kiloUserId, + sessionId, + 1 + ); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_outbox_after_metrics', + intent: { kind: 'raise', reason: 'question' }, + }); + + // First alarm: dispatches the outbox. With the fix, it does NOT emit + // metrics because the deadline (POST_CLOSE_DRAIN_MS in the future) + // hasn't arrived yet. + await runAlarm(instance); + expect(readMeta(state, 'metricsEmitted')).not.toBe('true'); + // The deadline is preserved and the alarm is rescheduled to it. + const pendingDeadline = Number(readMeta(state, 'metricsAlarmAt') ?? '0'); + expect(pendingDeadline).toBeGreaterThan(0); + const pendingAlarm = await state.storage.getAlarm(); + expect(pendingAlarm).toBe(pendingDeadline); + + // Advance past the metrics deadline and run again: metrics emit. + vi.advanceTimersByTime(6_000); + await runAlarm(instance); + expect(readMeta(state, 'metricsEmitted')).toBe('true'); + expect(readMeta(state, 'metricsAlarmAt')).toBeNull(); + + // No outbox work remains, so the alarm is cleared. + let alarmTime = await state.storage.getAlarm(); + expect(alarmTime).toBeNull(); + + // A new raise after metrics must reschedule the alarm. + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_after_metrics', + intent: { kind: 'raise', reason: 'permission' }, + }); + + alarmTime = await state.storage.getAlarm(); + expect(alarmTime).not.toBeNull(); + expect(Number.isFinite(alarmTime)).toBe(true); + await state.storage.deleteAlarm(); + }); + }); + + // Kilobot finding 5: the absolute dispatch window is bounded at + // ATTENTION_MAX_DISPATCH_AGE_MS (55min) from `raisedAt` so the row can + // never cross the receiver's 60min idempotency TTL. + describe('absolute dispatch window (55min from raisedAt)', () => { + it('parks a pending row at/over the deadline before claim returns it', async () => { + const sessionId = trackSession('ses_attention_claim_park_expired'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_expired_pending', + intent: { kind: 'raise', reason: 'question' }, + }); + // Move the row's `raisedAt` and `next_attempt_at` well past the + // deadline so the next claim is over the window. The claim + // must park the row as terminal failed and never call the RPC. + const pastDeadline = Date.now() + 60 * 60 * 1000; // +60min + state.storage.sql.exec( + 'UPDATE attention_outbox SET raised_at = ?, next_attempt_at = ? WHERE request_id = ?', + Date.now() - ATTENTION_MAX_DISPATCH_AGE_MS, // 55min ago exactly → at deadline + pastDeadline, + 'req_expired_pending' + ); + + await runAlarm(instance); + + const row = readOutboxRows(state)[0]; + expect(calls).toHaveLength(0); + expect(row).toMatchObject({ + request_id: 'req_expired_pending', + status: 'failed', + next_attempt_at: null, + last_error: 'retry_window_expired', + }); + }); + }); + + it('parks a pending row when `now` crosses the deadline even if its next_attempt_at is in the past', async () => { + // A row whose `next_attempt_at` is in the past but whose + // `raisedAt` is older than ATTENTION_MAX_DISPATCH_AGE_MS must + // still be parked, not dispatched. + const sessionId = trackSession('ses_attention_claim_now_over_deadline'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_now_past_deadline', + intent: { kind: 'raise', reason: 'question' }, + }); + // Force raisedAt deep in the past; keep next_attempt_at < now + // (so it looks "due") but the deadline has long since passed. + const longAgo = Date.now() - 24 * 60 * 60 * 1000; // 24h ago + state.storage.sql.exec( + 'UPDATE attention_outbox SET raised_at = ?, next_attempt_at = ? WHERE request_id = ?', + longAgo, + Date.now() - 1, + 'req_now_past_deadline' + ); + + await runAlarm(instance); + + const row = readOutboxRows(state)[0]; + expect(calls).toHaveLength(0); + expect(row).toMatchObject({ + request_id: 'req_now_past_deadline', + status: 'failed', + next_attempt_at: null, + last_error: 'retry_window_expired', + }); + }); + }); + + it('recovers a stale in_flight row that has crossed the deadline as terminal failed', async () => { + // Stale recovery must not reschedule a row past the absolute + // deadline, even if the cap-by-attempt-count would have allowed + // more attempts. + const sessionId = trackSession('ses_attention_recover_expired_in_flight'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_stale_expired', + intent: { kind: 'raise', reason: 'question' }, + }); + // Simulate a crash mid-dispatch with a row well past the window. + state.storage.sql.exec( + "UPDATE attention_outbox SET status = 'in_flight', raised_at = ?, attempt_count = 1 WHERE request_id = 'req_stale_expired'", + Date.now() - 2 * 60 * 60 * 1000 // 2h ago + ); + + await runAlarm(instance); + + const row = readOutboxRows(state)[0]; + expect(calls).toHaveLength(0); + expect(row).toMatchObject({ + request_id: 'req_stale_expired', + status: 'failed', + next_attempt_at: null, + last_error: 'stale_in_flight_recovered', + }); + }); + }); + + it('a retry sequence past the deadline never invokes the RPC and parks the row', async () => { + // The full integration: the alarm loop runs back-to-back retries + // until either the cap or the deadline trips. With raises that + // happened just inside the window, retries must still fall + // within the window until the cap or the absolute deadline + // parks the row — the RPC must never be invoked at/after the + // deadline. + const sessionId = trackSession('ses_attention_retry_window'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + const raisedAt = Date.now(); + await runInDurableObject(stub, async (instance, state) => { + installMocks( + instance, + makeMockNotifications(calls, async () => ({ + dispatched: false, + reason: 'dispatch_failed', + })), + makeMockO11Y() + ); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_window', + intent: { kind: 'raise', reason: 'question' }, + }); + + // Run a few retries strictly inside the window. Each advance + // is small enough to keep us well under the 55min cap. + for (let i = 0; i < 4; i++) { + vi.advanceTimersByTime(20_000); + await runAlarm(instance); + } + const beforeDeadline = readOutboxRows(state)[0]; + expect(beforeDeadline?.status).toBe('pending'); + expect(calls.length).toBeGreaterThan(0); + + // Jump past the deadline. The next alarm must NOT call the RPC + // and must park the row as failed. + vi.advanceTimersByTime(60 * 60 * 1000); // +60min + const callsBefore = calls.length; + await runAlarm(instance); + const callsAfter = calls.length; + expect(callsAfter).toBe(callsBefore); + + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_window', + status: 'failed', + next_attempt_at: null, + }); + expect(row?.last_error).toBe('retry_window_expired'); + // The deadline is the original raisedAt + 55min, not + // 'dispatch_failed' (which is the per-retry reason). + expect(dispatchDeadline(raisedAt)).toBe(raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS); + }); + }); + + it('a delayed alarm past the deadline fails the row without dispatch', async () => { + // A row was raised, the alarm was lost, and the platform re-fires + // it well past the deadline. The first claim must park the row + // and never call the RPC. + const sessionId = trackSession('ses_attention_delayed_past_deadline'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_delayed', + intent: { kind: 'raise', reason: 'question' }, + }); + // Simulate a long outage: rewind `raised_at` so the row is past + // its window when the alarm next fires. + state.storage.sql.exec( + 'UPDATE attention_outbox SET raised_at = ? WHERE request_id = ?', + Date.now() - 90 * 60 * 1000, // 90min ago + 'req_delayed' + ); + + await runAlarm(instance); + + const row = readOutboxRows(state)[0]; + expect(calls).toHaveLength(0); + expect(row).toMatchObject({ + request_id: 'req_delayed', + status: 'failed', + next_attempt_at: null, + last_error: 'retry_window_expired', + }); + }); + }); + + it('does not reschedule an alarm that only has expired pending work', async () => { + // After the alarm body has parked all expired pending rows, the + // shared scheduler must not keep an alarm alive pointing at a + // past-deadline time. The next alarm is cleared. + const sessionId = trackSession('ses_attention_reschedule_no_expired'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_expired_alone', + intent: { kind: 'raise', reason: 'question' }, + }); + state.storage.sql.exec( + 'UPDATE attention_outbox SET raised_at = ?, next_attempt_at = ? WHERE request_id = ?', + Date.now() - 60 * 60 * 1000, + Date.now() - 60 * 60 * 1000, + 'req_expired_alone' + ); + + await runAlarm(instance); + + const nextAlarm = await state.storage.getAlarm(); + expect(nextAlarm).toBeNull(); + const row = readOutboxRows(state)[0]; + expect(row?.status).toBe('failed'); + }); + }); + + it('initial raise immediate is unaffected: raisedAt === nextAttemptAt is well before the deadline', async () => { + // A fresh raise sets raisedAt = nextAttemptAt = now. The very + // first claim must dispatch immediately without hitting the + // boundary check. + const sessionId = trackSession('ses_attention_initial_immediate'); + const stub = getStub(kiloUserId, sessionId); + await seedSession(stub, sessionId); + + const calls: SendSessionAttentionNotificationParams[] = []; + await runInDurableObject(stub, async (instance, state) => { + installMocks(instance, makeMockNotifications(calls), makeMockO11Y()); + await instance.recordAttentionEvent({ + kiloUserId, + sessionId, + requestId: 'req_immediate', + intent: { kind: 'raise', reason: 'question' }, + }); + await runAlarm(instance); + expect(calls).toHaveLength(1); + const row = readOutboxRows(state)[0]; + expect(row).toMatchObject({ + request_id: 'req_immediate', + status: 'dispatched', + }); + }); + }); + }); +});