From 39fba5292c525814b7f4dbae2ce18254dddb2480 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Tue, 2 Jun 2026 13:08:32 +0800 Subject: [PATCH] fix: add Telegram notification context (#768) --- hub/src/telegram/bot.ts | 32 +++++++-- hub/src/telegram/sessionView.test.ts | 100 +++++++++++++++++++++++++++ hub/src/telegram/sessionView.ts | 98 ++++++++++++++++++++++++-- 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 hub/src/telegram/sessionView.test.ts diff --git a/hub/src/telegram/bot.ts b/hub/src/telegram/bot.ts index 637975a9..cddbe152 100644 --- a/hub/src/telegram/bot.ts +++ b/hub/src/telegram/bot.ts @@ -6,9 +6,9 @@ */ import { Bot, Context, InlineKeyboard } from 'grammy' -import { SyncEngine, Session } from '../sync/syncEngine' +import { SyncEngine, Session, type Machine } from '../sync/syncEngine' import { handleCallback, CallbackContext } from './callbacks' -import { formatSessionNotification, createNotificationKeyboard } from './sessionView' +import { formatReadyNotification, formatSessionNotification, createNotificationKeyboard } from './sessionView' import { getAgentName } from '../notifications/sessionInfo' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' import type { Store } from '../store' @@ -184,6 +184,28 @@ export class HappyBot implements NotificationChannel { return stored?.namespace ?? null } + private getSessionMachine(session: Session): Machine | undefined { + if (!this.syncEngine) { + return undefined + } + + const machineId = session.metadata?.machineId + if (machineId) { + const machine = this.syncEngine.getMachineByNamespace(machineId, session.namespace) + if (machine) { + return machine + } + } + + const host = session.metadata?.host + if (!host) { + return undefined + } + + return this.syncEngine.getMachinesByNamespace(session.namespace) + .find((machine) => machine.metadata?.host === host) + } + /** * Send a notification when agent is ready for input. */ @@ -192,7 +214,7 @@ export class HappyBot implements NotificationChannel { return } - const agentName = getAgentName(session) + const text = formatReadyNotification(session, this.getSessionMachine(session)) const url = buildMiniAppDeepLink(this.publicUrl, `session_${session.id}`) const keyboard = new InlineKeyboard() .webApp('Open Session', url) @@ -206,7 +228,7 @@ export class HappyBot implements NotificationChannel { try { await this.bot.api.sendMessage( chatId, - `It's ready!\n\n${agentName} is waiting for your command`, + text, { reply_markup: keyboard } ) } catch (error) { @@ -223,7 +245,7 @@ export class HappyBot implements NotificationChannel { return } - const text = formatSessionNotification(session) + const text = formatSessionNotification(session, this.getSessionMachine(session)) const keyboard = createNotificationKeyboard(session, this.publicUrl) const chatIds = this.getBoundChatIds(session.namespace) diff --git a/hub/src/telegram/sessionView.test.ts b/hub/src/telegram/sessionView.test.ts new file mode 100644 index 00000000..befeea52 --- /dev/null +++ b/hub/src/telegram/sessionView.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'bun:test' +import type { Machine, Session } from '../sync/syncEngine' +import { formatReadyNotification, formatSessionNotification } from './sessionView' + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-1234567890', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { + path: '/home/alice/infra', + host: 'devbox.local', + homeDir: '/home/alice', + name: 'rotate HAPI secrets', + machineId: 'machine-1', + flavor: 'codex' + }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } +} + +function createMachine(overrides: Partial = {}): Machine { + return { + id: 'machine-1', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { + host: 'devbox.local', + platform: 'linux', + happyCliVersion: '0.1.0', + displayName: 'Work Laptop' + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, + ...overrides + } +} + +describe('Telegram session notifications', () => { + it('adds session, machine, and path context to ready notifications', () => { + expect(formatReadyNotification(createSession(), createMachine())).toBe([ + 'Ready: rotate HAPI secrets on Work Laptop', + '', + 'Codex is waiting for your command', + 'Session: rotate HAPI secrets', + 'Path: ~/infra' + ].join('\n')) + }) + + it('keeps the previous ready notification text when context is unavailable', () => { + const session = createSession({ metadata: null }) + + expect(formatReadyNotification(session)).toBe([ + "It's ready!", + '', + 'Agent is waiting for your command' + ].join('\n')) + }) + + it('adds session, machine, and path context to permission notifications without losing tool details', () => { + const session = createSession({ + agentState: { + requests: { + req1: { + tool: 'Bash', + arguments: { command: 'bun test' }, + createdAt: 1 + } + } + } + }) + + expect(formatSessionNotification(session, createMachine())).toBe([ + 'Action required: rotate HAPI secrets on Work Laptop', + '', + 'Codex requests permission', + 'Session: rotate HAPI secrets', + 'Path: ~/infra', + 'Tool: Bash', + 'Command: bun test' + ].join('\n')) + }) +}) diff --git a/hub/src/telegram/sessionView.ts b/hub/src/telegram/sessionView.ts index 391aae11..f61d5b6d 100644 --- a/hub/src/telegram/sessionView.ts +++ b/hub/src/telegram/sessionView.ts @@ -6,18 +6,51 @@ */ import { InlineKeyboard } from 'grammy' -import type { Session } from '../sync/syncEngine' +import type { Machine, Session } from '../sync/syncEngine' import { ACTIONS } from './callbacks' import { createCallbackData, truncate, getSessionName } from './renderer' +import { getAgentName } from '../notifications/sessionInfo' const MAX_TOOL_ARGS_LENGTH = 150 +type NotificationContext = { + hasContext: boolean + heading: string + details: string[] +} + +/** + * Format a compact notification when the agent is ready for input. + */ +export function formatReadyNotification(session: Session, machine?: Machine): string { + const agentName = getAgentName(session) + const context = buildNotificationContext(session, machine) + + if (!context.hasContext) { + return `It's ready!\n\n${agentName} is waiting for your command` + } + + return [ + `Ready: ${context.heading}`, + '', + `${agentName} is waiting for your command`, + ...context.details + ].join('\n') +} + /** * Format a compact session notification for permission requests */ -export function formatSessionNotification(session: Session): string { - const name = getSessionName(session) - const lines: string[] = ['Permission Request', '', `Session: ${name}`] +export function formatSessionNotification(session: Session, machine?: Machine): string { + const context = buildNotificationContext(session, machine) + const lines: string[] = context.hasContext + ? [ + `Action required: ${context.heading}`, + '', + `${getAgentName(session)} requests permission`, + ...context.details + ] + : ['Permission Request', '', `Session: ${getSessionName(session)}`] const requests = session.agentState?.requests if (requests) { @@ -35,6 +68,63 @@ export function formatSessionNotification(session: Session): string { return lines.join('\n') } +function buildNotificationContext(session: Session, machine?: Machine): NotificationContext { + const sessionName = getContextSessionName(session) + const machineName = getMachineName(session, machine) + const path = formatSessionPath(session) + const heading = formatHeading(sessionName, machineName) + const details: string[] = [] + + if (sessionName) { + details.push(`Session: ${sessionName}`) + } + if (path) { + details.push(`Path: ${path}`) + } + + return { + hasContext: Boolean(heading || details.length > 0), + heading: heading || getSessionName(session), + details + } +} + +function getContextSessionName(session: Session): string | null { + if (session.metadata?.name) return session.metadata.name + if (session.metadata?.summary?.text) return session.metadata.summary.text + if (session.metadata?.path) return getSessionName(session) + return null +} + +function getMachineName(session: Session, machine?: Machine): string | null { + const name = machine?.metadata?.displayName + ?? machine?.metadata?.host + ?? session.metadata?.host + ?? null + const trimmed = name?.trim() + return trimmed ? trimmed : null +} + +function formatHeading(sessionName: string | null, machineName: string | null): string { + if (sessionName && machineName) return `${sessionName} on ${machineName}` + if (sessionName) return sessionName + if (machineName) return machineName + return '' +} + +function formatSessionPath(session: Session): string | null { + const path = session.metadata?.path?.trim() + if (!path) return null + + const homeDir = session.metadata?.homeDir?.trim() + if (!homeDir) return path + if (path === homeDir) return '~' + if (path.startsWith(`${homeDir}/`)) { + return `~/${path.slice(homeDir.length + 1)}` + } + return path +} + /** * Create notification keyboard for quick actions */