From ca81a2370243a4e4ede2b61971a2f7a227bbcd3d Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 7 Jul 2026 22:24:57 +0200 Subject: [PATCH 1/3] feat(session-ingest): push notifications for remote session attention signals Detect completed assistant turns and needs-input status transitions during ingest and dispatch a mobile push to the session owner for remote (vscode, agent-manager) root sessions. Pushes are suppressed when the user already has the session open in the web app (UserConnectionDO.hasSessionSubscribers). - SessionIngestDO emits AttentionSignal(s) on idle/question/permission transitions, pairing completed turns with the just-finished assistant message excerpt. First-ever status writes are skipped to avoid pushing about an old turn on full-history backfill. - queue-consumer collects signals across chunks and dispatches them via a NOTIFICATIONS service binding; partial flush on failure preserves signals from already-committed chunks. - Adds @kilocode/notifications dependency and wrangler NOTIFICATIONS binding. --- .../cloud-agent-next/CloudChatPage.tsx | 3 + apps/web/src/hooks/useCliSessionPresence.ts | 14 ++ dev/local/env-sync/parse.ts | 5 +- dev/local/services.ts | 2 +- packages/event-service/package.json | 1 + .../src/__tests__/presence.test.ts | 9 + packages/event-service/src/client.ts | 5 + packages/event-service/src/presence.ts | 3 + packages/notifications/src/rpc-schemas.ts | 1 + .../session/message-settlement-outbox.test.ts | 2 + .../src/session/message-settlement-outbox.ts | 1 + .../event-service/src/do/user-session-do.ts | 124 ++++++---- services/event-service/src/index.ts | 53 +++- services/event-service/src/util/logger.ts | 17 +- .../src/dos/NotificationChannelDO.ts | 4 + .../src/lib/cloud-agent-session-push.ts | 17 +- .../notifications-service-cloud-agent.test.ts | 71 +++++- .../session-ingest/src/dos/SessionIngestDO.ts | 103 +++++++- .../src/dos/UserConnectionDO.test.ts | 37 ++- .../src/dos/UserConnectionDO.ts | 5 + .../src/dos/session-ingest-attention.test.ts | 136 +++++++++++ .../src/dos/session-ingest-attention.ts | 79 ++++++ services/session-ingest/src/env.ts | 2 +- .../src/notifications-binding.ts | 5 + .../session-ingest/src/queue-consumer.test.ts | 167 +++++++++++++ services/session-ingest/src/queue-consumer.ts | 139 ++++++++++- .../src/remote-session-notifications.test.ts | 72 ++++++ .../src/remote-session-notifications.ts | 62 +++++ .../integration/session-ingest-do.test.ts | 230 ++++++++++++++++++ .../session-ingest/worker-configuration.d.ts | 2 +- 30 files changed, 1283 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/hooks/useCliSessionPresence.ts create mode 100644 packages/event-service/src/__tests__/presence.test.ts create mode 100644 services/session-ingest/src/dos/session-ingest-attention.test.ts create mode 100644 services/session-ingest/src/dos/session-ingest-attention.ts create mode 100644 services/session-ingest/src/remote-session-notifications.test.ts create mode 100644 services/session-ingest/src/remote-session-notifications.ts diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 27416a0920..b71cdc9433 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 { useCliSessionPresence } from '@/hooks/useCliSessionPresence'; import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import { SetPageTitle } from '@/components/SetPageTitle'; @@ -231,6 +232,8 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const observedModel = useAtomValue(manager.atoms.observedModel); const remoteModelOverride = useAtomValue(manager.atoms.remoteModelOverride); + useCliSessionPresence(fetchedSessionData?.kiloSessionId ?? null); + const setSessionConfig = useSetAtom(manager.atoms.sessionConfig); const [attachmentMessageUuid] = useState(() => uuidv4()); diff --git a/apps/web/src/hooks/useCliSessionPresence.ts b/apps/web/src/hooks/useCliSessionPresence.ts new file mode 100644 index 0000000000..a83902dd17 --- /dev/null +++ b/apps/web/src/hooks/useCliSessionPresence.ts @@ -0,0 +1,14 @@ +'use client'; + +import { presenceContextForCliSession } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; + +import { useDocumentVisible } from './useDocumentVisible'; + +export function useCliSessionPresence(sessionId: string | null, enabled = true) { + const visible = useDocumentVisible(); + usePresenceSubscription( + sessionId ? presenceContextForCliSession(sessionId) : null, + Boolean(sessionId) && enabled && visible + ); +} diff --git a/dev/local/env-sync/parse.ts b/dev/local/env-sync/parse.ts index a7448c92fe..687810ce85 100644 --- a/dev/local/env-sync/parse.ts +++ b/dev/local/env-sync/parse.ts @@ -228,7 +228,10 @@ function resolveAnnotatedValue( case 'url': { const isOrigins = key.includes('ORIGINS'); const isHostname = key.includes('HOSTNAME') && !key.includes('URL'); - const isWs = key.includes('_WS_'); + const isWs = + key.includes('_WS_') || + entry.defaultValue.startsWith('ws://') || + entry.defaultValue.startsWith('wss://'); const defaultUsesDockerHost = entry.defaultValue.includes('host.docker.internal'); const defaultUsesWorkerLocalhost = WORKER_LOCALHOST_URL_KEYS.has(key) && diff --git a/dev/local/services.ts b/dev/local/services.ts index 2e4aed0e88..1c5cf8563e 100644 --- a/dev/local/services.ts +++ b/dev/local/services.ts @@ -213,7 +213,7 @@ const serviceMeta: Record = { dir: 'services/kiloclaw-billing', }, 'event-service': { - group: 'kiloclaw', + group: 'cloud-agent', dependsOn: [], dir: 'services/event-service', }, diff --git a/packages/event-service/package.json b/packages/event-service/package.json index d14dc34908..cd0cbe3716 100644 --- a/packages/event-service/package.json +++ b/packages/event-service/package.json @@ -7,6 +7,7 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts", + "./presence": "./src/presence.ts", "./types": "./src/types.ts" }, "scripts": { 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..fccbd869c4 --- /dev/null +++ b/packages/event-service/src/__tests__/presence.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +import { presenceContextForCliSession } from '../presence'; + +describe('presence context builders', () => { + it('builds CLI session presence contexts', () => { + expect(presenceContextForCliSession('ses_1')).toBe('/presence/cli-session/ses_1'); + }); +}); diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 44e42a29eb..1423034464 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -42,6 +42,11 @@ function ticketEndpointFor(wsBase: string): string { function connectUrlFor(wsBase: string, ticket: string): string { const url = baseUrlWithPath(wsBase, 'connect'); + if (url.protocol === 'http:') { + url.protocol = 'ws:'; + } else if (url.protocol === 'https:') { + url.protocol = 'wss:'; + } const query = { ticket } satisfies ConnectTicketQuery; url.searchParams.set('ticket', query.ticket); return url.toString(); diff --git a/packages/event-service/src/presence.ts b/packages/event-service/src/presence.ts index a267aa667c..efe042df9d 100644 --- a/packages/event-service/src/presence.ts +++ b/packages/event-service/src/presence.ts @@ -19,3 +19,6 @@ export const presenceContextForInstance = (sandboxId: string) => export const presenceContextForConversation = (sandboxId: string, conversationId: string) => `/presence${kiloclawConversationContext(sandboxId, conversationId)}` as const; + +export const presenceContextForCliSession = (sessionId: string) => + `/presence/cli-session/${sessionId}` as const; diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index ae06ef1008..5b5cdfc97a 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -143,6 +143,7 @@ export const sendCloudAgentSessionNotificationInputSchema = z.object({ executionId: z.string().min(1), status: cloudAgentSessionPushStatusSchema, body: z.string(), + suppressIfViewingSession: z.boolean().optional(), }); export type SendCloudAgentSessionNotificationParams = z.infer< typeof sendCloudAgentSessionNotificationInputSchema diff --git a/services/cloud-agent-next/src/session/message-settlement-outbox.test.ts b/services/cloud-agent-next/src/session/message-settlement-outbox.test.ts index 5c8efc346d..b8ddb78e49 100644 --- a/services/cloud-agent-next/src/session/message-settlement-outbox.test.ts +++ b/services/cloud-agent-next/src/session/message-settlement-outbox.test.ts @@ -254,6 +254,7 @@ describe('MessageSettlementOutbox', () => { executionId: firstMessageId, status: 'completed', body: 'Done now', + suppressIfViewingSession: true, }, ]); const persisted = await getSessionMessageState(harness.storage, firstMessageId); @@ -483,6 +484,7 @@ describe('MessageSettlementOutbox', () => { executionId: firstMessageId, status: 'failed', body: 'Failed: Assistant request timed out', + suppressIfViewingSession: true, }, ]); expect(JSON.stringify(harness.pushJobs)).not.toContain('push-secret'); diff --git a/services/cloud-agent-next/src/session/message-settlement-outbox.ts b/services/cloud-agent-next/src/session/message-settlement-outbox.ts index 97914510a6..d6574c7eec 100644 --- a/services/cloud-agent-next/src/session/message-settlement-outbox.ts +++ b/services/cloud-agent-next/src/session/message-settlement-outbox.ts @@ -579,6 +579,7 @@ export function createMessageSettlementOutbox( executionId: state.messageId, status: state.status, body: buildCloudAgentPushBody(state.status, lastAssistantMessageText, failureMessage), + suppressIfViewingSession: true, }); if (result.dispatched) { return 'accounted'; diff --git a/services/event-service/src/do/user-session-do.ts b/services/event-service/src/do/user-session-do.ts index bb9b79cf16..d8e595fc1a 100644 --- a/services/event-service/src/do/user-session-do.ts +++ b/services/event-service/src/do/user-session-do.ts @@ -1,6 +1,6 @@ import { DurableObject } from 'cloudflare:workers'; import { clientMessageSchema, MAX_CONTEXTS } from '@kilocode/event-service'; -import { logger, withLogTags } from '../util/logger'; +import { configureDevLogging, logger, withLogTags } from '../util/logger'; import type { ServerMessage } from '../types'; type SerializedState = { contexts: string[] }; @@ -12,67 +12,77 @@ export class UserSessionDO extends DurableObject { } async fetch(request: Request): Promise { - const upgradeHeader = request.headers.get('Upgrade'); - if (upgradeHeader !== 'websocket') { - return new Response('Expected WebSocket upgrade', { status: 426 }); - } + return withLogTags({ source: 'UserSessionDO.fetch' }, () => { + configureDevLogging(this.env); + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader !== 'websocket') { + logger.debug('fetch: rejecting non-websocket request'); + return new Response('Expected WebSocket upgrade', { status: 426 }); + } - const pair = new WebSocketPair(); - const [client, server] = [pair[0], pair[1]]; + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; - this.ctx.acceptWebSocket(server); - server.serializeAttachment({ contexts: [] } satisfies SerializedState); + this.ctx.acceptWebSocket(server); + server.serializeAttachment({ contexts: [] } satisfies SerializedState); - return new Response(null, { status: 101, webSocket: client }); + logger.debug('fetch: accepted websocket'); + return new Response(null, { status: 101, webSocket: client }); + }); } async webSocketMessage(ws: WebSocket, rawMessage: string | ArrayBuffer): Promise { - if (typeof rawMessage !== 'string') return; - - let parsed: unknown; - try { - parsed = JSON.parse(rawMessage); - } catch { - return; - } - - const result = clientMessageSchema.safeParse(parsed); - if (!result.success) return; - const msg = result.data; + return withLogTags({ source: 'UserSessionDO.webSocketMessage' }, () => { + configureDevLogging(this.env); + if (typeof rawMessage !== 'string') return; + + let parsed: unknown; + try { + parsed = JSON.parse(rawMessage); + } catch { + return; + } - switch (msg.type) { - case 'context.subscribe': { - const state = this.getState(ws); - let overflowed = false; - for (const ctx of msg.contexts) { - if (state.contexts.size >= MAX_CONTEXTS && !state.contexts.has(ctx)) { - overflowed = true; - continue; + const result = clientMessageSchema.safeParse(parsed); + if (!result.success) return; + const msg = result.data; + + switch (msg.type) { + case 'context.subscribe': { + const state = this.getState(ws); + let overflowed = false; + for (const ctx of msg.contexts) { + if (state.contexts.size >= MAX_CONTEXTS && !state.contexts.has(ctx)) { + overflowed = true; + continue; + } + state.contexts.add(ctx); } - state.contexts.add(ctx); - } - this.saveState(ws, state); - if (overflowed) { - const errorMsg = { - type: 'error', - code: 'too_many_contexts', - max: MAX_CONTEXTS, - } satisfies ServerMessage; - try { - ws.send(JSON.stringify(errorMsg)); - } catch { - // Connection dead — hibernation will clean up + this.saveState(ws, state); + logger.debug('subscribed', { contexts: msg.contexts, overflowed }); + if (overflowed) { + const errorMsg = { + type: 'error', + code: 'too_many_contexts', + max: MAX_CONTEXTS, + } satisfies ServerMessage; + try { + ws.send(JSON.stringify(errorMsg)); + } catch { + // Connection dead — hibernation will clean up + } } + break; + } + case 'context.unsubscribe': { + const state = this.getState(ws); + for (const ctx of msg.contexts) state.contexts.delete(ctx); + this.saveState(ws, state); + logger.debug('unsubscribed', { contexts: msg.contexts }); + break; } - break; - } - case 'context.unsubscribe': { - const state = this.getState(ws); - for (const ctx of msg.contexts) state.contexts.delete(ctx); - this.saveState(ws, state); - break; } - } + }); } // Required by the hibernation API: workerd calls webSocketClose on any @@ -92,6 +102,7 @@ export class UserSessionDO extends DurableObject { payload: unknown ): Promise { return withLogTags({ source: 'UserSessionDO.pushEvent' }, () => { + configureDevLogging(this.env); logger.setTags({ userId: this.ctx.id.name, context, event }); const sockets = this.ctx.getWebSockets(); @@ -109,18 +120,25 @@ export class UserSessionDO extends DurableObject { // Connection dead — hibernation will clean up } } + logger.debug('pushEvent: delivered', { sockets: sockets.length, delivered }); return delivered; }); } async hasContext(context: string): Promise { return withLogTags({ source: 'UserSessionDO.hasContext' }, () => { + configureDevLogging(this.env); logger.setTags({ userId: this.ctx.id.name, context }); + let present = false; for (const ws of this.ctx.getWebSockets()) { const state = this.getState(ws); - if (state.contexts.has(context)) return true; + if (state.contexts.has(context)) { + present = true; + break; + } } - return false; + logger.debug('hasContext', { present }); + return present; }); } diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 3cf2f92dcc..d30976c7b6 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -7,7 +7,7 @@ import type { ConnectTicketResponse } from '@kilocode/event-service'; import { connectTicketQuerySchema } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; import { authenticateToken } from './auth'; -import { logger } from './util/logger'; +import { configureDevLogging, logger, withLogTags } from './util/logger'; import { type TicketMintRequest } from './do/connection-ticket-do'; export { UserSessionDO } from './do/user-session-do'; @@ -17,15 +17,19 @@ const app = new Hono<{ Bindings: Env }>(); const ACCEPTED_WEBSOCKET_PROTOCOL = 'kilo.events.v1'; const CONNECTION_TICKET_TTL_MS = 30_000; const ALLOWED_BROWSER_ORIGINS = ['https://kilo.ai', 'https://app.kilo.ai', 'http://localhost:3000']; +const LOCALHOST_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/; -app.use( - '/connect/*', - cors({ origin: origin => (ALLOWED_BROWSER_ORIGINS.includes(origin) ? origin : null) }) -); +function allowedOrigin(origin: string, env: { WORKER_ENV?: string }): string | null { + if (ALLOWED_BROWSER_ORIGINS.includes(origin)) return origin; + if (env.WORKER_ENV === 'development' && LOCALHOST_ORIGIN.test(origin)) return origin; + return null; +} + +app.use('/connect/*', cors({ origin: (origin, c) => allowedOrigin(origin, c.env) })); app.use( '/connect-ticket', cors({ - origin: origin => (ALLOWED_BROWSER_ORIGINS.includes(origin) ? origin : null), + origin: (origin, c) => allowedOrigin(origin, c.env), allowMethods: ['POST', 'OPTIONS'], allowHeaders: ['Authorization'], }) @@ -33,6 +37,11 @@ app.use( // ── Structured logging context ────────────────────────────────────────── app.use('*', useWorkersLogger('event-service') as unknown as MiddlewareHandler); +// Enable debug logs for the request only in local dev (WORKER_ENV === 'development'). +app.use('*', async (c, next) => { + configureDevLogging(c.env); + await next(); +}); app.get('/health', c => c.json({ ok: true })); @@ -72,33 +81,42 @@ app.post('/connect-ticket', async c => { const token = extractBearerToken(c.req.header('authorization')); const auth = await authenticateToken(token, c.env); if (!auth) { + logger.debug('connect-ticket: unauthorized'); return c.json({ error: 'Unauthorized' }, 401); } + logger.setTags({ userId: auth.userId }); const ticket = await mintConnectionTicket(c.env, auth.userId); if (!ticket) { + logger.debug('connect-ticket: mint failed'); return c.json({ error: 'Ticket mint failed' }, 500); } + logger.debug('connect-ticket: minted'); + const response = { ticket } satisfies ConnectTicketResponse; return c.json(response); }); app.get('/connect', async c => { if (c.req.header('Upgrade') !== 'websocket') { + logger.debug('connect: expected websocket upgrade'); return c.json({ error: 'Expected WebSocket upgrade' }, 426); } const query = connectTicketQuerySchema.safeParse({ ticket: c.req.query('ticket') }); if (!query.success) { + logger.debug('connect: missing ticket'); return c.json({ error: 'Unauthorized' }, 401); } const userId = await consumeConnectionTicket(c.env, query.data.ticket); if (!userId) { + logger.debug('connect: ticket consume failed'); return c.json({ error: 'Unauthorized' }, 401); } logger.setTags({ userId }); + logger.debug('connect: websocket upgrade', { userId }); const doId = c.env.USER_SESSION_DO.idFromName(userId); const stub = c.env.USER_SESSION_DO.get(doId); @@ -142,14 +160,25 @@ export default class extends WorkerEntrypoint { event: Name, payload: unknown ): Promise { - logger.setTags({ userId, context, event }); - const stub = this.env.USER_SESSION_DO.get(this.env.USER_SESSION_DO.idFromName(userId)); - return stub.pushEvent(context, event, payload); + return withLogTags({ source: 'event-service.pushEvent' }, async () => { + configureDevLogging(this.env); + logger.setTags({ userId, context, event }); + logger.debug('pushEvent: received'); + const stub = this.env.USER_SESSION_DO.get(this.env.USER_SESSION_DO.idFromName(userId)); + const delivered = await stub.pushEvent(context, event, payload); + logger.debug('pushEvent: delivered', { delivered }); + return delivered; + }); } async isUserInContext(userId: string, context: string): Promise { - logger.setTags({ userId, context }); - const stub = this.env.USER_SESSION_DO.get(this.env.USER_SESSION_DO.idFromName(userId)); - return stub.hasContext(context); + return withLogTags({ source: 'event-service.isUserInContext' }, async () => { + configureDevLogging(this.env); + logger.setTags({ userId, context }); + const stub = this.env.USER_SESSION_DO.get(this.env.USER_SESSION_DO.idFromName(userId)); + const present = await stub.hasContext(context); + logger.debug('isUserInContext', { present }); + return present; + }); } } diff --git a/services/event-service/src/util/logger.ts b/services/event-service/src/util/logger.ts index 24c1e4d7e4..0674383bbb 100644 --- a/services/event-service/src/util/logger.ts +++ b/services/event-service/src/util/logger.ts @@ -8,6 +8,11 @@ * - In the Hono worker: use `useWorkersLogger` middleware to establish context. * - In DOs: wrap the entry point with `withLogTags`. * - Anywhere: call `logger.setTags({ userId })` to tag all subsequent logs. + * + * Debug logs are suppressed by default (minimumLogLevel: 'info'). Local dev + * opts in per-request via configureDevLogging(), which raises the current + * context to 'debug' when WORKER_ENV === 'development'. Production never opts + * in, so debug calls are no-ops there. */ import { WorkersLogger, withLogTags } from 'workers-tagged-logger'; @@ -19,5 +24,15 @@ export type LogTags = { event?: string; }; -export const logger = new WorkersLogger(); +export const logger = new WorkersLogger({ minimumLogLevel: 'info' }); + +/** + * Enable debug-level logs for the current async context in local dev only. + * Call inside a withLogTags/useWorkersLogger context (per request entry point). + * No-op in production, so debug logs never ship there. + */ +export function configureDevLogging(env: { WORKER_ENV?: string }): void { + if (env.WORKER_ENV === 'development') logger.setLogLevel('debug'); +} + export { withLogTags }; diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index 7cef8cc1df..669df84e48 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -35,6 +35,10 @@ const ACCEPTED_BOOKKEEPING_RETRY_DELAY_MS = 30_000; export class NotificationChannelDO extends DurableObject { async dispatchPush(input: DispatchPushInput): Promise { + return this.dispatchPushCore(input); + } + + private async dispatchPushCore(input: DispatchPushInput): Promise { // 1. Idempotency. DO is single-threaded — requests for a given // user serialize on this instance. Retryable send failures leave the // record at `pending` so upstream can retry the send without diff --git a/services/notifications/src/lib/cloud-agent-session-push.ts b/services/notifications/src/lib/cloud-agent-session-push.ts index ce11537916..f4d9feeff7 100644 --- a/services/notifications/src/lib/cloud-agent-session-push.ts +++ b/services/notifications/src/lib/cloud-agent-session-push.ts @@ -1,4 +1,4 @@ -import { presenceContextForPlatform } from '@kilocode/event-service'; +import { presenceContextForCliSession, presenceContextForPlatform } from '@kilocode/event-service'; import { sendCloudAgentSessionNotificationInputSchema, sendSessionReadyNotificationInputSchema, @@ -39,7 +39,15 @@ async function dispatchSessionPush( ): Promise { const session = await deps.getSession(userId, cliSessionId); + const log = (outcome: string) => + console.log('Cloud agent session notification', { + userId, + cliSessionId, + outcome, + }); + if (!session) { + log('missing_session'); return { dispatched: false, reason: 'missing_session' }; } @@ -47,6 +55,7 @@ async function dispatchSessionPush( session.organizationId && !(await deps.hasOrganizationAccess(userId, session.organizationId)) ) { + log('missing_session'); return { dispatched: false, reason: 'missing_session' }; } @@ -66,9 +75,11 @@ async function dispatchSessionPush( } satisfies DispatchPushInput); if (outcome.kind === 'failed') { + log('dispatch_failed'); return { dispatched: false, reason: 'dispatch_failed' }; } + log(outcome.kind); return { dispatched: true }; } @@ -81,7 +92,9 @@ export async function dispatchCloudAgentSessionPush( parsed.userId, parsed.cliSessionId, session => ({ - presenceContext: null, + presenceContext: parsed.suppressIfViewingSession + ? presenceContextForCliSession(parsed.cliSessionId) + : null, idempotencyKey: `cloud-agent:${parsed.cliSessionId}:${parsed.executionId}`, title: session.title ?? 'Agent session', body: parsed.body, 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..482bb620ea 100644 --- a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts +++ b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { type DispatchPushInput, type DispatchPushOutcome } from '@kilocode/notifications'; +import { + sendCloudAgentSessionNotificationInputSchema, + type DispatchPushInput, + type DispatchPushOutcome, +} from '@kilocode/notifications'; import { dispatchCloudAgentSessionPush, @@ -75,6 +79,48 @@ describe('dispatchCloudAgentSessionPush', () => { expect(deps.hasOrganizationAccess).not.toHaveBeenCalled(); }); + it('passes the CLI session presence context when requested', async () => { + const deps = createDeps(); + + const result = await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_1', + status: 'completed', + body: 'Finished', + suppressIfViewingSession: true, + }, + deps + ); + + expect(result).toEqual({ dispatched: true }); + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ presenceContext: '/presence/cli-session/ses_1' }) + ); + }); + + it('does not pass a presence context when suppression is explicitly disabled', async () => { + const deps = createDeps(); + + const result = await dispatchCloudAgentSessionPush( + { + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_1', + status: 'completed', + body: 'Finished', + suppressIfViewingSession: false, + }, + deps + ); + + expect(result).toEqual({ dispatched: true }); + expect(mockDispatchPush).toHaveBeenCalledWith( + expect.objectContaining({ presenceContext: null }) + ); + }); + it('keeps follow-up executions in one session idempotent independently', async () => { const deps = createDeps(); @@ -277,3 +323,26 @@ describe('dispatchSessionReadyPush', () => { expect(result).toEqual({ dispatched: false, reason: 'dispatch_failed' }); }); }); + +describe('sendCloudAgentSessionNotificationInputSchema', () => { + it('accepts suppressIfViewingSession and strips unrelated fields', () => { + const parsed = sendCloudAgentSessionNotificationInputSchema.parse({ + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_1', + status: 'completed', + body: 'Finished', + suppressIfViewingSession: true, + extra: 'stripped', + }); + + expect(parsed).toEqual({ + userId: 'user-1', + cliSessionId: 'ses_1', + executionId: 'exec_1', + status: 'completed', + body: 'Finished', + suppressIfViewingSession: true, + }); + }); +}); diff --git a/services/session-ingest/src/dos/SessionIngestDO.ts b/services/session-ingest/src/dos/SessionIngestDO.ts index 7adc3a7b13..9490140cc3 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.ts @@ -1,5 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; -import { eq, ne, gt, gte, lt, and, or, inArray, isNull, isNotNull } from 'drizzle-orm'; +import { desc, eq, ne, gt, gte, lt, and, or, inArray, isNull, isNotNull } from 'drizzle-orm'; import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; @@ -17,6 +17,13 @@ import { extractNormalizedTitleFromItem, extractStatusFromItem, } from './session-ingest-extractors'; +import { + buildAssistantExcerpt, + completedAssistantMessageIdFromItemData, + isCompletedStatus, + isNeedsInputStatus, + type AttentionSignal, +} from './session-ingest-attention'; import { computeSessionMetrics, INACTIVITY_TIMEOUT_MS, @@ -72,6 +79,13 @@ function writeIngestMetaIfChanged( return { changed: true, value: params.incomingValue }; } +function hasIngestMeta(db: DrizzleSqliteDODatabase, key: IngestMetaKey): boolean { + return ( + db.select({ value: ingestMeta.value }).from(ingestMeta).where(eq(ingestMeta.key, key)).get() !== + undefined + ); +} + const INGEST_META_EXTRACTORS: Array<{ key: ExtractableMetaKey; extract: (item: IngestBatch[number]) => string | null | undefined; @@ -88,8 +102,10 @@ const INGEST_META_EXTRACTORS: Array<{ type Changes = Array<{ name: ExtractableMetaKey; value: string | null }>; export type IngestResult = - | { accepted: true; changes: Changes } + | { accepted: true; changes: Changes; attentionSignals: AttentionSignal[] } | { accepted: false; reason: 'deleted'; changes: never[] }; +/** How many of the newest message rows to inspect when pairing an idle transition with the assistant turn that just finished. */ +const COMPLETED_MESSAGE_SCAN_LIMIT = 50; type IngestLifecycleEvent = | { type: 'session_open' } @@ -273,6 +289,11 @@ export class SessionIngestDO extends DurableObject { await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS); } + // Read before the write loop below persists incoming values: whether this session had ever + // reported a status. The first-ever status write also registers as a change, and a + // full-history backfill of an already-idle session must not push about an old turn. + const hadPriorStatus = hasIngestMeta(this.db, 'status'); + const changes: Changes = []; for (const key of Object.keys(incomingByKey) as ExtractableMetaKey[]) { const incoming = incomingByKey[key]; @@ -302,9 +323,34 @@ export class SessionIngestDO extends DurableObject { ); } + const attentionSignals: AttentionSignal[] = []; + + const statusChange = changes.find(change => change.name === 'status'); + if (statusChange && isCompletedStatus(statusChange.value) && hadPriorStatus) { + // An idle transition means the assistant finished its turn. Pair it with the most recent + // completed assistant message so the signal carries that turn's excerpt; if none exists yet + // (e.g. a fresh session reporting idle before any turn), emit nothing rather than a spurious + // "Task completed" push. + const completedMessageId = this.findLastCompletedAssistantMessageId(); + if (completedMessageId) { + attentionSignals.push({ + signalId: completedMessageId, + kind: 'completed', + messageExcerpt: this.buildAssistantExcerptForMessage(completedMessageId), + }); + } + } else if (statusChange && isNeedsInputStatus(statusChange.value)) { + attentionSignals.push({ + signalId: `status:${statusChange.value}:${ingestedAt ?? Date.now()}`, + kind: 'needs_input', + messageExcerpt: '', + }); + } + return { accepted: true, changes, + attentionSignals, }; } @@ -343,6 +389,59 @@ export class SessionIngestDO extends DurableObject { ); } + /** Builds a text excerpt for a completed assistant message from its already-ingested text parts. */ + private buildAssistantExcerptForMessage(messageId: string): string { + const range = getPartItemIdentityRange(messageId); + const rows = this.db + .select({ + item_data: ingestItems.item_data, + item_data_r2_key: ingestItems.item_data_r2_key, + }) + .from(ingestItems) + .where( + and( + eq(ingestItems.item_type, 'part'), + gte(ingestItems.item_id, range.start), + lt(ingestItems.item_id, range.end) + ) + ) + .orderBy(ingestItems.ingested_at, ingestItems.id) + .all(); + + // Parts offloaded to R2 (oversized items) store '{}' inline; skip them rather + // than fetching from R2 — this is a best-effort excerpt, not a full transcript. + return buildAssistantExcerpt( + rows.filter(row => !row.item_data_r2_key).map(row => row.item_data) + ); + } + + /** + * Finds the most recent completed assistant message id, scanning messages newest-first. Used to + * pair an idle transition with the assistant turn that just finished so the `completed` attention + * signal carries that turn's excerpt. R2-offloaded message rows (inline '{}') are skipped. + * + * The scan is bounded: the turn that just finished is effectively always among the newest + * messages, and an unbounded scan would load every message row on every turn end. + */ + private findLastCompletedAssistantMessageId(): string | null { + const rows = this.db + .select({ + item_data: ingestItems.item_data, + item_data_r2_key: ingestItems.item_data_r2_key, + }) + .from(ingestItems) + .where(eq(ingestItems.item_type, 'message')) + .orderBy(desc(ingestItems.ingested_at), desc(ingestItems.id)) + .limit(COMPLETED_MESSAGE_SCAN_LIMIT) + .all(); + for (const row of rows) { + if (row.item_data_r2_key) continue; + const messageId = completedAssistantMessageIdFromItemData(row.item_data); + if (messageId) return messageId; + } + return null; + } + async readKiloSdkSessionSnapshot(): Promise { return readKiloSdkSessionSnapshot(this.db, this.env.SESSION_INGEST_R2); } diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index 83fddbf466..07e2719828 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -182,7 +182,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; + }> = [] ): MockWS { const attachment = { role: 'cli' as const, connectionId, sessions }; const ws = createMockWs(['cli'], attachment); @@ -206,7 +210,11 @@ function addWebSocket( function sendHeartbeat( doInstance: UserConnectionDO, cliWs: MockWS, - sessions: Array<{ id: string; status: string; title: string }>, + sessions: Array<{ + id: string; + status: string; + title: string; + }>, protocolVersion?: string ) { const msg = JSON.stringify({ @@ -346,6 +354,31 @@ describe('UserConnectionDO', () => { }); }); + describe('hasActiveCliSession', () => { + it('tracks whether a connected CLI heartbeat currently owns the session', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + + expect(doInstance.hasActiveCliSession('ses_1')).toBe(false); + + sendHeartbeat(doInstance, cliWs, [makeSession('ses_1')]); + + expect(doInstance.hasActiveCliSession('ses_1')).toBe(true); + + mockCtx.removeSocket(cliWs); + disconnectCli(doInstance, cliWs); + + expect(doInstance.hasActiveCliSession('ses_1')).toBe(false); + }); + + it('reconstructs live session ownership from a hibernated CLI attachment', () => { + const { doInstance, mockCtx } = setup(); + addCliSocket(mockCtx, 'cli-1', [makeSession('ses_1')]); + + expect(doInstance.hasActiveCliSession('ses_1')).toBe(true); + }); + }); + // ------------------------------------------------------------------------- // Heartbeat processing // ------------------------------------------------------------------------- diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index e79014dc7f..b6855f19a7 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -805,6 +805,11 @@ export class UserConnectionDO extends DurableObject { return { delivered }; } + hasActiveCliSession(sessionId: string): boolean { + this.ensureState(); + return this.findCliForSession(sessionId) !== undefined; + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/services/session-ingest/src/dos/session-ingest-attention.test.ts b/services/session-ingest/src/dos/session-ingest-attention.test.ts new file mode 100644 index 0000000000..5ca0dda844 --- /dev/null +++ b/services/session-ingest/src/dos/session-ingest-attention.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { + buildAssistantExcerpt, + completedAssistantMessageIdFromItemData, + extractTextFromPartItemData, + isCompletedStatus, + isNeedsInputStatus, +} from './session-ingest-attention'; + +function completedAssistantMessageJson(data: Record): string { + return JSON.stringify({ id: 'msg-1', ...data }); +} + +describe('completedAssistantMessageIdFromItemData', () => { + it('returns the message id for a completed assistant message', () => { + expect( + completedAssistantMessageIdFromItemData( + completedAssistantMessageJson({ role: 'assistant', time: { created: 1, completed: 2 } }) + ) + ).toBe('msg-1'); + }); + + it('returns undefined for an assistant message without time.completed', () => { + expect( + completedAssistantMessageIdFromItemData( + completedAssistantMessageJson({ role: 'assistant', time: { created: 1 } }) + ) + ).toBeUndefined(); + }); + + it('returns undefined for a user message', () => { + expect( + completedAssistantMessageIdFromItemData( + completedAssistantMessageJson({ role: 'user', time: { created: 1, completed: 2 } }) + ) + ).toBeUndefined(); + }); + + it('returns undefined for malformed JSON', () => { + expect(completedAssistantMessageIdFromItemData('{not json')).toBeUndefined(); + }); +}); + +describe('extractTextFromPartItemData', () => { + it('extracts text from a text part', () => { + expect(extractTextFromPartItemData(JSON.stringify({ type: 'text', text: 'hello' }))).toBe( + 'hello' + ); + }); + + it('returns undefined for a non-text part', () => { + expect( + extractTextFromPartItemData(JSON.stringify({ type: 'tool', tool: 'bash' })) + ).toBeUndefined(); + }); + + it('returns undefined for malformed JSON', () => { + expect(extractTextFromPartItemData('{not json')).toBeUndefined(); + }); +}); + +describe('buildAssistantExcerpt', () => { + it('joins multiple text parts in order and trims the result', () => { + const rows = [ + JSON.stringify({ type: 'text', text: 'Hello ' }), + JSON.stringify({ type: 'text', text: 'world' }), + JSON.stringify({ type: 'text', text: ' ' }), + ]; + expect(buildAssistantExcerpt(rows)).toBe('Hello world'); + }); + + it('skips non-text parts when building the excerpt', () => { + const rows = [ + JSON.stringify({ type: 'tool', tool: 'bash' }), + JSON.stringify({ type: 'text', text: 'done' }), + ]; + expect(buildAssistantExcerpt(rows)).toBe('done'); + }); + + it('returns an empty string when there are no text parts', () => { + expect(buildAssistantExcerpt([JSON.stringify({ type: 'tool', tool: 'bash' })])).toBe(''); + }); + + it('collapses newlines and repeated whitespace into single spaces', () => { + const rows = [JSON.stringify({ type: 'text', text: 'First line.\n\nSecond line.' })]; + expect(buildAssistantExcerpt(rows)).toBe('First line. Second line.'); + }); + + it('truncates a long excerpt to 100 characters with an ellipsis', () => { + const rows = [JSON.stringify({ type: 'text', text: 'a'.repeat(250) })]; + const excerpt = buildAssistantExcerpt(rows); + expect(excerpt).toHaveLength(100); + expect(excerpt).toBe('a'.repeat(97) + '...'); + }); + + it('keeps an excerpt at exactly 100 characters untouched', () => { + const rows = [JSON.stringify({ type: 'text', text: 'b'.repeat(100) })]; + expect(buildAssistantExcerpt(rows)).toBe('b'.repeat(100)); + }); +}); + +describe('isCompletedStatus', () => { + it('returns true for idle', () => { + expect(isCompletedStatus('idle')).toBe(true); + }); + + it('returns false for busy, question, permission, and retry', () => { + expect(isCompletedStatus('busy')).toBe(false); + expect(isCompletedStatus('question')).toBe(false); + expect(isCompletedStatus('permission')).toBe(false); + expect(isCompletedStatus('retry')).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isCompletedStatus(null)).toBe(false); + expect(isCompletedStatus(undefined)).toBe(false); + }); +}); + +describe('isNeedsInputStatus', () => { + it('returns true for question and permission', () => { + expect(isNeedsInputStatus('question')).toBe(true); + expect(isNeedsInputStatus('permission')).toBe(true); + }); + + it('returns false for idle, busy, and retry', () => { + expect(isNeedsInputStatus('idle')).toBe(false); + expect(isNeedsInputStatus('busy')).toBe(false); + expect(isNeedsInputStatus('retry')).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isNeedsInputStatus(null)).toBe(false); + expect(isNeedsInputStatus(undefined)).toBe(false); + }); +}); diff --git a/services/session-ingest/src/dos/session-ingest-attention.ts b/services/session-ingest/src/dos/session-ingest-attention.ts new file mode 100644 index 0000000000..b5208b58bb --- /dev/null +++ b/services/session-ingest/src/dos/session-ingest-attention.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +export type AttentionSignalKind = 'completed' | 'needs_input'; + +export type AttentionSignal = { + signalId: string; + kind: AttentionSignalKind; + /** Push-ready excerpt: whitespace-collapsed and capped to fit a notification body. */ + messageExcerpt: string; +}; + +const NEEDS_INPUT_STATUSES = new Set(['question', 'permission']); + +/** Statuses that mean the session is waiting for the user to answer a question or approve a permission. */ +export function isNeedsInputStatus(status: string | null | undefined): boolean { + return status !== null && status !== undefined && NEEDS_INPUT_STATUSES.has(status); +} + +/** + * The root session went idle — the assistant finished its turn and is awaiting the next user message. + * Child-session statuses never reach this point: the kilo global feed drops any event whose + * `sessionID` is not the root session's, so only root idle transitions are ingested. + */ +export function isCompletedStatus(status: string | null | undefined): boolean { + return status === 'idle'; +} + +const CompletedAssistantMessageSchema = z.object({ + id: z.string().min(1), + role: z.literal('assistant'), + time: z.object({ completed: z.number() }), +}); + +/** + * Returns the message id when a message item's stored `item_data` JSON is a completed assistant + * message (role `assistant` with `time.completed`). Used to pair an idle transition with the + * assistant turn that just finished, so the `completed` signal can carry that turn's excerpt. + */ +export function completedAssistantMessageIdFromItemData(itemDataJson: string): string | undefined { + try { + const parsed = CompletedAssistantMessageSchema.safeParse(JSON.parse(itemDataJson)); + return parsed.success ? parsed.data.id : undefined; + } catch { + return undefined; + } +} + +const TextPartSchema = z.object({ type: z.literal('text'), text: z.string() }); + +/** Extracts the text of a `part` item's stored `item_data` JSON, or undefined if it isn't a text part. */ +export function extractTextFromPartItemData(itemDataJson: string): string | undefined { + try { + const parsed = TextPartSchema.safeParse(JSON.parse(itemDataJson)); + return parsed.success ? parsed.data.text : undefined; + } catch { + return undefined; + } +} + +// Expo/APNs reject oversized push payloads outright, so the excerpt must stay far below +// that limit. Matches the snippet length cloud-agent-next uses for the same notification. +const EXCERPT_MAX_LENGTH = 100; +const ELLIPSIS = '...'; + +function truncateExcerpt(text: string): string { + const singleLine = text.trim().replace(/\s+/g, ' '); + if (singleLine.length <= EXCERPT_MAX_LENGTH) return singleLine; + return singleLine.slice(0, EXCERPT_MAX_LENGTH - ELLIPSIS.length) + ELLIPSIS; +} + +/** Joins text parts (already in emission order) into a single push-ready excerpt. */ +export function buildAssistantExcerpt(partItemDataJsonRows: string[]): string { + const pieces: string[] = []; + for (const itemDataJson of partItemDataJsonRows) { + const text = extractTextFromPartItemData(itemDataJson); + if (text) pieces.push(text); + } + return truncateExcerpt(pieces.join('')); +} diff --git a/services/session-ingest/src/env.ts b/services/session-ingest/src/env.ts index 6bc8f942a8..9cc58ea337 100644 --- a/services/session-ingest/src/env.ts +++ b/services/session-ingest/src/env.ts @@ -1,5 +1,5 @@ -import type { NotificationsBinding } from './notifications-binding.js'; import type { O11YBinding } from './o11y-binding.js'; +import type { NotificationsBinding } from './notifications-binding.js'; export type Env = Omit< Cloudflare.Env, diff --git a/services/session-ingest/src/notifications-binding.ts b/services/session-ingest/src/notifications-binding.ts index deb6749e6d..9de1ea4035 100644 --- a/services/session-ingest/src/notifications-binding.ts +++ b/services/session-ingest/src/notifications-binding.ts @@ -7,11 +7,16 @@ */ import type { + SendCloudAgentSessionNotificationParams, + SendCloudAgentSessionNotificationResult, SendSessionReadyNotificationParams, SendSessionReadyNotificationResult, } from '@kilocode/notifications'; export type NotificationsBinding = Fetcher & { + sendCloudAgentSessionNotification( + params: SendCloudAgentSessionNotificationParams + ): Promise; sendSessionReadyNotification( params: SendSessionReadyNotificationParams ): Promise; diff --git a/services/session-ingest/src/queue-consumer.test.ts b/services/session-ingest/src/queue-consumer.test.ts index 3ef5c19345..78172ab5f1 100644 --- a/services/session-ingest/src/queue-consumer.test.ts +++ b/services/session-ingest/src/queue-consumer.test.ts @@ -27,6 +27,10 @@ vi.mock('./dos/SessionIngestDO', () => ({ getSessionIngestDO: vi.fn(), })); +vi.mock('./dos/UserConnectionDO', () => ({ + getUserConnectionDO: vi.fn(), +})); + vi.mock('./session-events', async importOriginal => { const actual = await importOriginal(); return { @@ -45,6 +49,7 @@ vi.mock('./util/ingest-limits', () => ({ import { getWorkerDb } from '@kilocode/db/client'; import { getSessionIngestDO } from './dos/SessionIngestDO'; +import { getUserConnectionDO } from './dos/UserConnectionDO'; import { notifyUserSessionEvent } from './session-events'; import { QUEUE_RETRY_DELAY_SECONDS, createItemExtractor, queue } from './queue-consumer'; import { computeSessionMetadataUpdates } from './ingest/metadata'; @@ -1014,3 +1019,165 @@ describe('queue status notifications', () => { ); }); }); + +describe('remote session attention notifications', () => { + const attentionSignal = { signalId: 'msg_1', kind: 'completed', messageExcerpt: 'All done' }; + + function setUpAttentionTest(params: { + sessionRow: { parent_session_id: string | null }; + activeCliSession?: boolean; + ingest?: ReturnType; + stagedItems?: unknown[]; + }) { + const ingest = + params.ingest ?? vi.fn(async () => ({ changes: [], attentionSignals: [attentionSignal] })); + vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + + // A single session lookup serves both the deleted-session guard and push eligibility. + const limit = vi.fn(async () => [{ session_id: 'ses_remote', ...params.sessionRow }]); + const where = vi.fn(() => ({ limit })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + vi.mocked(getWorkerDb).mockReturnValue({ select } as never); + + const hasActiveCliSession = vi.fn(async () => params.activeCliSession ?? true); + vi.mocked(getUserConnectionDO).mockReturnValue({ + hasActiveCliSession, + } as never); + + const sendCloudAgentSessionNotification = vi.fn(async () => ({ dispatched: true })); + const body = JSON.stringify({ + data: params.stagedItems ?? [{ type: 'message', data: { id: 'msg_1' } }], + }); + const env = { + HYPERDRIVE: { connectionString: 'postgres://unused' }, + SESSION_INGEST_R2: { + get: vi.fn(async () => new Response(body)), + put: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }, + NOTIFICATIONS: { sendCloudAgentSessionNotification }, + } as never; + + return { + env, + ingest, + hasActiveCliSession, + sendCloudAgentSessionNotification, + }; + } + + async function runAttentionQueue(env: unknown) { + const ack = vi.fn(); + const retry = vi.fn(); + const waitUntilPromises: Promise[] = []; + const ctx = { + waitUntil: vi.fn((p: Promise) => waitUntilPromises.push(p)), + } as unknown as ExecutionContext; + + await queue( + { + messages: [ + { + body: { + r2Key: 'staging/attention', + kiloUserId: 'usr_remote', + sessionId: 'ses_remote', + ingestVersion: 1, + ingestedAt: 1, + }, + ack, + retry, + }, + ], + } as never, + env as never, + ctx + ); + await Promise.all(waitUntilPromises); + + return { ack, retry }; + } + + it('dispatches a push notification for an active root session', async () => { + const { env, hasActiveCliSession, sendCloudAgentSessionNotification } = setUpAttentionTest({ + sessionRow: { parent_session_id: null }, + activeCliSession: true, + }); + + const { ack } = await runAttentionQueue(env); + + expect(ack).toHaveBeenCalledTimes(1); + expect(hasActiveCliSession).toHaveBeenCalledWith('ses_remote'); + expect(sendCloudAgentSessionNotification).toHaveBeenCalledWith({ + userId: 'usr_remote', + cliSessionId: 'ses_remote', + executionId: 'remote:msg_1', + status: 'completed', + body: 'All done', + suppressIfViewingSession: true, + }); + }); + + it('suppresses the push when no active CLI owns the root session', async () => { + const { env, hasActiveCliSession, sendCloudAgentSessionNotification } = setUpAttentionTest({ + sessionRow: { parent_session_id: null }, + activeCliSession: false, + }); + + await runAttentionQueue(env); + + expect(hasActiveCliSession).toHaveBeenCalledWith('ses_remote'); + expect(sendCloudAgentSessionNotification).not.toHaveBeenCalled(); + }); + + it('suppresses the push for a child session', async () => { + const { env, sendCloudAgentSessionNotification } = setUpAttentionTest({ + sessionRow: { parent_session_id: 'ses_parent' }, + }); + + await runAttentionQueue(env); + + expect(sendCloudAgentSessionNotification).not.toHaveBeenCalled(); + }); + + it('keeps notification failures non-fatal after the ingest succeeds', async () => { + const { env, sendCloudAgentSessionNotification } = setUpAttentionTest({ + sessionRow: { parent_session_id: null }, + }); + sendCloudAgentSessionNotification.mockRejectedValue(new Error('Notifications unavailable')); + + const { ack, retry } = await runAttentionQueue(env); + + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); + + it('dispatches signals collected from earlier chunks when a later chunk fails', async () => { + // 129 items force a mid-stream flush at the 128-item chunk cap; the leftover item + // flushes at the end as a second DO call. The first call commits and reports a + // signal, the second fails — the signal must still be dispatched because the retry + // will not re-emit the already-persisted status transition. + let ingestCalls = 0; + const ingest = vi.fn(async () => { + ingestCalls += 1; + if (ingestCalls > 1) throw new Error('DO unavailable'); + return { changes: [], attentionSignals: [attentionSignal] }; + }); + const stagedItems = Array.from({ length: 129 }, (_, i) => ({ + type: 'message', + data: { id: `msg_${i}` }, + })); + const { env, sendCloudAgentSessionNotification } = setUpAttentionTest({ + sessionRow: { parent_session_id: null }, + ingest, + stagedItems, + }); + + const { ack, retry } = await runAttentionQueue(env); + + expect(ack).not.toHaveBeenCalled(); + expect(retry).toHaveBeenCalledTimes(1); + expect(sendCloudAgentSessionNotification).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/session-ingest/src/queue-consumer.ts b/services/session-ingest/src/queue-consumer.ts index 642d753869..660252f8c7 100644 --- a/services/session-ingest/src/queue-consumer.ts +++ b/services/session-ingest/src/queue-consumer.ts @@ -15,6 +15,12 @@ import { withDORetry } from '@kilocode/worker-utils'; import { applyMetadataChanges, flushPartialMetadataChanges } from './ingest/metadata'; export { createItemExtractor } from './ingest/item-extractor'; import { createItemExtractor } from './ingest/item-extractor'; +import { getUserConnectionDO } from './dos/UserConnectionDO'; +import type { AttentionSignal } from './dos/session-ingest-attention'; +import { + dispatchRemoteSessionAttentionSignal, + isEligibleForRemoteSessionAttention, +} from './remote-session-notifications'; export interface IngestQueueMessage { r2Key: string; @@ -36,13 +42,21 @@ async function processMessage( msg: IngestQueueMessage, ctx: ExecutionContext ): Promise { - if (await deleteStagingObjectIfSessionMissing(env, msg)) return; + const sessionRow = await loadSessionOrCleanupStaging(env, msg); + if (!sessionRow) return; const body = await getStagingObjectBody(env, msg.r2Key); const mergedChanges = new Map(); + const mergedAttentionSignals: AttentionSignal[] = []; try { - const accepted = await ingestStagedSessionItems(env, msg, body, mergedChanges); + const accepted = await ingestStagedSessionItems( + env, + msg, + body, + mergedChanges, + mergedAttentionSignals + ); if (accepted) { await applyMetadataChanges(env, msg.kiloUserId, msg.sessionId, mergedChanges, ctx); } @@ -53,33 +67,49 @@ async function processMessage( // re-emitted — Postgres would never catch up. Flush what we have now so the // two stores stay in sync. Best-effort: never mask the original error. await flushPartialMetadataChanges(env, msg, mergedChanges, ctx); + // Same reasoning for attention signals: a committed status transition won't + // re-emit on retry, so dispatch what was collected before the failure. + scheduleAttentionSignalDispatch(env, msg, sessionRow, mergedAttentionSignals, ctx); throw err; } + scheduleAttentionSignalDispatch(env, msg, sessionRow, mergedAttentionSignals, ctx); await env.SESSION_INGEST_R2.delete(msg.r2Key); } -async function deleteStagingObjectIfSessionMissing( +type IngestSessionRow = { + session_id: string; + parent_session_id: string | null; +}; + +/** + * Loads the session row for the queued message, or cleans up the staging object and returns + * null if the session has been deleted since the message was queued. The parent column feeds + * the attention-push eligibility check later in processing. + */ +async function loadSessionOrCleanupStaging( env: Env, msg: IngestQueueMessage -): Promise { +): Promise { const { r2Key, kiloUserId, sessionId } = msg; - // Guard: skip processing if the session has been deleted since this message was queued const db = getWorkerDb(env.HYPERDRIVE.connectionString); const sessionRows = await db - .select({ session_id: cli_sessions_v2.session_id }) + .select({ + session_id: cli_sessions_v2.session_id, + parent_session_id: cli_sessions_v2.parent_session_id, + }) .from(cli_sessions_v2) .where( and(eq(cli_sessions_v2.session_id, sessionId), eq(cli_sessions_v2.kilo_user_id, kiloUserId)) ) .limit(1); - if (sessionRows[0]) return false; + if (sessionRows[0]) return sessionRows[0]; console.warn('Session no longer exists, cleaning up staging object', { r2Key, sessionId }); await env.SESSION_INGEST_R2.delete(r2Key); - return true; + return null; } async function getStagingObjectBody(env: Env, r2Key: string): Promise> { @@ -95,9 +125,10 @@ async function ingestStagedSessionItems( env: Env, msg: IngestQueueMessage, body: ReadableStream, - mergedChanges: Map + mergedChanges: Map, + mergedAttentionSignals: AttentionSignal[] ): Promise { - const chunker = createIngestChunker(env, msg, mergedChanges); + const chunker = createIngestChunker(env, msg, mergedChanges, mergedAttentionSignals); const parseError = await streamSessionItems(msg.r2Key, body, rawItem => chunker.stage(rawItem)); if (parseError) { @@ -178,7 +209,8 @@ function slimItemForR2Reference(item: SessionDataItem): SessionDataItem { function createIngestChunker( env: Env, msg: IngestQueueMessage, - mergedChanges: Map + mergedChanges: Map, + mergedAttentionSignals: AttentionSignal[] ) { const { r2Key, kiloUserId, sessionId, ingestVersion, ingestedAt } = msg; const encoder = new TextEncoder(); @@ -209,6 +241,7 @@ function createIngestChunker( for (const change of ingestResult.changes) { mergedChanges.set(change.name, change.value); } + mergedAttentionSignals.push(...(ingestResult.attentionSignals ?? [])); }; const stage = async (rawItem: Record): Promise => { @@ -260,6 +293,90 @@ function createIngestChunker( return { stage, flushChunkToSessionDO, wasAccepted: () => accepted }; } +/** + * Best-effort mobile push dispatch for remote-session attention signals detected during + * ingest (completed assistant turn, or waiting for input). Suppressed for sessions without + * a live CLI owner and for child sessions. Viewing suppression is delegated to notifications. + * Never throws — a dispatch failure must not block ack'ing (or retrying) the message. + */ +function scheduleAttentionSignalDispatch( + env: Env, + msg: IngestQueueMessage, + sessionRow: IngestSessionRow, + signals: AttentionSignal[], + ctx: ExecutionContext +): void { + if (signals.length === 0) return; + if ( + !isEligibleForRemoteSessionAttention({ + parentSessionId: sessionRow.parent_session_id, + }) + ) { + console.log('Skipping attention signal dispatch (ineligible session)', { + sessionId: msg.sessionId, + kiloUserId: msg.kiloUserId, + parentSessionId: sessionRow.parent_session_id, + signalCount: signals.length, + }); + return; + } + + console.log('Scheduling attention signal dispatch', { + sessionId: msg.sessionId, + kiloUserId: msg.kiloUserId, + signalCount: signals.length, + kinds: signals.map(s => s.kind), + }); + ctx.waitUntil( + dispatchRemoteSessionAttentionSignals(env, msg.kiloUserId, msg.sessionId, signals).catch( + error => { + console.error('Failed to dispatch remote session attention signals (non-fatal)', { + sessionId: msg.sessionId, + kiloUserId: msg.kiloUserId, + error: error instanceof Error ? error.message : String(error), + }); + } + ) + ); +} + +async function dispatchRemoteSessionAttentionSignals( + env: Env, + kiloUserId: string, + sessionId: string, + signals: AttentionSignal[] +): Promise { + for (const signal of signals) { + try { + const outcome = await dispatchRemoteSessionAttentionSignal( + { kiloUserId, sessionId, signal }, + { + hasActiveCliSession: async () => { + const stub = getUserConnectionDO(env, { kiloUserId }); + return stub.hasActiveCliSession(sessionId); + }, + sendPush: pushParams => env.NOTIFICATIONS.sendCloudAgentSessionNotification(pushParams), + } + ); + console.log('Remote session attention signal dispatch outcome', { + sessionId, + kiloUserId, + signalId: signal.signalId, + kind: signal.kind, + outcome, + }); + } catch (error) { + console.error('Failed to dispatch remote session attention notification (non-fatal)', { + sessionId, + kiloUserId, + signalId: signal.signalId, + kind: signal.kind, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + export async function queue( batch: MessageBatch, env: Env, diff --git a/services/session-ingest/src/remote-session-notifications.test.ts b/services/session-ingest/src/remote-session-notifications.test.ts new file mode 100644 index 0000000000..bee3d7aab2 --- /dev/null +++ b/services/session-ingest/src/remote-session-notifications.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { AttentionSignal } from './dos/session-ingest-attention'; +import { + buildRemoteSessionAttentionPushBody, + dispatchRemoteSessionAttentionSignal, + isEligibleForRemoteSessionAttention, +} from './remote-session-notifications'; + +function completedSignal(messageExcerpt: string): AttentionSignal { + return { signalId: 'msg-1', kind: 'completed', messageExcerpt }; +} + +function needsInputSignal(): AttentionSignal { + return { signalId: 'status:question:123', kind: 'needs_input', messageExcerpt: '' }; +} + +describe('isEligibleForRemoteSessionAttention', () => { + it('is eligible for a root session', () => { + expect(isEligibleForRemoteSessionAttention({ parentSessionId: null })).toBe(true); + }); + + it('is not eligible for a child session', () => { + expect(isEligibleForRemoteSessionAttention({ parentSessionId: 'parent-1' })).toBe(false); + }); +}); + +describe('buildRemoteSessionAttentionPushBody', () => { + it('uses the message excerpt for a completed signal', () => { + expect(buildRemoteSessionAttentionPushBody(completedSignal('All done!'))).toBe('All done!'); + }); + + it('falls back to a default body when the excerpt is empty', () => { + expect(buildRemoteSessionAttentionPushBody(completedSignal(''))).toBe('Task completed'); + }); + + it('uses a fixed body for needs-input signals', () => { + expect(buildRemoteSessionAttentionPushBody(needsInputSignal())).toBe('Kilo needs your input.'); + }); +}); + +describe('dispatchRemoteSessionAttentionSignal', () => { + it('sends a push with a stable executionId and web-viewing suppression flag', async () => { + const sendPush = vi.fn(async () => ({ dispatched: true })); + const outcome = await dispatchRemoteSessionAttentionSignal( + { kiloUserId: 'usr_1', sessionId: 'ses_1', signal: completedSignal('Done') }, + { hasActiveCliSession: async () => true, sendPush } + ); + + expect(outcome).toBe('sent'); + expect(sendPush).toHaveBeenCalledWith({ + userId: 'usr_1', + cliSessionId: 'ses_1', + executionId: 'remote:msg-1', + status: 'completed', + body: 'Done', + suppressIfViewingSession: true, + }); + }); + + it('suppresses the push when no connected CLI reports the session', async () => { + const hasActiveCliSession = vi.fn(async () => false); + const sendPush = vi.fn(async () => ({ dispatched: true })); + const outcome = await dispatchRemoteSessionAttentionSignal( + { kiloUserId: 'usr_1', sessionId: 'ses_1', signal: completedSignal('Done') }, + { hasActiveCliSession, sendPush } + ); + + expect(outcome).toBe('suppressed'); + expect(hasActiveCliSession).toHaveBeenCalledOnce(); + expect(sendPush).not.toHaveBeenCalled(); + }); +}); diff --git a/services/session-ingest/src/remote-session-notifications.ts b/services/session-ingest/src/remote-session-notifications.ts new file mode 100644 index 0000000000..22aec54018 --- /dev/null +++ b/services/session-ingest/src/remote-session-notifications.ts @@ -0,0 +1,62 @@ +import type { + SendCloudAgentSessionNotificationParams, + SendCloudAgentSessionNotificationResult, +} from '@kilocode/notifications'; +import type { AttentionSignal } from './dos/session-ingest-attention'; + +export type RemoteSessionInfo = { + parentSessionId: string | null; +}; + +/** Only root sessions (no parent) can be eligible for attention pushes. */ +export function isEligibleForRemoteSessionAttention(session: RemoteSessionInfo): boolean { + return session.parentSessionId === null; +} + +const NEEDS_INPUT_BODY = 'Kilo needs your input.'; +const DEFAULT_COMPLETED_BODY = 'Task completed'; + +export function buildRemoteSessionAttentionPushBody( + signal: Pick +): string { + if (signal.kind === 'needs_input') return NEEDS_INPUT_BODY; + return signal.messageExcerpt.length > 0 ? signal.messageExcerpt : DEFAULT_COMPLETED_BODY; +} + +export type DispatchRemoteSessionAttentionDeps = { + hasActiveCliSession: () => Promise; + sendPush: ( + params: SendCloudAgentSessionNotificationParams + ) => Promise; +}; + +export type DispatchRemoteSessionAttentionOutcome = 'sent' | 'suppressed'; + +/** + * Sends a best-effort mobile push for a remote session attention signal. Viewing suppression + * is handled by the notifications service presence check. A connected CLI must currently + * report the session in its heartbeat. Callers are expected to have already confirmed + * `isEligibleForRemoteSessionAttention` for the owning session. + */ +export async function dispatchRemoteSessionAttentionSignal( + params: { kiloUserId: string; sessionId: string; signal: AttentionSignal }, + deps: DispatchRemoteSessionAttentionDeps +): Promise { + if (!(await deps.hasActiveCliSession())) { + return 'suppressed'; + } + + await deps.sendPush({ + userId: params.kiloUserId, + cliSessionId: params.sessionId, + executionId: `remote:${params.signal.signalId}`, + // The push status enum has no needs_input value and the notifications service ignores + // status when building the push — the body carries the real semantics. Extending the + // enum would fail validation on a notifications worker deployed with the old schema. + status: 'completed', + body: buildRemoteSessionAttentionPushBody(params.signal), + suppressIfViewingSession: true, + }); + + return 'sent'; +} diff --git a/services/session-ingest/test/integration/session-ingest-do.test.ts b/services/session-ingest/test/integration/session-ingest-do.test.ts index a7e8db26df..25c3270d77 100644 --- a/services/session-ingest/test/integration/session-ingest-do.test.ts +++ b/services/session-ingest/test/integration/session-ingest-do.test.ts @@ -2095,6 +2095,236 @@ describe('SessionIngestDO integration', () => { }); }); + describe('attention signals', () => { + /** A completed signal requires a previously stored status, so tests start sessions as busy. */ + async function ingestBusyStatus(stub: ReturnType, sessionId: string) { + await stub.ingest( + [{ type: 'session_status', data: { status: 'busy' } }], + kiloUserId, + sessionId, + 1 + ); + } + + it('emits a completed signal with a text excerpt when the status transitions to idle', async () => { + const sessionId = 'ses_attention_completed_0001'; + const stub = getStub(kiloUserId, sessionId); + await ingestBusyStatus(stub, sessionId); + + const result = await stub.ingest( + [ + { + type: 'part', + data: { id: 'part_1', messageID: 'msg_1', type: 'text', text: 'Hello ' }, + }, + { type: 'part', data: { id: 'part_2', messageID: 'msg_1', type: 'text', text: 'world' } }, + { + type: 'message', + data: { id: 'msg_1', role: 'assistant', time: { created: 1, completed: 2 } }, + }, + { type: 'session_status', data: { status: 'idle' } }, + ], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([ + { signalId: 'msg_1', kind: 'completed', messageExcerpt: 'Hello world' }, + ]); + }); + + it('finds an excerpt from parts ingested in an earlier call', async () => { + const sessionId = 'ses_attention_completed_0002'; + const stub = getStub(kiloUserId, sessionId); + + await stub.ingest( + [ + { type: 'part', data: { id: 'part_1', messageID: 'msg_1', type: 'text', text: 'Done!' } }, + { type: 'session_status', data: { status: 'busy' } }, + ], + kiloUserId, + sessionId, + 1 + ); + const result = await stub.ingest( + [ + { + type: 'message', + data: { id: 'msg_1', role: 'assistant', time: { created: 1, completed: 2 } }, + }, + { type: 'session_status', data: { status: 'idle' } }, + ], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([ + { signalId: 'msg_1', kind: 'completed', messageExcerpt: 'Done!' }, + ]); + }); + + it('truncates a long excerpt to a push-sized snippet', async () => { + const sessionId = 'ses_attention_truncated_0001'; + const stub = getStub(kiloUserId, sessionId); + await ingestBusyStatus(stub, sessionId); + + const longText = `First line.\n\n${'x'.repeat(200)}`; + const result = await stub.ingest( + [ + { + type: 'part', + data: { id: 'part_1', messageID: 'msg_1', type: 'text', text: longText }, + }, + { + type: 'message', + data: { id: 'msg_1', role: 'assistant', time: { created: 1, completed: 2 } }, + }, + { type: 'session_status', data: { status: 'idle' } }, + ], + kiloUserId, + sessionId, + 1 + ); + + const excerpt = result.attentionSignals[0]?.messageExcerpt; + expect(excerpt).toHaveLength(100); + expect(excerpt).toMatch(/^First line\. x+\.\.\.$/); + }); + + it('does not emit a completed signal when the assistant message has not finished', async () => { + const sessionId = 'ses_attention_incomplete_001'; + const stub = getStub(kiloUserId, sessionId); + await ingestBusyStatus(stub, sessionId); + + const result = await stub.ingest( + [ + { type: 'message', data: { id: 'msg_1', role: 'assistant', time: { created: 1 } } }, + { type: 'session_status', data: { status: 'idle' } }, + ], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([]); + }); + + it('does not emit a completed signal when only a user message has completed', async () => { + const sessionId = 'ses_attention_user_msg_0001'; + const stub = getStub(kiloUserId, sessionId); + await ingestBusyStatus(stub, sessionId); + + const result = await stub.ingest( + [ + { + type: 'message', + data: { id: 'msg_1', role: 'user', time: { created: 1, completed: 2 } }, + }, + { type: 'session_status', data: { status: 'idle' } }, + ], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([]); + }); + + it("does not emit a completed signal on the session's first reported status", async () => { + const sessionId = 'ses_attention_first_status_1'; + const stub = getStub(kiloUserId, sessionId); + + // A full-history backfill of an already-idle session must not push about an old turn. + const result = await stub.ingest( + [ + { type: 'part', data: { id: 'part_1', messageID: 'msg_1', type: 'text', text: 'Old' } }, + { + type: 'message', + data: { id: 'msg_1', role: 'assistant', time: { created: 1, completed: 2 } }, + }, + { type: 'session_status', data: { status: 'idle' } }, + ], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([]); + }); + + it('emits a needs-input signal when status becomes question', async () => { + const sessionId = 'ses_attention_question_0001'; + const stub = getStub(kiloUserId, sessionId); + + const result = await stub.ingest( + [{ type: 'session_status', data: { status: 'question' } }], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toHaveLength(1); + expect(result.attentionSignals[0]).toMatchObject({ kind: 'needs_input' }); + }); + + it('emits a needs-input signal when status becomes permission', async () => { + const sessionId = 'ses_attention_permission_001'; + const stub = getStub(kiloUserId, sessionId); + + const result = await stub.ingest( + [{ type: 'session_status', data: { status: 'permission' } }], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toHaveLength(1); + expect(result.attentionSignals[0]).toMatchObject({ kind: 'needs_input' }); + }); + + it('does not emit a completed signal when the session has no messages at all', async () => { + const sessionId = 'ses_attention_idle_0000001'; + const stub = getStub(kiloUserId, sessionId); + + await stub.ingest( + [{ type: 'session_status', data: { status: 'busy' } }], + kiloUserId, + sessionId, + 1 + ); + const result = await stub.ingest( + [{ type: 'session_status', data: { status: 'idle' } }], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([]); + }); + + it('does not re-emit a needs-input signal when the status is unchanged', async () => { + const sessionId = 'ses_attention_repeat_000001'; + const stub = getStub(kiloUserId, sessionId); + + await stub.ingest( + [{ type: 'session_status', data: { status: 'question' } }], + kiloUserId, + sessionId, + 1 + ); + const result = await stub.ingest( + [{ type: 'session_status', data: { status: 'question' } }], + kiloUserId, + sessionId, + 1 + ); + + expect(result.attentionSignals).toEqual([]); + }); + }); + describe('export produces valid JSON', () => { it('returns valid JSON from getAllStream even with no items', async () => { const sessionId = 'ses_export_empty_0000000007'; diff --git a/services/session-ingest/worker-configuration.d.ts b/services/session-ingest/worker-configuration.d.ts index adf2717c40..aa2b33b97a 100644 --- a/services/session-ingest/worker-configuration.d.ts +++ b/services/session-ingest/worker-configuration.d.ts @@ -33,4 +33,4 @@ declare namespace NodeJS { declare module "*.sql" { const value: string; export default value; -} \ No newline at end of file +} From f21ad9873902662fe3f0a31018f137d2a7e76cec Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 14 Jul 2026 20:38:14 +0200 Subject: [PATCH 2/3] fix(event-service): type CORS environment --- services/event-service/src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index d30976c7b6..c48fccf757 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -25,11 +25,14 @@ function allowedOrigin(origin: string, env: { WORKER_ENV?: string }): string | n return null; } -app.use('/connect/*', cors({ origin: (origin, c) => allowedOrigin(origin, c.env) })); +app.use( + '/connect/*', + cors({ origin: (origin, c) => allowedOrigin(origin, c.env as Pick) }) +); app.use( '/connect-ticket', cors({ - origin: (origin, c) => allowedOrigin(origin, c.env), + origin: (origin, c) => allowedOrigin(origin, c.env as Pick), allowMethods: ['POST', 'OPTIONS'], allowHeaders: ['Authorization'], }) From 6e10e1728c8d3ac4f2a69b26184260a3bb146299 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 14 Jul 2026 20:53:07 +0200 Subject: [PATCH 3/3] test(cloud-agent-next): expect viewing suppression flag --- .../test/integration/session/push-notifications.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/cloud-agent-next/test/integration/session/push-notifications.test.ts b/services/cloud-agent-next/test/integration/session/push-notifications.test.ts index 4da25b07cf..1e7e754a85 100644 --- a/services/cloud-agent-next/test/integration/session/push-notifications.test.ts +++ b/services/cloud-agent-next/test/integration/session/push-notifications.test.ts @@ -152,6 +152,7 @@ describe('CloudAgentSession push notification producer', () => { executionId: COMPLETED_MESSAGE_ID, status: 'completed', body: 'Assistant finished the task.', + suppressIfViewingSession: true, }, ]); }); @@ -180,6 +181,7 @@ describe('CloudAgentSession push notification producer', () => { executionId: FAILED_MESSAGE_ID, status: 'failed', body: 'Failed: The message failed', + suppressIfViewingSession: true, }, ]); }); @@ -210,6 +212,7 @@ describe('CloudAgentSession push notification producer', () => { executionId: INTERRUPTED_MESSAGE_ID, status: 'interrupted', body: 'Interrupted: Task interrupted', + suppressIfViewingSession: true, }, ]); }); @@ -247,6 +250,7 @@ describe('CloudAgentSession push notification producer', () => { executionId: RETRY_MESSAGE_ID, status: 'failed', body: 'Failed: The message failed', + suppressIfViewingSession: true, }, { userId, @@ -254,6 +258,7 @@ describe('CloudAgentSession push notification producer', () => { executionId: RETRY_MESSAGE_ID, status: 'failed', body: 'Failed: The message failed', + suppressIfViewingSession: true, }, ]); });