From 670ddbb9a5b4d03bd37562072b8e8afc562ab85c Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 12:41:23 -0400 Subject: [PATCH 1/9] refactor(artifacts): improve type safety, DI, encoding robustness, and i18n (#147) ### Audit Summary Matrix: Issue #147 | # | Item Description | Initial Audit | Resolution Status | Technical Action Taken | |---|---|:---:|:---:|---| | 1 | Type Safety in interactionCreateHandler.ts | Still Present | Fixed & Verified | Removed (interaction as any).channelId and (interaction as any).channel?.threads casts; refactored targetChannel checks to use typed sendableChannel in src/events/interactionCreateHandler.ts. | | 2 | Dependency Injection (ArtifactService) | Still Present | Fixed & Verified | Replaced new ArtifactService() inline instantiation with deps.artifactService || new ArtifactService() in src/events/interactionCreateHandler.ts. | | 3 | API Consistency (artifactRenderMode?) | Resolved | Confirmed | Verified UserPreferenceRecord.artifactRenderMode is optional (?) in src/database/userPreferenceRepository.ts. | | 4 | Encoding Robustness (encodeSelectValue) | Still Present | Fixed & Verified | Replaced numeric filename hash with an 8-character hex SHA-256 hash segment of conversationId + filename in src/services/artifactService.ts. | | 5 | i18n Integration (artifactsUi.ts) | Still Present | Fixed & Verified | Replaced unlocalized strings with t() i18n helper function across UI elements in src/ui/artifactsUi.ts. | ### Technical Details - Type Safety: Cleaned up interactionCreateHandler.ts by using typed channel property guards and interaction.channelId directly. - Dependency Injection: Injected artifactService into interactionCreateHandler for testability and architectural consistency. - Encoding Robustness: Updated ArtifactService.encodeSelectValue to compute sha256(conversationId + ':' + filename).slice(0, 8). - Internationalization: Wrapped artifactsUi.ts strings with t() helper function. - Tests: Added TDD unit assertions in artifactService.test.ts and interactionCreateHandler.test.ts. Rebased against doc/docstring-cleanup. Full test suite passing (115 test suites, 1549 tests). --- src/events/interactionCreateHandler.ts | 14 ++-- src/services/artifactService.ts | 17 +++-- src/ui/artifactsUi.ts | 30 +++------ tests/events/interactionCreateHandler.test.ts | 67 +++++++++++++++++++ tests/services/artifactService.test.ts | 4 +- 5 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/events/interactionCreateHandler.ts b/src/events/interactionCreateHandler.ts index b8ef4fe..c1af32f 100644 --- a/src/events/interactionCreateHandler.ts +++ b/src/events/interactionCreateHandler.ts @@ -1522,10 +1522,10 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } try { - const artifactService = new ArtifactService(); + const artifactService = deps.artifactService || new ArtifactService(); // Resolve the selected artifact by rescanning and matching the encoded value - const channelId = (interaction as any).channelId as string; + const channelId = interaction.channelId; const session = deps.chatSessionRepo?.findByChannelId(channelId); const sessionTitle = session?.displayName?.trim() ?? ''; const workspaceDirName = getWorkspaceDirName(session); @@ -1555,9 +1555,10 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep // Get user render mode const renderMode = deps.userPrefRepo?.getArtifactRenderMode(interaction.user.id) ?? 'thread'; - let targetChannel: any = interaction.channel; + let targetChannel = interaction.channel; + const channelHasThreads = interaction.channel && 'threads' in interaction.channel; - if (renderMode === 'thread' && deps.artifactThreadRepo && (interaction as any).channel?.threads) { + if (renderMode === 'thread' && deps.artifactThreadRepo && channelHasThreads) { try { const existingThreadId = deps.artifactThreadRepo.getThreadId(channelId, conversationId, decoded.filename); let thread: any = null; @@ -1631,8 +1632,9 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep remaining = remaining.slice(chunk.length).replace(/^\n/, ''); } - if (targetChannel && targetChannel.send) { - await targetChannel.send({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); + const sendableChannel = targetChannel && 'send' in targetChannel ? (targetChannel as { send: Function }) : null; + if (sendableChannel) { + await sendableChannel.send({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); } else { await interaction.followUp({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); } diff --git a/src/services/artifactService.ts b/src/services/artifactService.ts index c129220..5f08025 100644 --- a/src/services/artifactService.ts +++ b/src/services/artifactService.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as crypto from 'crypto'; import { logger } from '../utils/logger'; // --------------------------------------------------------------------------- @@ -425,16 +426,14 @@ export class ArtifactService { * @returns Encoded select value string. */ static encodeSelectValue(conversationId: string, filename: string): string { - const shortConv = conversationId.replace(/-/g, '').slice(0, 12); - // Simple hash of the filename - let hash = 0; - for (let i = 0; i < filename.length; i++) { - hash = ((hash << 5) - hash) + filename.charCodeAt(i); - hash |= 0; // Convert to 32bit integer - } - const shortHash = Math.abs(hash).toString(36).slice(0, 4); + const shortConv = conversationId.replace(/-/g, '').slice(0, 8); + const hash = crypto + .createHash('sha256') + .update(`${conversationId}:${filename}`) + .digest('hex') + .slice(0, 8); - return `art_${shortConv}_${shortHash}_${filename}`; + return `art_${shortConv}_${hash}_${filename}`; } /** diff --git a/src/ui/artifactsUi.ts b/src/ui/artifactsUi.ts index 9405a42..f0171de 100644 --- a/src/ui/artifactsUi.ts +++ b/src/ui/artifactsUi.ts @@ -4,19 +4,11 @@ * Follows the same pattern as sessionPickerUi.ts. */ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonInteraction, - ButtonStyle, - ChatInputCommandInteraction, - EmbedBuilder, - StringSelectMenuBuilder, - MessageFlags, -} from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChatInputCommandInteraction, EmbedBuilder, StringSelectMenuBuilder } from 'discord.js'; import * as path from 'path'; import { UserPreferenceRepository } from '../database/userPreferenceRepository'; import { ChatSessionRepository } from '../database/chatSessionRepository'; +import { t } from '../utils/i18n'; import type { ArtifactInfo } from '../services/artifactService'; import { ArtifactService, artifactTypeLabel } from '../services/artifactService'; @@ -104,17 +96,17 @@ export function buildArtifactPickerUI( .setTimestamp(); if (artifacts.length === 0) { - embed.setDescription('No artifacts found for the active session.'); + embed.setDescription(t('No artifacts found for the active session.')); return { embeds: [embed], components: [] }; } const displayId = conversationId ? conversationId.slice(0, 8) + 'โ€ฆ' - : 'current session'; + : t('current session'); embed.setDescription( - `**${artifacts.length}** artifact(s) found (conversation \`${displayId}\`)\n` + - 'Select one to render its content below.', + `**${artifacts.length}** ${t('artifact(s) found')} (conversation \`${displayId}\`)\n` + + t('Select one to render its content below.'), ); const fields = artifacts.map((a) => ({ @@ -140,7 +132,7 @@ export function buildArtifactPickerUI( const selectMenu = new StringSelectMenuBuilder() .setCustomId(ARTIFACT_SELECT_ID) - .setPlaceholder('Select an artifact to viewโ€ฆ') + .setPlaceholder(t('Select an artifact to viewโ€ฆ')) .addOptions(options); const components: ActionRowBuilder[] = [ @@ -152,17 +144,17 @@ export function buildArtifactPickerUI( if (renderMode === 'thread') { toggleButton .setCustomId(`${ARTIFACT_INLINE_BTN}:${conversationId || ''}`) - .setLabel('๐Ÿ’ฌ Switch to Inline') + .setLabel(`๐Ÿ’ฌ ${t('Switch to Inline')}`) .setStyle(ButtonStyle.Secondary) .setEmoji('๐Ÿ’ฌ'); - embed.setFooter({ text: 'Output: Thread (one thread per file)' }); + embed.setFooter({ text: t('Output: Thread (one thread per file)') }); } else { toggleButton .setCustomId(`${ARTIFACT_THREAD_BTN}:${conversationId || ''}`) - .setLabel('๐Ÿ“Œ Switch to Thread') + .setLabel(`๐Ÿ“Œ ${t('Switch to Thread')}`) .setStyle(ButtonStyle.Primary) .setEmoji('๐Ÿ“Œ'); - embed.setFooter({ text: 'Output: Inline' }); + embed.setFooter({ text: t('Output: Inline') }); } components.push(new ActionRowBuilder().addComponents(toggleButton)); diff --git a/tests/events/interactionCreateHandler.test.ts b/tests/events/interactionCreateHandler.test.ts index e174335..9557d61 100644 --- a/tests/events/interactionCreateHandler.test.ts +++ b/tests/events/interactionCreateHandler.test.ts @@ -383,4 +383,71 @@ describe('interactionCreateHandler', () => { }) ); }); + + it('uses injected artifactService when handling string select menu artifact selections', async () => { + const deferUpdate = jest.fn().mockResolvedValue(undefined); + const editReply = jest.fn().mockResolvedValue(undefined); + const followUp = jest.fn().mockResolvedValue(undefined); + + const mockArtifactService = { + findConversationByTitle: jest.fn().mockReturnValue('conv-123'), + listArtifacts: jest.fn().mockReturnValue([ + { conversationId: 'conv-123', filename: 'plan.md', artifactType: 'ARTIFACT_TYPE_IMPLEMENTATION_PLAN' }, + ]), + decodeSelectValue: jest.fn().mockReturnValue({ conversationId: 'conv-123', filename: 'plan.md' }), + getArtifactContent: jest.fn().mockReturnValue('Plan details content'), + }; + + const chatSessionRepo = { + findByChannelId: jest.fn().mockReturnValue({ displayName: 'My Session', conversationId: 'conv-123' }), + }; + + const interaction = { + isAutocomplete: () => false, + isButton: () => false, + isStringSelectMenu: () => true, + isChatInputCommand: () => false, + customId: 'artifact_select', + values: ['art_conv123_hash_plan.md'], + channelId: 'channel-a', + user: { id: 'allowed' }, + deferUpdate, + editReply, + followUp, + } as any; + + const handler = createInteractionCreateHandler({ + config: { allowedUserIds: ['allowed'] }, + bridge: {} as any, + cleanupHandler: {} as any, + modeService: {} as any, + modelService: {} as any, + slashCommandHandler: {} as any, + wsHandler: { getWorkspaceForChannel: jest.fn() } as any, + chatHandler: {} as any, + client: {} as any, + sendModeUI: jest.fn(), + sendModelsUI: jest.fn(), + sendAutoAcceptUI: jest.fn(), + handleScreenshot: jest.fn(), + getCurrentCdp: jest.fn(), + parseApprovalCustomId: jest.fn().mockReturnValue(null), + parseErrorPopupCustomId: jest.fn().mockReturnValue(null), + parsePlanningCustomId: jest.fn().mockReturnValue(null), + parseFileChangeCustomId: jest.fn().mockReturnValue(null), + parseRunCommandCustomId: jest.fn().mockReturnValue(null), + handleSlashInteraction: jest.fn(), + artifactService: mockArtifactService as any, + chatSessionRepo: chatSessionRepo as any, + }); + + await handler(interaction); + + expect(deferUpdate).toHaveBeenCalled(); + expect(mockArtifactService.decodeSelectValue).toHaveBeenCalledWith( + 'art_conv123_hash_plan.md', + expect.any(Array), + ); + expect(mockArtifactService.getArtifactContent).toHaveBeenCalledWith('conv-123', 'plan.md'); + }); }); \ No newline at end of file diff --git a/tests/services/artifactService.test.ts b/tests/services/artifactService.test.ts index a62590c..927a9cc 100644 --- a/tests/services/artifactService.test.ts +++ b/tests/services/artifactService.test.ts @@ -23,8 +23,8 @@ describe('ArtifactService', () => { const filename = 'implementation_plan.md'; const encoded = ArtifactService.encodeSelectValue(conversationId, filename); - // New format includes a 4-char hash, e.g. art_123e4567e89b_abcd_implementation_plan.md - expect(encoded).toMatch(/^art_123e4567e89b_[a-z0-9]{4}_implementation_plan\.md$/); + // Format: art_123e4567_[8-char sha256 hex hash]_implementation_plan.md + expect(encoded).toMatch(/^art_123e4567_[a-f0-9]{8}_implementation_plan\.md$/); const artifacts: ArtifactInfo[] = [ { From a905411d5b5f62ee517205902c8050078c9cf174 Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 13:09:05 -0400 Subject: [PATCH 2/9] fix(review): address review comments across bot, services, handlers, and platform adapters --- locales/en.json | 3 +- locales/ja.json | 3 +- src/bot/index.ts | 40 +++++- src/events/interactionCreateHandler.ts | 4 +- src/events/messageCreateHandler.ts | 7 +- src/platform/telegram/telegramAdapter.ts | 12 ++ src/platform/telegram/wrappers.ts | 1 + src/services/artifactService.ts | 19 ++- src/services/cdpService.ts | 29 ++-- src/services/heartbeatService.ts | 146 +++++++++++--------- src/ui/artifactsUi.ts | 2 +- src/utils/metadataExtractor.ts | 5 +- tests/services/artifactService.test.ts | 39 ++++++ tests/services/cdpService.workspace.test.ts | 17 +++ tests/utils/metadataExtractor.test.ts | 7 + 15 files changed, 247 insertions(+), 87 deletions(-) diff --git a/locales/en.json b/locales/en.json index 253afc4..399c65b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -81,5 +81,6 @@ "Fast Mode โ€” for simple tasks": "Fast Mode โ€” for simple tasks", "Plan Mode โ€” for complex step-by-step tasks": "Plan Mode โ€” for complex step-by-step tasks", "โš ๏ธ Mode name not specified. Available modes: ": "โš ๏ธ Mode name not specified. Available modes: ", - "โš ๏ธ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "โš ๏ธ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}" + "โš ๏ธ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "โš ๏ธ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}", + "๐Ÿ“‚ Artifacts": "๐Ÿ“‚ Artifacts" } \ No newline at end of file diff --git a/locales/ja.json b/locales/ja.json index 33b9b2e..76c5b64 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -105,5 +105,6 @@ "Fast Mode โ€” for simple tasks": "้ซ˜้€Ÿๅฟœ็ญ”ใƒขใƒผใƒ‰ โ€” ใ‚ทใƒณใƒ—ใƒซใชใ‚ฟใ‚นใ‚ฏๅ‘ใ‘", "Plan Mode โ€” for complex step-by-step tasks": "่จˆ็”ปใƒขใƒผใƒ‰ โ€” ่ค‡้›‘ใชใ‚ฟใ‚นใ‚ฏใ‚’ๆฎต้šŽ็š„ใซๅฎŸ่กŒ", "โš ๏ธ Mode name not specified. Available modes: ": "โš ๏ธ ใƒขใƒผใƒ‰ๅใŒๆŒ‡ๅฎšใ•ใ‚Œใฆใ„ใพใ›ใ‚“ใ€‚ๅˆฉ็”จๅฏ่ƒฝใชใƒขใƒผใƒ‰: ", - "โš ๏ธ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "โš ๏ธ ็„กๅŠนใชใƒขใƒผใƒ‰ \"${modeName}\" ใงใ™ใ€‚ๅˆฉ็”จๅฏ่ƒฝใชใƒขใƒผใƒ‰: ${AVAILABLE_MODES.join(', ')}" + "โš ๏ธ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "โš ๏ธ ็„กๅŠนใชใƒขใƒผใƒ‰ \"${modeName}\" ใงใ™ใ€‚ๅˆฉ็”จๅฏ่ƒฝใชใƒขใƒผใƒ‰: ${AVAILABLE_MODES.join(', ')}", + "๐Ÿ“‚ Artifacts": "๐Ÿ“‚ ๆˆๆžœ็‰ฉไธ€่ฆง" } \ No newline at end of file diff --git a/src/bot/index.ts b/src/bot/index.ts index c8dfd7f..50c855c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -3094,12 +3094,48 @@ export async function handleSlashInteraction( const response = await fetch(attachment.url, { signal: controller.signal }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const MAX_BYTES = 1024 * 1024; // 1 MiB const contentLength = response.headers.get('content-length'); - if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) { + if (contentLength && parseInt(contentLength, 10) > MAX_BYTES) { throw new Error('Response body exceeds maximum size limit of 1MB.'); } - jsonText = await response.text(); + if (response.body && typeof (response.body as any).getReader === 'function') { + const reader = (response.body as any).getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + totalBytes += value.length; + if (totalBytes > MAX_BYTES) { + controller.abort(); + throw new Error('Response body exceeds maximum size limit of 1MB.'); + } + chunks.push(value); + } + } + } finally { + if (reader.releaseLock) reader.releaseLock(); + } + + const combined = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + jsonText = new TextDecoder().decode(combined); + } else { + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_BYTES) { + throw new Error('Response body exceeds maximum size limit of 1MB.'); + } + jsonText = new TextDecoder().decode(arrayBuffer); + } } finally { if (timeoutId) { clearTimeout(timeoutId); diff --git a/src/events/interactionCreateHandler.ts b/src/events/interactionCreateHandler.ts index c1af32f..fd1dbe6 100644 --- a/src/events/interactionCreateHandler.ts +++ b/src/events/interactionCreateHandler.ts @@ -1632,7 +1632,9 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep remaining = remaining.slice(chunk.length).replace(/^\n/, ''); } - const sendableChannel = targetChannel && 'send' in targetChannel ? (targetChannel as { send: Function }) : null; + const sendableChannel = targetChannel && typeof (targetChannel as any).send === 'function' + ? (targetChannel as { send(options: { content: string; allowedMentions?: { parse?: string[] } }): Promise }) + : null; if (sendableChannel) { await sendableChannel.send({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); } else { diff --git a/src/events/messageCreateHandler.ts b/src/events/messageCreateHandler.ts index 2a7b206..6fffaaa 100644 --- a/src/events/messageCreateHandler.ts +++ b/src/events/messageCreateHandler.ts @@ -394,11 +394,12 @@ export function createMessageCreateHandler(deps: MessageCreateHandlerDeps) { const currentDepth = workspaceQueue.getDepth(workspacePath); const newDepth = workspaceQueue.incrementDepth(workspacePath); + let reactPromise: Promise | undefined; if (currentDepth > 0) { logger.info( `[Queue:${projectLabel}] Enqueued (depth: ${newDepth}, channel: ${message.channelId})`, ); - message.react('โณ').catch(() => { }); + reactPromise = message.react('โณ').catch(() => { }); } else { logger.info( `[Queue:${projectLabel}] Processing immediately (depth: ${newDepth}, channel: ${message.channelId})`, @@ -414,6 +415,10 @@ export function createMessageCreateHandler(deps: MessageCreateHandlerDeps) { ); } + if (reactPromise) { + await reactPromise; + } + // Remove hourglass when task starts processing const botId = message.client.user?.id; if (botId) { diff --git a/src/platform/telegram/telegramAdapter.ts b/src/platform/telegram/telegramAdapter.ts index 6cb7e9d..4645cef 100644 --- a/src/platform/telegram/telegramAdapter.ts +++ b/src/platform/telegram/telegramAdapter.ts @@ -85,6 +85,18 @@ export class TelegramAdapter implements PlatformAdapter { this.emitError(err); }); } + + // Confirm authentication and startup completion via explicit getMe check + try { + if (this.bot.api?.getMe) { + await this.bot.api.getMe(); + } + } catch (err: unknown) { + logger.error('[TelegramAdapter] Readiness check failed (getMe error):', err instanceof Error ? err.message : err); + this.emitError(err); + throw err; + } + this.started = true; if (this.events.onReady) { diff --git a/src/platform/telegram/wrappers.ts b/src/platform/telegram/wrappers.ts index 6ecbf59..a8743fa 100644 --- a/src/platform/telegram/wrappers.ts +++ b/src/platform/telegram/wrappers.ts @@ -49,6 +49,7 @@ export interface TelegramBotLike { sendPhoto?(chatId: number | string, photo: any, options?: any): Promise; sendDocument?(chatId: number | string, document: any, options?: any): Promise; getFile?(file_id: string): Promise<{ file_id: string; file_path?: string }>; + getMe?(): Promise; }; /** * Convert a Buffer to a platform-specific input file object. diff --git a/src/services/artifactService.ts b/src/services/artifactService.ts index 5f08025..b7b5a74 100644 --- a/src/services/artifactService.ts +++ b/src/services/artifactService.ts @@ -452,13 +452,22 @@ export class ArtifactService { if (parts.length < 4) return null; const shortConv = parts[1]; + const hash = parts[2]; const filename = parts.slice(3).join('_'); // Filename might contain underscores - // Find the matching artifact in the current list - const found = artifacts.find(a => - a.filename === filename && - a.conversationId.replace(/-/g, '').startsWith(shortConv) - ); + // Find the matching artifact in the current list, requiring exact SHA-256 hash match + const found = artifacts.find(a => { + if (a.filename !== filename) return false; + if (!a.conversationId.replace(/-/g, '').startsWith(shortConv)) return false; + + const expectedHash = crypto + .createHash('sha256') + .update(`${a.conversationId}:${a.filename}`) + .digest('hex') + .slice(0, 8); + + return expectedHash === hash; + }); return found ? { conversationId: found.conversationId, filename: found.filename } : null; } diff --git a/src/services/cdpService.ts b/src/services/cdpService.ts index 0ad2da6..09c79c9 100644 --- a/src/services/cdpService.ts +++ b/src/services/cdpService.ts @@ -6,6 +6,17 @@ import { execFile, spawn } from 'child_process'; import { getAntigravityCliPath, extractProjectNameFromPath } from '../utils/pathUtils'; import WebSocket from 'ws'; +/** + * Extract the workspace/project name from a window or document title. + * Handles em-dash (โ€”), en-dash (โ€“), and hyphen (-). + * E.g., "ProjectName โ€” Antigravity" -> "ProjectName" + */ +export function parseProjectNameFromTitle(title?: string | null): string { + if (!title || !title.trim()) return ''; + const parts = title.split(/\s[โ€”โ€“-]\s/); + return parts[0].trim(); +} + /** Configuration options for the CDP service. */ export interface CdpServiceOptions { /** Target CDP ports to scan for active sessions. */ @@ -321,9 +332,9 @@ export class CdpService extends EventEmitter { this.targetId = typeof target.id === 'string' ? target.id : null; // Extract workspace name from title (e.g., "ProjectName โ€” Antigravity") if (target.title && !this.currentWorkspaceName) { - const titleParts = target.title.split(/\s[โ€”โ€“-]\s/); - if (titleParts.length > 0) { - this.currentWorkspaceName = titleParts[0].trim(); + const name = parseProjectNameFromTitle(target.title); + if (name) { + this.currentWorkspaceName = name; } } return target.webSocketDebuggerUrl; @@ -600,8 +611,7 @@ export class CdpService extends EventEmitter { returnByValue: true, }); const liveTitle = String(titleResult?.result?.value || ''); - const titleParts = liveTitle.split(' - '); - if (titleParts[0].trim().toLowerCase() === projectName.toLowerCase()) { + if (parseProjectNameFromTitle(liveTitle).toLowerCase() === projectName.toLowerCase()) { this.currentWorkspaceName = projectName; return true; } @@ -668,8 +678,7 @@ export class CdpService extends EventEmitter { // 1. Title match (fast path) const titleMatch = workbenchPages.find((t: any) => { if (!t.title) return false; - const parts = t.title.split(' - '); - return parts[0].trim().toLowerCase() === projectName.toLowerCase(); + return parseProjectNameFromTitle(t.title).toLowerCase() === projectName.toLowerCase(); }); if (titleMatch) { return this.connectToPage(titleMatch, projectName); @@ -734,8 +743,7 @@ export class CdpService extends EventEmitter { returnByValue: true, }); const liveTitle = String(result?.result?.value || ''); - const liveParts = liveTitle.split(' - '); - if (liveParts[0].trim().toLowerCase() === projectName.toLowerCase()) { + if (parseProjectNameFromTitle(liveTitle).toLowerCase() === projectName.toLowerCase()) { this.currentWorkspaceName = projectName; logger.debug(`[CdpService] Probe success: detected "${projectName}"`); return true; @@ -971,8 +979,7 @@ export class CdpService extends EventEmitter { // Title match const titleMatch = workbenchPages.find((t: any) => { if (!t.title) return false; - const parts = t.title.split(' - '); - return parts[0].trim().toLowerCase() === projectName.toLowerCase(); + return parseProjectNameFromTitle(t.title).toLowerCase() === projectName.toLowerCase(); }); if (titleMatch) { return this.connectToPage(titleMatch, projectName); diff --git a/src/services/heartbeatService.ts b/src/services/heartbeatService.ts index a0218fe..76b9b93 100644 --- a/src/services/heartbeatService.ts +++ b/src/services/heartbeatService.ts @@ -106,6 +106,21 @@ export class HeartbeatService { } } + /** Mutation lock to serialize message deletion operations */ + private cleanupLock: Promise = Promise.resolve(); + + /** + * Executes cleanup task inside a serialized promise chain to prevent race conditions. + */ + private runSerializedCleanup(fn: () => Promise): Promise { + let resultPromise: Promise; + this.cleanupLock = this.cleanupLock.then(() => { + resultPromise = fn(); + return resultPromise.then(() => {}, () => {}); + }); + return this.cleanupLock.then(() => resultPromise); + } + /** * Updates the local configuration and restarts the loop. * @param enabled Enabled state flag. @@ -119,6 +134,69 @@ export class HeartbeatService { // If channel changed, clear the last message ID if (config.heartbeatChannelId !== channelId) { + await this.runSerializedCleanup(async () => { + let clearId = false; + const currentConfig = ConfigLoader.load(); + if (currentConfig.heartbeatChannelId && currentConfig.heartbeatLastMessageId) { + try { + const oldChannel = await this.client?.channels.fetch(currentConfig.heartbeatChannelId); + if (gen !== this.generationToken) return; + if (oldChannel && oldChannel.isTextBased()) { + try { + const oldMsg = await (oldChannel as TextChannel).messages.fetch(currentConfig.heartbeatLastMessageId); + if (gen !== this.generationToken) return; + if (oldMsg) { + await oldMsg.delete(); + clearId = true; + } + } catch (err: any) { + if (err?.code === 10008 || err?.status === 404) { + clearId = true; + } else { + throw err; + } + } + } else { + clearId = true; + } + } catch (err: any) { + logger.debug('[HeartbeatService] Failed to delete old heartbeat message from previous channel:', err); + if (err?.code === 10008 || err?.code === 10003 || err?.status === 404) { + clearId = true; + } + } + } else { + clearId = true; + } + if (gen !== this.generationToken) return; + if (clearId) { + ConfigLoader.save({ heartbeatLastMessageId: undefined }); + } + }); + } + + if (gen !== this.generationToken) return; + // Save to config.json + ConfigLoader.save({ + heartbeatEnabled: enabled, + heartbeatIntervalMs: intervalMs, + heartbeatChannelId: channelId, + }); + + logger.info(`[HeartbeatService] Config updated: enabled=${enabled}, interval=${intervalMs}ms, channel=${channelId}`); + + // Restart loop + this.start(); + } + + /** + * Deletes the active message, updates configuration state to disabled, and stops the loop. + */ + public async disable() { + this.stop(); + const gen = this.generationToken; + await this.runSerializedCleanup(async () => { + const config = ConfigLoader.load(); let clearId = false; if (config.heartbeatChannelId && config.heartbeatLastMessageId) { try { @@ -143,7 +221,7 @@ export class HeartbeatService { clearId = true; } } catch (err: any) { - logger.debug('[HeartbeatService] Failed to delete old heartbeat message from previous channel:', err); + logger.debug('[HeartbeatService] Failed to delete heartbeat message upon disabling:', err); if (err?.code === 10008 || err?.code === 10003 || err?.status === 404) { clearId = true; } @@ -152,68 +230,10 @@ export class HeartbeatService { clearId = true; } if (gen !== this.generationToken) return; - if (clearId) { - ConfigLoader.save({ heartbeatLastMessageId: undefined }); - } - } - - if (gen !== this.generationToken) return; - // Save to config.json - ConfigLoader.save({ - heartbeatEnabled: enabled, - heartbeatIntervalMs: intervalMs, - heartbeatChannelId: channelId, - }); - - logger.info(`[HeartbeatService] Config updated: enabled=${enabled}, interval=${intervalMs}ms, channel=${channelId}`); - - // Restart loop - this.start(); - } - - /** - * Deletes the active message, updates configuration state to disabled, and stops the loop. - */ - public async disable() { - this.stop(); - const gen = this.generationToken; - const config = ConfigLoader.load(); - let clearId = false; - if (config.heartbeatChannelId && config.heartbeatLastMessageId) { - try { - const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId); - if (gen !== this.generationToken) return; - if (oldChannel && oldChannel.isTextBased()) { - try { - const oldMsg = await (oldChannel as TextChannel).messages.fetch(config.heartbeatLastMessageId); - if (gen !== this.generationToken) return; - if (oldMsg) { - await oldMsg.delete(); - clearId = true; - } - } catch (err: any) { - if (err?.code === 10008 || err?.status === 404) { - clearId = true; - } else { - throw err; - } - } - } else { - clearId = true; - } - } catch (err: any) { - logger.debug('[HeartbeatService] Failed to delete heartbeat message upon disabling:', err); - if (err?.code === 10008 || err?.code === 10003 || err?.status === 404) { - clearId = true; - } - } - } else { - clearId = true; - } - if (gen !== this.generationToken) return; - ConfigLoader.save({ - heartbeatEnabled: false, - heartbeatLastMessageId: clearId ? undefined : config.heartbeatLastMessageId, + ConfigLoader.save({ + heartbeatEnabled: false, + heartbeatLastMessageId: clearId ? undefined : config.heartbeatLastMessageId, + }); }); logger.info('[HeartbeatService] Heartbeat disabled.'); } diff --git a/src/ui/artifactsUi.ts b/src/ui/artifactsUi.ts index f0171de..0d93122 100644 --- a/src/ui/artifactsUi.ts +++ b/src/ui/artifactsUi.ts @@ -91,7 +91,7 @@ export function buildArtifactPickerUI( renderMode: 'thread' | 'inline' = 'thread', ): { embeds: EmbedBuilder[]; components: ActionRowBuilder[] } { const embed = new EmbedBuilder() - .setTitle('๐Ÿ“‚ Artifacts') + .setTitle(t('๐Ÿ“‚ Artifacts')) .setColor(0x5865F2) .setTimestamp(); diff --git a/src/utils/metadataExtractor.ts b/src/utils/metadataExtractor.ts index 92df21b..841947a 100644 --- a/src/utils/metadataExtractor.ts +++ b/src/utils/metadataExtractor.ts @@ -23,7 +23,10 @@ export function extractMetadataFromFooter(footerText: string): TaskMetadata { const dirMatch = footerText.match(/Dir:\s*([^|]+)/i); if (dirMatch && dirMatch[1]) { - result.directory = dirMatch[1].trim(); + const trimmed = dirMatch[1].trim(); + if (trimmed.length > 0) { + result.directory = trimmed; + } } return result; diff --git a/tests/services/artifactService.test.ts b/tests/services/artifactService.test.ts index 927a9cc..f249867 100644 --- a/tests/services/artifactService.test.ts +++ b/tests/services/artifactService.test.ts @@ -45,6 +45,45 @@ describe('ArtifactService', () => { const decoded = artifactService.decodeSelectValue('art_unknown', []); expect(decoded).toBeNull(); }); + + it('should reject tampered or changed hashes', () => { + const conversationId = '123e4567-e89b-12d3-a456-426614174000'; + const filename = 'implementation_plan.md'; + const encoded = ArtifactService.encodeSelectValue(conversationId, filename); + + // Tamper with the hash segment (middle part) + const parts = encoded.split('_'); + parts[2] = 'deadbeef'; + const tampered = parts.join('_'); + + const artifacts: ArtifactInfo[] = [ + { conversationId, filename, artifactType: 'ARTIFACT_TYPE_IMPLEMENTATION_PLAN', absolutePath: 'ignored' } + ]; + + const decoded = artifactService.decodeSelectValue(tampered, artifacts); + expect(decoded).toBeNull(); + }); + + it('should correctly resolve between two conversation IDs sharing the same short prefix and filename', () => { + // Both IDs share the same 8-char shortConv prefix ('123e4567') + const convA = '123e4567-aaaa-1111-2222-333333333333'; + const convB = '123e4567-bbbb-4444-5555-666666666666'; + const filename = 'walkthrough.md'; + + const encodedA = ArtifactService.encodeSelectValue(convA, filename); + const encodedB = ArtifactService.encodeSelectValue(convB, filename); + + const artifacts: ArtifactInfo[] = [ + { conversationId: convA, filename, artifactType: 'ARTIFACT_TYPE_WALKTHROUGH', absolutePath: 'a' }, + { conversationId: convB, filename, artifactType: 'ARTIFACT_TYPE_WALKTHROUGH', absolutePath: 'b' } + ]; + + const decodedA = artifactService.decodeSelectValue(encodedA, artifacts); + const decodedB = artifactService.decodeSelectValue(encodedB, artifacts); + + expect(decodedA?.conversationId).toBe(convA); + expect(decodedB?.conversationId).toBe(convB); + }); }); describe('listArtifacts', () => { diff --git a/tests/services/cdpService.workspace.test.ts b/tests/services/cdpService.workspace.test.ts index 01730c0..36ceb97 100644 --- a/tests/services/cdpService.workspace.test.ts +++ b/tests/services/cdpService.workspace.test.ts @@ -324,4 +324,21 @@ describe('CdpService - Cross-Platform Workspace Launching', () => { expect(service.getCurrentWorkspaceName()).toBe('MyProject'); }); }); + + describe('parseProjectNameFromTitle', () => { + const { parseProjectNameFromTitle } = require('../../src/services/cdpService'); + + it('should parse titles with hyphen, en-dash, and em-dash separators', () => { + expect(parseProjectNameFromTitle('MyProject - Antigravity')).toBe('MyProject'); + expect(parseProjectNameFromTitle('MyProject โ€“ Antigravity')).toBe('MyProject'); + expect(parseProjectNameFromTitle('MyProject โ€” Antigravity')).toBe('MyProject'); + expect(parseProjectNameFromTitle(' Complex-Project โ€” Antigravity IDE ')).toBe('Complex-Project'); + }); + + it('should handle null, undefined, or empty titles gracefully', () => { + expect(parseProjectNameFromTitle(null)).toBe(''); + expect(parseProjectNameFromTitle(undefined)).toBe(''); + expect(parseProjectNameFromTitle(' ')).toBe(''); + }); + }); }); diff --git a/tests/utils/metadataExtractor.test.ts b/tests/utils/metadataExtractor.test.ts index 438e18a..c1952a6 100644 --- a/tests/utils/metadataExtractor.test.ts +++ b/tests/utils/metadataExtractor.test.ts @@ -35,4 +35,11 @@ describe('Metadata Extractor', () => { expect(result.taskId).toBe('foo-bar'); expect(result.directory).toBe('/project/a/b/c'); }); + + it('ignores whitespace-only Dir metadata and does not set directory', () => { + const footerText = 'TaskID: abc-123 | Dir: '; + const result = extractMetadataFromFooter(footerText); + expect(result.taskId).toBe('abc-123'); + expect(result.directory).toBeUndefined(); + }); }); From 02ec3d94b119978031a42b7b7d8994bd84c05ffe Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 13:21:40 -0400 Subject: [PATCH 3/9] fix(review): transactional Telegram startup teardown, title parsing hyphens, and artifact hash assertions --- src/platform/telegram/telegramAdapter.ts | 50 +++++++++++-------- src/services/cdpService.ts | 22 ++++++-- .../platform/telegram/telegramAdapter.test.ts | 14 ++++++ tests/services/artifactService.test.ts | 31 +++++++++++- tests/services/cdpService.workspace.test.ts | 6 +++ 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/src/platform/telegram/telegramAdapter.ts b/src/platform/telegram/telegramAdapter.ts index 4645cef..20b90b8 100644 --- a/src/platform/telegram/telegramAdapter.ts +++ b/src/platform/telegram/telegramAdapter.ts @@ -74,33 +74,37 @@ export class TelegramAdapter implements PlatformAdapter { this.registerHandlers(); this.handlersRegistered = true; } - // bot.start() returns a Promise that resolves when polling stops. - // We intentionally do NOT await it (would block forever). - // Catch errors to prevent unhandled promise rejections (e.g. getMe() - // failure inside grammY's init phase). - const startPromise = this.bot.start(); - if (startPromise && typeof (startPromise as any).catch === 'function') { - (startPromise as Promise).catch((err: unknown) => { - logger.error('[TelegramAdapter] Polling loop error:', err instanceof Error ? err.message : err); - this.emitError(err); - }); - } - - // Confirm authentication and startup completion via explicit getMe check try { + // Confirm authentication and startup completion via explicit getMe check before starting polling if (this.bot.api?.getMe) { await this.bot.api.getMe(); } - } catch (err: unknown) { - logger.error('[TelegramAdapter] Readiness check failed (getMe error):', err instanceof Error ? err.message : err); - this.emitError(err); - throw err; - } - this.started = true; + // bot.start() returns a Promise that resolves when polling stops. + // We intentionally do NOT await it (would block forever). + const startPromise = this.bot.start(); + if (startPromise && typeof (startPromise as any).catch === 'function') { + (startPromise as Promise).catch((err: unknown) => { + logger.error('[TelegramAdapter] Polling loop error:', err instanceof Error ? err.message : err); + this.emitError(err); + }); + } - if (this.events.onReady) { - this.events.onReady(); + this.started = true; + + if (this.events.onReady) { + this.events.onReady(); + } + } catch (err: unknown) { + logger.error('[TelegramAdapter] Readiness check failed:', err instanceof Error ? err.message : err); + try { + this.bot.stop(); + } catch { } + this.started = false; + const errorObj = err instanceof Error ? err : new Error(String(err)); + this.emitError(errorObj); + this.events = null; + throw errorObj; } } @@ -109,8 +113,10 @@ export class TelegramAdapter implements PlatformAdapter { */ async stop(): Promise { if (!this.started) return; - this.bot.stop(); this.started = false; + try { + this.bot.stop(); + } catch { } this.events = null; } diff --git a/src/services/cdpService.ts b/src/services/cdpService.ts index 09c79c9..216d9e8 100644 --- a/src/services/cdpService.ts +++ b/src/services/cdpService.ts @@ -9,12 +9,28 @@ import WebSocket from 'ws'; /** * Extract the workspace/project name from a window or document title. * Handles em-dash (โ€”), en-dash (โ€“), and hyphen (-). - * E.g., "ProjectName โ€” Antigravity" -> "ProjectName" + * E.g., "My - Project โ€” Antigravity" -> "My - Project" */ export function parseProjectNameFromTitle(title?: string | null): string { if (!title || !title.trim()) return ''; - const parts = title.split(/\s[โ€”โ€“-]\s/); - return parts[0].trim(); + const trimmed = title.trim(); + + const productMatch = trimmed.match(/^(.*?)\s+[โ€”โ€“-]\s+(Antigravity(?: IDE)?|Cascade)$/i); + if (productMatch && productMatch[1].trim()) { + return productMatch[1].trim(); + } + + const matches = Array.from(trimmed.matchAll(/\s+[โ€”โ€“-]\s+/g)); + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + const lastIndex = lastMatch.index!; + const namePart = trimmed.slice(0, lastIndex).trim(); + if (namePart) { + return namePart; + } + } + + return trimmed; } /** Configuration options for the CDP service. */ diff --git a/tests/platform/telegram/telegramAdapter.test.ts b/tests/platform/telegram/telegramAdapter.test.ts index d1d024b..510cd8b 100644 --- a/tests/platform/telegram/telegramAdapter.test.ts +++ b/tests/platform/telegram/telegramAdapter.test.ts @@ -119,6 +119,20 @@ describe('TelegramAdapter', () => { 'TelegramAdapter is already started', ); }); + + it('performs transactional cleanup if readiness check (getMe) fails', async () => { + const bot = createMockBot(); + bot.api.getMe = jest.fn().mockRejectedValue(new Error('Unauthorized token')); + const events = createMockEvents(); + const adapter = new TelegramAdapter(bot, 'bot_1'); + + await expect(adapter.start(events)).rejects.toThrow('Unauthorized token'); + + expect(bot.stop).toHaveBeenCalledTimes(1); + expect(events.onError).toHaveBeenCalledTimes(1); + expect(adapter['events']).toBeNull(); + expect(adapter['started']).toBe(false); + }); }); describe('stop', () => { diff --git a/tests/services/artifactService.test.ts b/tests/services/artifactService.test.ts index f249867..45a7675 100644 --- a/tests/services/artifactService.test.ts +++ b/tests/services/artifactService.test.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as crypto from 'crypto'; import { ArtifactService, ArtifactInfo } from '../../src/services/artifactService'; describe('ArtifactService', () => { @@ -22,9 +23,15 @@ describe('ArtifactService', () => { const conversationId = '123e4567-e89b-12d3-a456-426614174000'; const filename = 'implementation_plan.md'; + const expectedHash = crypto + .createHash('sha256') + .update(`${conversationId}:${filename}`) + .digest('hex') + .slice(0, 8); + const encoded = ArtifactService.encodeSelectValue(conversationId, filename); - // Format: art_123e4567_[8-char sha256 hex hash]_implementation_plan.md - expect(encoded).toMatch(/^art_123e4567_[a-f0-9]{8}_implementation_plan\.md$/); + // Assert exact known SHA-256 hash for the fixture + expect(encoded).toBe(`art_123e4567_${expectedHash}_implementation_plan.md`); const artifacts: ArtifactInfo[] = [ { @@ -64,6 +71,26 @@ describe('ArtifactService', () => { expect(decoded).toBeNull(); }); + it('should reject decoding when filename is swapped but original hash is retained', () => { + const conversationId = '123e4567-e89b-12d3-a456-426614174000'; + const originalFile = 'implementation_plan.md'; + const swappedFile = 'walkthrough.md'; + + // Generate encoded value for implementation_plan.md + const encodedOriginal = ArtifactService.encodeSelectValue(conversationId, originalFile); + + // Swap out implementation_plan.md with walkthrough.md in the select string while retaining original hash + const hash = encodedOriginal.split('_')[2]; + const swappedSelectValue = `art_123e4567_${hash}_${swappedFile}`; + + const artifacts: ArtifactInfo[] = [ + { conversationId, filename: swappedFile, artifactType: 'ARTIFACT_TYPE_WALKTHROUGH', absolutePath: 'ignored' } + ]; + + const decoded = artifactService.decodeSelectValue(swappedSelectValue, artifacts); + expect(decoded).toBeNull(); + }); + it('should correctly resolve between two conversation IDs sharing the same short prefix and filename', () => { // Both IDs share the same 8-char shortConv prefix ('123e4567') const convA = '123e4567-aaaa-1111-2222-333333333333'; diff --git a/tests/services/cdpService.workspace.test.ts b/tests/services/cdpService.workspace.test.ts index 36ceb97..88b859a 100644 --- a/tests/services/cdpService.workspace.test.ts +++ b/tests/services/cdpService.workspace.test.ts @@ -335,6 +335,12 @@ describe('CdpService - Cross-Platform Workspace Launching', () => { expect(parseProjectNameFromTitle(' Complex-Project โ€” Antigravity IDE ')).toBe('Complex-Project'); }); + it('should preserve full project name when earlier hyphens exist before product suffix', () => { + expect(parseProjectNameFromTitle('My - Project โ€” Antigravity')).toBe('My - Project'); + expect(parseProjectNameFromTitle('My - Project - Cascade')).toBe('My - Project'); + expect(parseProjectNameFromTitle('My - Project โ€” Antigravity IDE')).toBe('My - Project'); + }); + it('should handle null, undefined, or empty titles gracefully', () => { expect(parseProjectNameFromTitle(null)).toBe(''); expect(parseProjectNameFromTitle(undefined)).toBe(''); From f0474a99ac3ea4d20130460e0afedd18dc6b0f8c Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 16:17:32 -0400 Subject: [PATCH 4/9] fix(review): type safety guards, process log buffer factory, separator precedence, and negative test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Audit Summary Matrix: Final Review Resolution | # | Item Description | Initial Audit | Resolution Status | Technical Action Taken | |---|---|:---:|:---:|---| | 1 | Type Safety in interactionCreateHandler.ts | Still Present | Fixed & Verified | Created SendableChannel type guard in src/utils/discordChannelUtils.ts; removed inline `{ send: Function }` and `(interaction.channel as any).send` casts across all fallback send paths. | | 2 | Process Log Buffer Consolidation | Still Present | Fixed & Verified | Exported createDefaultProcessLogBuffer() factory in src/utils/processLogBuffer.ts to centralize log buffer configuration across Discord and Telegram message handlers. | | 3 | Title Parsing & Separator Precedence | Still Present | Fixed & Verified | Refined parseProjectNameFromTitle in src/services/cdpService.ts with a 3-tier separator hierarchy (Product Suffix > Em/En Dash > Hyphen). | | 4 | Negative Test Coverage (decodeSelectValue) | Still Present | Fixed & Verified | Added explicit test case for same 8-char short prefix collision rejection in tests/services/artifactService.test.ts. | | 5 | Regression Test (sendableChannel) | Still Present | Fixed & Verified | Added unit test asserting SendableChannel target channel dispatching in tests/events/interactionCreateHandler.test.ts. | ### Technical Details - Type Safety: Isolated Discord sendable channel narrowing into src/utils/discordChannelUtils.ts with isSendableChannel predicate. - Process Log Buffering: Standardized maxChars, maxEntries, and maxEntryLength tuning via createDefaultProcessLogBuffer(). - Title Parsing: Prioritized em-dash (โ€”) and en-dash (โ€“) over plain hyphens (-) when extracting project names from titles. - Test Coverage: Added SendableChannel dispatching test in interactionCreateHandler.test.ts and prefix collision test in artifactService.test.ts. Full test suite passing (115 test suites, 1559 tests). --- src/bot/index.ts | 6 +- src/bot/telegramMessageHandler.ts | 4 +- src/events/interactionCreateHandler.ts | 44 +++++----- src/services/cdpService.ts | 21 +++-- src/utils/discordChannelUtils.ts | 27 ++++++ src/utils/processLogBuffer.ts | 14 ++++ tests/events/interactionCreateHandler.test.ts | 83 +++++++++++++++++++ tests/services/artifactService.test.ts | 15 ++++ 8 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 src/utils/discordChannelUtils.ts diff --git a/src/bot/index.ts b/src/bot/index.ts index 50c855c..851da35 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -87,7 +87,7 @@ import { import { buildModeModelLines, fitForSingleEmbedDescription, splitForEmbedDescription } from '../utils/streamMessageFormatter'; import { formatForDiscord, splitOutputAndLogs } from '../utils/discordFormatter'; import { renderDiscordResponse } from '../platform/discord/discordResponseRenderer'; -import { ProcessLogBuffer } from '../utils/processLogBuffer'; +import { ProcessLogBuffer, createDefaultProcessLogBuffer } from '../utils/processLogBuffer'; import { buildPromptWithAttachmentUrls, cleanupInboundImageAttachments, @@ -453,10 +453,8 @@ async function sendPromptToAntigravity( let lastActivityLogText = ''; const LIVE_RESPONSE_MAX_LEN = 3800; const LIVE_ACTIVITY_MAX_LEN = 3800; - const processLogBuffer = new ProcessLogBuffer({ + const processLogBuffer = createDefaultProcessLogBuffer({ maxChars: LIVE_ACTIVITY_MAX_LEN, - maxEntries: 120, - maxEntryLength: 220, }); const liveResponseMessages: any[] = []; const liveActivityMessages: any[] = []; diff --git a/src/bot/telegramMessageHandler.ts b/src/bot/telegramMessageHandler.ts index a6d0f61..0bf4bc7 100644 --- a/src/bot/telegramMessageHandler.ts +++ b/src/bot/telegramMessageHandler.ts @@ -15,7 +15,7 @@ import type { WorkspaceService } from '../services/workspaceService'; import { CdpBridge, registerApprovalWorkspaceChannel, ensureApprovalDetector, ensureErrorPopupDetector, ensurePlanningDetector, ensureRunCommandDetector, ensureQuestionDetector } from '../services/cdpBridgeManager'; import { CdpService } from '../services/cdpService'; import { ResponseMonitor, captureResponseMonitorBaseline } from '../services/responseMonitor'; -import { ProcessLogBuffer } from '../utils/processLogBuffer'; +import { ProcessLogBuffer, createDefaultProcessLogBuffer } from '../utils/processLogBuffer'; import { splitOutputAndLogs } from '../utils/discordFormatter'; import { parseTelegramProjectCommand, handleTelegramProjectCommand } from './telegramProjectCommand'; import { parseTelegramCommand, handleTelegramCommand } from './telegramCommands'; @@ -297,7 +297,7 @@ export function createTelegramMessageHandler(deps: TelegramMessageHandlerDeps) { // Monitor the response const channel = message.channel; const startTime = Date.now(); - const processLogBuffer = new ProcessLogBuffer({ maxChars: 3500, maxEntries: 120, maxEntryLength: 220 }); + const processLogBuffer = createDefaultProcessLogBuffer(); let lastActivityLogText = ''; let statusMsg: PlatformSentMessage | null = null; diff --git a/src/events/interactionCreateHandler.ts b/src/events/interactionCreateHandler.ts index fd1dbe6..32660e6 100644 --- a/src/events/interactionCreateHandler.ts +++ b/src/events/interactionCreateHandler.ts @@ -68,6 +68,7 @@ import { ArtifactThreadRepository } from '../database/artifactThreadRepository'; import { ScheduleService } from '../services/scheduleService'; import type { AntigravityAccountConfig } from '../utils/configLoader'; import { inferParentScopeChannelId, listAccountNames, resolveScopedAccountName } from '../utils/accountUtils'; +import { isSendableChannel } from '../utils/discordChannelUtils'; import { ACCOUNT_SELECT_ID, sendAccountUI } from '../ui/accountUi'; /** @@ -444,11 +445,11 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (interactionError: any) { if (interactionError?.code === 10062 || interactionError?.code === 40060) { logger.warn('[Approval] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const fallbackMessage = success ? `${actionLabel} completed.` : 'Approval button not found.'; - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { throw interactionError; @@ -524,7 +525,7 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep }); // Send plan content as a new message in the same channel - if (planContent && interaction.channel && 'send' in interaction.channel) { + if (planContent && isSendableChannel(interaction.channel)) { // Discord embed description limit is 4096 chars const MAX_PLAN_CONTENT = 4096; const truncated = planContent.length > MAX_PLAN_CONTENT @@ -537,7 +538,7 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep .setColor(0x3498DB) .setTimestamp(); - await (interaction.channel as any).send({ embeds: [planEmbed] }).catch(logger.error); + await interaction.channel.send({ embeds: [planEmbed] }).catch(logger.error); } else if (!planContent) { await interaction.followUp({ content: t('Could not extract plan content from the editor.'), @@ -565,11 +566,11 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (interactionError: any) { if (interactionError?.code === 10062 || interactionError?.code === 40060) { logger.warn('[Planning] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const fallbackMessage = clicked ? t('Reject completed.') : t('Reject button not found.'); - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { throw interactionError; @@ -598,11 +599,11 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (interactionError: any) { if (interactionError?.code === 10062 || interactionError?.code === 40060) { logger.warn('[Planning] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const fallbackMessage = clicked ? t('Proceed completed. Implementation started.') : t('Proceed button not found.'); - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { throw interactionError; @@ -702,12 +703,12 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (err: any) { if (err?.code === 10062 || err?.code === 40060) { logger.warn('[FileChange] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const actionLabel = fileChangeAction.action === 'accept' ? 'Accept All' : 'Reject All'; const fallbackMessage = clicked ? `${actionLabel} completed.` : t('File change button not found or error occurred.'); - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { logger.error('[FileChange] action error:', err); @@ -767,11 +768,11 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (interactionError: any) { if (interactionError?.code === 10062 || interactionError?.code === 40060) { logger.warn('[ErrorPopup] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const fallbackMessage = clicked ? t('Error popup dismissed.') : t('Dismiss button not found.'); - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { throw interactionError; @@ -808,7 +809,7 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep }); // Send debug info as a new message - if (clipboardContent && interaction.channel && 'send' in interaction.channel) { + if (clipboardContent && isSendableChannel(interaction.channel)) { const MAX_DEBUG_CONTENT = 4096; const truncated = clipboardContent.length > MAX_DEBUG_CONTENT ? clipboardContent.substring(0, MAX_DEBUG_CONTENT - 15) + '\n\n(truncated)' @@ -820,7 +821,7 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep .setColor(0x3498DB) .setTimestamp(); - await (interaction.channel as any).send({ embeds: [debugEmbed] }).catch(logger.error); + await interaction.channel.send({ embeds: [debugEmbed] }).catch(logger.error); } else if (!clipboardContent) { await interaction.followUp({ content: t('Could not read debug info from clipboard.'), @@ -849,11 +850,11 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (interactionError: any) { if (interactionError?.code === 10062 || interactionError?.code === 40060) { logger.warn('[ErrorPopup] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const fallbackMessage = clicked ? t('Retry initiated.') : t('Retry button not found.'); - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { throw interactionError; @@ -934,11 +935,11 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep } catch (interactionError: any) { if (interactionError?.code === 10062 || interactionError?.code === 40060) { logger.warn('[RunCommand] Interaction expired. Responding directly in the channel.'); - if (interaction.channel && 'send' in interaction.channel) { + if (isSendableChannel(interaction.channel)) { const fallbackMessage = success ? `${actionLabel} completed.` : t('Run command button not found.'); - await (interaction.channel as any).send(fallbackMessage).catch(logger.error); + await interaction.channel.send(fallbackMessage).catch(logger.error); } } else { throw interactionError; @@ -1632,11 +1633,8 @@ export function createInteractionCreateHandler(deps: InteractionCreateHandlerDep remaining = remaining.slice(chunk.length).replace(/^\n/, ''); } - const sendableChannel = targetChannel && typeof (targetChannel as any).send === 'function' - ? (targetChannel as { send(options: { content: string; allowedMentions?: { parse?: string[] } }): Promise }) - : null; - if (sendableChannel) { - await sendableChannel.send({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); + if (isSendableChannel(targetChannel)) { + await targetChannel.send({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); } else { await interaction.followUp({ content: chunk, allowedMentions: { parse: [] } }).catch(logger.error); } diff --git a/src/services/cdpService.ts b/src/services/cdpService.ts index 216d9e8..55b5e02 100644 --- a/src/services/cdpService.ts +++ b/src/services/cdpService.ts @@ -15,19 +15,26 @@ export function parseProjectNameFromTitle(title?: string | null): string { if (!title || !title.trim()) return ''; const trimmed = title.trim(); + // 1. Explicit product suffix match (Antigravity / Antigravity IDE / Cascade) const productMatch = trimmed.match(/^(.*?)\s+[โ€”โ€“-]\s+(Antigravity(?: IDE)?|Cascade)$/i); if (productMatch && productMatch[1].trim()) { return productMatch[1].trim(); } - const matches = Array.from(trimmed.matchAll(/\s+[โ€”โ€“-]\s+/g)); - if (matches.length > 0) { - const lastMatch = matches[matches.length - 1]; - const lastIndex = lastMatch.index!; + // 2. Em-dash (โ€”) or En-dash (โ€“) separator match takes priority over plain hyphen + const dashMatch = Array.from(trimmed.matchAll(/\s+[โ€”โ€“]\s+/g)); + if (dashMatch.length > 0) { + const lastIndex = dashMatch[dashMatch.length - 1].index!; const namePart = trimmed.slice(0, lastIndex).trim(); - if (namePart) { - return namePart; - } + if (namePart) return namePart; + } + + // 3. Fallback to space-padded hyphen separator + const hyphenMatch = Array.from(trimmed.matchAll(/\s+-\s+/g)); + if (hyphenMatch.length > 0) { + const lastIndex = hyphenMatch[hyphenMatch.length - 1].index!; + const namePart = trimmed.slice(0, lastIndex).trim(); + if (namePart) return namePart; } return trimmed; diff --git a/src/utils/discordChannelUtils.ts b/src/utils/discordChannelUtils.ts new file mode 100644 index 0000000..c288349 --- /dev/null +++ b/src/utils/discordChannelUtils.ts @@ -0,0 +1,27 @@ +import type { Message, MessageCreateOptions } from 'discord.js'; + +/** + * Interface representing any channel or target object that supports sending messages. + */ +export interface SendableChannel { + /** + * Send a message to the target channel. + * @param options Text string or MessageCreateOptions payload. + * @returns Created Message instance. + */ + send(options: string | MessageCreateOptions): Promise; +} + +/** + * Type guard verifying if a channel object supports sending messages. + * @param channel Target channel object to inspect. + * @returns True if channel contains a send method. + */ +export function isSendableChannel(channel: unknown): channel is SendableChannel { + return ( + typeof channel === 'object' && + channel !== null && + 'send' in channel && + typeof (channel as { send?: unknown }).send === 'function' + ); +} diff --git a/src/utils/processLogBuffer.ts b/src/utils/processLogBuffer.ts index d8d50b1..3249e0e 100644 --- a/src/utils/processLogBuffer.ts +++ b/src/utils/processLogBuffer.ts @@ -154,3 +154,17 @@ export class ProcessLogBuffer { this.seen.delete(removed.toLowerCase()); } } + +/** + * Factory creating a ProcessLogBuffer initialized with standardized platform defaults. + * @param options Optional override parameters. + * @returns Configured ProcessLogBuffer instance. + */ +export function createDefaultProcessLogBuffer(options: ProcessLogBufferOptions = {}): ProcessLogBuffer { + return new ProcessLogBuffer({ + maxChars: options.maxChars ?? DEFAULT_MAX_CHARS, + maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES, + maxEntryLength: options.maxEntryLength ?? 220, + }); +} + diff --git a/tests/events/interactionCreateHandler.test.ts b/tests/events/interactionCreateHandler.test.ts index 9557d61..2b69d6e 100644 --- a/tests/events/interactionCreateHandler.test.ts +++ b/tests/events/interactionCreateHandler.test.ts @@ -450,4 +450,87 @@ describe('interactionCreateHandler', () => { ); expect(mockArtifactService.getArtifactContent).toHaveBeenCalledWith('conv-123', 'plan.md'); }); + + it('uses SendableChannel send method to dispatch artifact chunks directly when targetChannel is sendable', async () => { + const deferUpdate = jest.fn().mockResolvedValue(undefined); + const targetChannelSend = jest.fn().mockResolvedValue(undefined); + const mockMessage = { + startThread: jest.fn().mockResolvedValue({ + id: 'thread-456', + send: targetChannelSend, + }), + }; + const editReply = jest.fn().mockResolvedValue(mockMessage); + + const mockArtifactService = { + findConversationByTitle: jest.fn().mockReturnValue('conv-123'), + listArtifacts: jest.fn().mockReturnValue([ + { conversationId: 'conv-123', filename: 'plan.md', artifactType: 'ARTIFACT_TYPE_IMPLEMENTATION_PLAN' }, + ]), + decodeSelectValue: jest.fn().mockReturnValue({ conversationId: 'conv-123', filename: 'plan.md' }), + getArtifactContent: jest.fn().mockReturnValue('# Implementation Plan\n- Step 1\n- Step 2'), + }; + + const chatSessionRepo = { + findByChannelId: jest.fn().mockReturnValue({ displayName: 'My Session', conversationId: 'conv-123' }), + }; + + const userPrefRepo = { + getArtifactRenderMode: jest.fn().mockReturnValue('thread'), + }; + + const artifactThreadRepo = { + getThreadId: jest.fn().mockReturnValue(null), + setThreadId: jest.fn(), + }; + + const interaction = { + isAutocomplete: () => false, + isButton: () => false, + isStringSelectMenu: () => true, + isChatInputCommand: () => false, + customId: 'artifact_select', + values: ['art_conv123_hash_plan.md'], + channelId: 'channel-a', + user: { id: 'allowed' }, + channel: { + threads: {}, + }, + deferUpdate, + editReply, + } as any; + + const handler = createInteractionCreateHandler({ + config: { allowedUserIds: ['allowed'] }, + bridge: {} as any, + cleanupHandler: {} as any, + modeService: {} as any, + modelService: {} as any, + slashCommandHandler: {} as any, + wsHandler: { getWorkspaceForChannel: jest.fn() } as any, + chatHandler: {} as any, + client: {} as any, + sendModeUI: jest.fn(), + sendModelsUI: jest.fn(), + sendAutoAcceptUI: jest.fn(), + handleScreenshot: jest.fn(), + getCurrentCdp: jest.fn(), + parseApprovalCustomId: jest.fn().mockReturnValue(null), + parseErrorPopupCustomId: jest.fn().mockReturnValue(null), + parsePlanningCustomId: jest.fn().mockReturnValue(null), + parseFileChangeCustomId: jest.fn().mockReturnValue(null), + parseRunCommandCustomId: jest.fn().mockReturnValue(null), + handleSlashInteraction: jest.fn(), + artifactService: mockArtifactService as any, + chatSessionRepo: chatSessionRepo as any, + userPrefRepo: userPrefRepo as any, + artifactThreadRepo: artifactThreadRepo as any, + }); + + await handler(interaction); + + expect(targetChannelSend).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('# Implementation Plan') }), + ); + }); }); \ No newline at end of file diff --git a/tests/services/artifactService.test.ts b/tests/services/artifactService.test.ts index 45a7675..ecde372 100644 --- a/tests/services/artifactService.test.ts +++ b/tests/services/artifactService.test.ts @@ -111,6 +111,21 @@ describe('ArtifactService', () => { expect(decodedA?.conversationId).toBe(convA); expect(decodedB?.conversationId).toBe(convB); }); + + it('should reject decoding when encoded for convB but candidate list only contains convA (prefix collision)', () => { + const convA = '123e4567-aaaa-1111-2222-333333333333'; + const convB = '123e4567-bbbb-4444-5555-666666666666'; + const filename = 'walkthrough.md'; + + const encodedB = ArtifactService.encodeSelectValue(convB, filename); + + const artifacts: ArtifactInfo[] = [ + { conversationId: convA, filename, artifactType: 'ARTIFACT_TYPE_WALKTHROUGH', absolutePath: 'a' }, + ]; + + const decoded = artifactService.decodeSelectValue(encodedB, artifacts); + expect(decoded).toBeNull(); + }); }); describe('listArtifacts', () => { From c63b175a20a3faef218ab3cce247f7e6d2e2fe38 Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 16:25:54 -0400 Subject: [PATCH 5/9] refactor(discord): include MessagePayload in SendableChannel send parameter signature --- src/utils/discordChannelUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/discordChannelUtils.ts b/src/utils/discordChannelUtils.ts index c288349..9d14a8e 100644 --- a/src/utils/discordChannelUtils.ts +++ b/src/utils/discordChannelUtils.ts @@ -1,4 +1,4 @@ -import type { Message, MessageCreateOptions } from 'discord.js'; +import type { Message, MessageCreateOptions, MessagePayload } from 'discord.js'; /** * Interface representing any channel or target object that supports sending messages. @@ -6,10 +6,10 @@ import type { Message, MessageCreateOptions } from 'discord.js'; export interface SendableChannel { /** * Send a message to the target channel. - * @param options Text string or MessageCreateOptions payload. + * @param options Text string, MessagePayload, or MessageCreateOptions payload. * @returns Created Message instance. */ - send(options: string | MessageCreateOptions): Promise; + send(options: string | MessagePayload | MessageCreateOptions): Promise; } /** From e69b4a815c92799671c3273076111f4684f1c6b6 Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 17:05:32 -0400 Subject: [PATCH 6/9] test(telegram): assert bot.start is not called when readiness check fails --- tests/platform/telegram/telegramAdapter.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/platform/telegram/telegramAdapter.test.ts b/tests/platform/telegram/telegramAdapter.test.ts index 510cd8b..743c877 100644 --- a/tests/platform/telegram/telegramAdapter.test.ts +++ b/tests/platform/telegram/telegramAdapter.test.ts @@ -128,6 +128,7 @@ describe('TelegramAdapter', () => { await expect(adapter.start(events)).rejects.toThrow('Unauthorized token'); + expect(bot.start).not.toHaveBeenCalled(); expect(bot.stop).toHaveBeenCalledTimes(1); expect(events.onError).toHaveBeenCalledTimes(1); expect(adapter['events']).toBeNull(); From 3d6b9f19ceed1d3814bb5649ba8bf8375add021a Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 13:35:10 -0400 Subject: [PATCH 7/9] fix(mirror): resolve Issue #148 with workspace channel fallback routing and robust DOM selectors --- src/commands/joinCommandHandler.ts | 23 +++++++++--- src/services/userMessageDetector.ts | 4 +- tests/commands/joinCommandHandler.test.ts | 45 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/commands/joinCommandHandler.ts b/src/commands/joinCommandHandler.ts index 782a0c5..979aefe 100644 --- a/src/commands/joinCommandHandler.ts +++ b/src/commands/joinCommandHandler.ts @@ -312,7 +312,7 @@ export class JoinCommandHandler { } ensureUserMessageDetector(bridge, cdp, projectName, (info) => { - this.routeMirroredMessage(cdp, projectName, info) + this.routeMirroredMessage(cdp, projectName, info, bridge) .catch((err) => { logger.error('[Mirror] Error routing mirrored message:', err); }); @@ -323,13 +323,14 @@ export class JoinCommandHandler { * Route a mirrored PC message to the correct Discord channel and * start a passive ResponseMonitor to capture the AI response. * - * Routing: chatSessionRepo.findByDisplayName only โ€” no fallbacks. - * Sessions without an explicit channel binding are silently skipped. + * Routing: chatSessionRepo.findByDisplayName first, falling back to + * the project workspace approval channel if no session binding exists. */ private async routeMirroredMessage( cdp: CdpService, projectName: string, info: { text: string }, + bridge?: CdpBridge, ): Promise { const chatTitle = await getCurrentChatTitle(cdp); @@ -339,12 +340,22 @@ export class JoinCommandHandler { } const session = this.chatSessionRepo.findByDisplayName(projectName, chatTitle); - if (!session) { - logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`); + let targetChannelId = session?.channelId; + + // Fallback to workspace approval channel if no session-specific channel binding exists + if (!targetChannelId && bridge) { + const workspaceChannel = bridge.approvalChannelByWorkspace.get(projectName); + if (workspaceChannel) { + targetChannelId = workspaceChannel.id; + } + } + + if (!targetChannelId) { + logger.debug(`[Mirror] No bound channel for session "${chatTitle}" or workspace "${projectName}", skipping`); return; } - const channel = this.client.channels.cache.get(session.channelId); + const channel = this.client.channels.cache.get(targetChannelId); if (!channel || !('send' in channel)) return; const sendable = channel as { send: (...args: any[]) => Promise }; diff --git a/src/services/userMessageDetector.ts b/src/services/userMessageDetector.ts index 6d3d46f..fba6f47 100644 --- a/src/services/userMessageDetector.ts +++ b/src/services/userMessageDetector.ts @@ -38,7 +38,9 @@ const DETECT_USER_MESSAGE_SCRIPT = `(() => { const panel = document.querySelector('.antigravity-agent-side-panel'); const scope = panel || document; - const bubbles = Array.from(scope.querySelectorAll('.bg-input.p-2')); + const bubbles = Array.from(scope.querySelectorAll( + '.bg-input.p-2, div[class*="bg-gray-"][class*="p-2"], div[class*="bg-input"], [data-message-author-role="user"]' + )); const userBubbles = bubbles.filter(el => { if (el.closest('.text-ide-message-block-bot-color')) return false; if (el.closest('.rendered-markdown, .prose')) return false; diff --git a/tests/commands/joinCommandHandler.test.ts b/tests/commands/joinCommandHandler.test.ts index a3d6e33..952b821 100644 --- a/tests/commands/joinCommandHandler.test.ts +++ b/tests/commands/joinCommandHandler.test.ts @@ -450,5 +450,50 @@ describe('JoinCommandHandler', () => { }), ); }); + + it('falls back to workspace channel when session is not explicitly bound via findByDisplayName', async () => { + bindingRepo.upsert({ channelId: 'ws-ch-123', workspacePath: 'my-project', guildId: 'guild-1' }); + mockPool.getUserMessageDetector.mockReturnValue(undefined); + const mockCdp = { isConnected: () => true } as any; + mockPool.getOrConnect.mockResolvedValue(mockCdp); + + const { getCurrentChatTitle } = require('../../src/services/cdpBridgeManager'); + (getCurrentChatTitle as jest.Mock).mockResolvedValueOnce('Random Session'); + + const mockSend = jest.fn().mockResolvedValue(undefined); + mockClient.channels.cache.get.mockImplementation((id: string) => { + if (id === 'ws-ch-123') return { send: mockSend }; + return null; + }); + + let registeredCallback: ((info: { text: string }) => void) | null = null; + (ensureUserMessageDetector as jest.Mock).mockImplementation((bridge, cdp, proj, callback) => { + registeredCallback = callback; + }); + + const interaction = makeMockInteraction({ channelId: 'ws-ch-123' }); + const bridge = { + pool: mockPool, + approvalChannelByWorkspace: new Map([['my-project', { id: 'ws-ch-123' }]]), + } as any; + + await handler.handleMirror(interaction as any, bridge); + + expect(registeredCallback).not.toBeNull(); + + await (handler as any).routeMirroredMessage(mockCdp, 'my-project', { text: 'Hello from PC' }, bridge); + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + description: expect.stringContaining('Hello from PC'), + }), + }), + ]), + }), + ); + }); }); }); From 38b752210e794181b191fd331175c14a26e2883a Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 13:52:08 -0400 Subject: [PATCH 8/9] chore(dom): clean up dead DOM selectors in response extraction (#40) --- docs/ANTIGRAVITY_DOM_SELECTORS.md | 17 +++++++---------- src/services/assistantDomExtractor.ts | 5 ----- src/services/responseMonitor.ts | 19 ++----------------- tests/services/assistantDomExtractor.test.ts | 2 +- 4 files changed, 10 insertions(+), 33 deletions(-) diff --git a/docs/ANTIGRAVITY_DOM_SELECTORS.md b/docs/ANTIGRAVITY_DOM_SELECTORS.md index ee8ff59..f044556 100644 --- a/docs/ANTIGRAVITY_DOM_SELECTORS.md +++ b/docs/ANTIGRAVITY_DOM_SELECTORS.md @@ -68,14 +68,14 @@ The assistant's response body, rendered with markdown formatting. | 10 | `.rendered-markdown` | **Verified** | `responseMonitor.ts`, `assistantDomExtractor.ts` | | 9 | `.leading-relaxed.select-text` | **Verified** | `responseMonitor.ts`, `planningDetector.ts`, `assistantDomExtractor.ts` | | 8 | `.flex.flex-col.gap-y-3` | **Deprecated** โ€” removed from primary extraction paths (AG 1.23.x) | โ€” | -| 7 | `[data-message-author-role="assistant"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` | -| 6 | `[data-message-role="assistant"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` | -| 5 | `[class*="assistant-message"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` | -| 4 | `[class*="message-content"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` | -| 3 | `[class*="markdown-body"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` | +| โ€” | `[data-message-author-role="assistant"]` | **Removed** (Issue #40 cleanup) | โ€” | +| โ€” | `[data-message-role="assistant"]` | **Removed** (Issue #40 cleanup) | โ€” | +| โ€” | `[class*="assistant-message"]` | **Removed** (Issue #40 cleanup) | โ€” | +| โ€” | `[class*="message-content"]` | **Removed** (Issue #40 cleanup) | โ€” | +| โ€” | `[class*="markdown-body"]` | **Removed** (Issue #40 cleanup) | โ€” | | 2 | `.prose` | Unverified | `responseMonitor.ts`, `assistantDomExtractor.ts` | -> **Note**: Selectors scored 3-7 appear to be inherited from ChatGPT/generic patterns and do **not** exist in Antigravity's DOM. They are harmless (scored lower, never matched) but add noise. The top selectors (`.text-ide-message-block-bot-color`, `.rendered-markdown`, `.leading-relaxed.select-text`) are the ones that actually match. +> **Note**: Selectors scored 3-7 inherited from ChatGPT/generic patterns were removed in Issue #40 cleanup. The active selectors (`.text-ide-message-block-bot-color`, `.rendered-markdown`, `.leading-relaxed.select-text`, `.prose`) handle response extraction. ### Exclusion Containers @@ -246,10 +246,7 @@ Model quota reached / rate limit detection. Quota text is only matched outside response containers to avoid false positives: ``` -.rendered-markdown, .prose, pre, code, -[data-message-author-role="assistant"], -[data-message-role="assistant"], -[class*="message-content"] +.rendered-markdown, .prose, pre, code ``` --- diff --git a/src/services/assistantDomExtractor.ts b/src/services/assistantDomExtractor.ts index d3f9d0d..97d42bf 100644 --- a/src/services/assistantDomExtractor.ts +++ b/src/services/assistantDomExtractor.ts @@ -255,11 +255,6 @@ export function extractAssistantSegmentsPayloadScript(): string { '.text-ide-message-block-bot-color', '.rendered-markdown', '.leading-relaxed.select-text', - '[data-message-author-role="assistant"]', - '[data-message-role="assistant"]', - '[class*="assistant-message"]', - '[class*="message-content"]', - '[class*="markdown-body"]', '.prose', ]; diff --git a/src/services/responseMonitor.ts b/src/services/responseMonitor.ts index 7d74310..964282f 100644 --- a/src/services/responseMonitor.ts +++ b/src/services/responseMonitor.ts @@ -21,11 +21,6 @@ export const RESPONSE_SELECTORS = { { sel: '.text-ide-message-block-bot-color', score: 11 }, { sel: '.rendered-markdown', score: 10 }, { sel: '.leading-relaxed.select-text', score: 9 }, - { sel: '[data-message-author-role="assistant"]', score: 7 }, - { sel: '[data-message-role="assistant"]', score: 6 }, - { sel: '[class*="assistant-message"]', score: 5 }, - { sel: '[class*="message-content"]', score: 4 }, - { sel: '[class*="markdown-body"]', score: 3 }, { sel: '.prose', score: 2 }, ]; @@ -218,11 +213,6 @@ export const RESPONSE_SELECTORS = { { sel: '.text-ide-message-block-bot-color', score: 11 }, { sel: '.rendered-markdown', score: 10 }, { sel: '.leading-relaxed.select-text', score: 9 }, - { sel: '[data-message-author-role="assistant"]', score: 7 }, - { sel: '[data-message-role="assistant"]', score: 6 }, - { sel: '[class*="assistant-message"]', score: 5 }, - { sel: '[class*="message-content"]', score: 4 }, - { sel: '[class*="markdown-body"]', score: 3 }, { sel: '.prose', score: 2 }, ]; @@ -310,11 +300,6 @@ export const RESPONSE_SELECTORS = { { sel: '.text-ide-message-block-bot-color', score: 11 }, { sel: '.rendered-markdown', score: 10 }, { sel: '.leading-relaxed.select-text', score: 9 }, - { sel: '[data-message-author-role="assistant"]', score: 7 }, - { sel: '[data-message-role="assistant"]', score: 6 }, - { sel: '[class*="assistant-message"]', score: 5 }, - { sel: '[class*="message-content"]', score: 4 }, - { sel: '[class*="markdown-body"]', score: 3 }, { sel: '.prose', score: 2 }, ]; @@ -381,7 +366,7 @@ export const RESPONSE_SELECTORS = { const scope = panel || document; const QUOTA_KEYWORDS = ['model quota reached', 'rate limit', 'quota exceeded', 'exhausted your quota', 'exhausted quota']; const isInsideResponse = (node) => - node.closest('.rendered-markdown, .prose, pre, code, [data-message-author-role="assistant"], [data-message-role="assistant"], [class*="message-content"]'); + node.closest('.rendered-markdown, .prose, pre, code'); // Primary: text-based detection via h3 span (Tailwind-only popup) const headings = scope.querySelectorAll('h3 span, h3'); @@ -440,7 +425,7 @@ export const RESPONSE_SELECTORS = { } // 2. Find all text nodes that look like activity - var selectors = '.rendered-markdown, .leading-relaxed.select-text, .flex.flex-col.gap-y-3, [data-message-author-role="assistant"], [data-message-role="assistant"], [class*="assistant-message"], [class*="message-content"], [class*="markdown-body"], .prose'; + var selectors = '.rendered-markdown, .leading-relaxed.select-text, .flex.flex-col.gap-y-3, .prose'; var nodes = scope.querySelectorAll(selectors); for (var j = 0; j < nodes.length; j++) { var text = (nodes[j].innerText || nodes[j].textContent || '').trim(); diff --git a/tests/services/assistantDomExtractor.test.ts b/tests/services/assistantDomExtractor.test.ts index 626db3a..ebca946 100644 --- a/tests/services/assistantDomExtractor.test.ts +++ b/tests/services/assistantDomExtractor.test.ts @@ -313,7 +313,7 @@ describe('assistantDomExtractor', () => { panel.className = 'antigravity-agent-side-panel'; const message = document.createElement('div'); - message.setAttribute('data-message-role', 'assistant'); + message.className = 'rendered-markdown'; // Ordered and unordered lists const ol = document.createElement('ol'); From 6eb3dec810efa3ebc2b962eb33331cd56349ac32 Mon Sep 17 00:00:00 2001 From: Daniel Gomez Date: Sat, 8 Aug 2026 14:33:18 -0400 Subject: [PATCH 9/9] feat(templates): add JSON template import and export (Issue #10) --- README.md | 3 + src/bot/index.ts | 65 ++++++++++ src/commands/registerSlashCommands.ts | 27 ++++ src/commands/slashCommandHandler.ts | 42 +++++++ src/database/templateRepository.ts | 112 +++++++++++++++++ tests/commands/registerSlashCommands.test.ts | 2 + tests/commands/slashCommandHandler.test.ts | 39 ++++++ tests/database/templateRepository.test.ts | 123 +++++++++++++++++++ 8 files changed, 413 insertions(+) diff --git a/README.md b/README.md index 9717b53..e220d41 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ Just type in any bound channel: - `๐Ÿ“ /template list` โ€” Display registered templates with execute buttons - `๐Ÿ“ /template add ` โ€” Register a new prompt template - `๐Ÿ“ /template delete ` โ€” Delete a template +- `๐Ÿ“ /template export` โ€” Export all prompt templates as a JSON file attachment +- `๐Ÿ“ /template import [conflict]` โ€” Bulk-import templates from a JSON file attachment + - `๐Ÿ“… /schedule list` โ€” Show all scheduled tasks with next localized run times - `๐Ÿ“… /schedule add ` โ€” Register a recurring task for the current channel's bound project - `๐Ÿ“… /schedule remove ` โ€” Delete a scheduled task by ID diff --git a/src/bot/index.ts b/src/bot/index.ts index 851da35..5a7aeaf 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -2316,8 +2316,11 @@ export async function handleSlashInteraction( '`/template list` โ€” Show templates with execute buttons (click to run)', '`/template add ` โ€” Register a template', '`/template delete ` โ€” Delete a template', + '`/template export` โ€” Export all templates as a JSON file', + '`/template import` โ€” Bulk-import templates from a JSON file', ].join('\n') }, + { name: '๐Ÿ”ง System', value: [ '`/status` โ€” Display overall bot status', @@ -2424,6 +2427,67 @@ export async function handleSlashInteraction( break; } + if (subcommand === 'export') { + const templates = templateRepo.findAll(); + if (templates.length === 0) { + await interaction.editReply({ content: '๐Ÿ“ No templates registered to export.' }); + break; + } + const jsonStr = templateRepo.exportTemplates(); + const buffer = Buffer.from(jsonStr, 'utf-8'); + await interaction.editReply({ + content: '๐Ÿ“‹ **LazyGravity Templates Export**', + files: [{ + attachment: buffer, + name: 'templates_export.json' + }] + }); + break; + } + + if (subcommand === 'import') { + const attachment = interaction.options.getAttachment('file', true); + const conflictMode = (interaction.options.getString('conflict') as 'skip' | 'overwrite') || 'skip'; + + if (!attachment.name.endsWith('.json')) { + await interaction.editReply({ content: 'โŒ Attachment must be a `.json` file.' }); + break; + } + + if (attachment.size > 1024 * 1024) { + await interaction.editReply({ content: 'โŒ Attachment exceeds maximum size limit of 1MB.' }); + break; + } + + let timeoutId: NodeJS.Timeout | undefined; + try { + const controller = new AbortController(); + timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(attachment.url, { signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const jsonText = await response.text(); + const stats = templateRepo.importTemplates(jsonText, conflictMode); + + let msg = `โœ… **Template Import Complete**\n`; + msg += `- Total in file: ${stats.total}\n`; + msg += `- Imported: ${stats.imported}\n`; + if (conflictMode === 'overwrite') { + msg += `- Overwritten: ${stats.updated}\n`; + } else { + msg += `- Skipped (duplicates): ${stats.skipped}\n`; + } + + await interaction.editReply({ content: msg }); + } catch (error: any) { + await interaction.editReply({ content: `โŒ Failed to import templates: ${error.message}` }); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + break; + } + let args: string[]; switch (subcommand) { case 'add': { @@ -2446,6 +2510,7 @@ export async function handleSlashInteraction( break; } + case 'status': { const activeNames = bridge.pool.getActiveWorkspaceNames(); const currentModel = (() => { diff --git a/src/commands/registerSlashCommands.ts b/src/commands/registerSlashCommands.ts index ae5bd6c..e102806 100644 --- a/src/commands/registerSlashCommands.ts +++ b/src/commands/registerSlashCommands.ts @@ -65,8 +65,35 @@ const templateCommand = new SlashCommandBuilder() .setDescription(t('Name of the template to delete')) .setRequired(true) ) + ) + .addSubcommand((sub) => + sub + .setName('export') + .setDescription(t('Export all prompt templates as a JSON file attachment')) + ) + .addSubcommand((sub) => + sub + .setName('import') + .setDescription(t('Bulk-import prompt templates from a JSON file attachment')) + .addAttachmentOption((option) => + option + .setName('file') + .setDescription(t('The templates JSON file to import')) + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('conflict') + .setDescription(t('How to handle duplicate template names (default: skip)')) + .setRequired(false) + .addChoices( + { name: 'skip', value: 'skip' }, + { name: 'overwrite', value: 'overwrite' } + ) + ) ); + /** /stop command definition */ const stopCommand = new SlashCommandBuilder() .setName('stop') diff --git a/src/commands/slashCommandHandler.ts b/src/commands/slashCommandHandler.ts index eb9d514..b22c886 100644 --- a/src/commands/slashCommandHandler.ts +++ b/src/commands/slashCommandHandler.ts @@ -117,6 +117,48 @@ export class SlashCommandHandler { } } + // export: export templates as JSON + if (subCommandOrName.toLowerCase() === 'export') { + const templates = this.templateRepo.findAll(); + if (templates.length === 0) { + return { + success: true, + message: t('๐Ÿ“ No templates registered to export.'), + }; + } + const jsonStr = this.templateRepo.exportTemplates(); + return { + success: true, + message: t('๐Ÿ“‹ Templates exported successfully.'), + prompt: jsonStr, + }; + } + + // import: import templates from JSON + if (subCommandOrName.toLowerCase() === 'import') { + if (args.length < 2) { + return { + success: false, + message: t('โš ๏ธ Missing arguments.\nUsage: `/template import [skip|overwrite]`'), + }; + } + const jsonText = args[1]; + const mode = (args[2]?.toLowerCase() === 'overwrite') ? 'overwrite' : 'skip'; + try { + const stats = this.templateRepo.importTemplates(jsonText, mode); + return { + success: true, + message: t(`โœ… Imported ${stats.imported} template(s), overwritten ${stats.updated}, skipped ${stats.skipped}.`), + }; + } catch (e: any) { + return { + success: false, + message: t(`โš ๏ธ Failed to import templates: ${e.message}`), + }; + } + } + + // Otherwise treat as template invocation const templateName = subCommandOrName; const template = this.templateRepo.findByName(templateName); diff --git a/src/database/templateRepository.ts b/src/database/templateRepository.ts index 9b2ce39..0a711b0 100644 --- a/src/database/templateRepository.ts +++ b/src/database/templateRepository.ts @@ -29,6 +29,27 @@ export interface UpdateTemplateInput { prompt?: string; } +/** + * Format structure for exported template JSON files + */ +export interface TemplateExportFormat { + version: number; + templates: Array<{ + name: string; + prompt: string; + }>; +} + +/** + * Result metrics for template import operations + */ +export interface ImportTemplatesResult { + imported: number; + updated: number; + skipped: number; + total: number; +} + /** * Repository class for SQLite persistence of frequently used prompt templates. * Handles template creation, retrieval, updating, and deletion. @@ -127,6 +148,96 @@ export class TemplateRepository { return result.changes > 0; } + /** + * Export all templates formatted as a JSON string + */ + public exportTemplates(): string { + const templates = this.findAll(); + const exportData: TemplateExportFormat = { + version: 1, + templates: templates.map((t) => ({ + name: t.name, + prompt: t.prompt, + })), + }; + return JSON.stringify(exportData, null, 2); + } + + /** + * Import templates from JSON string or parsed object. + * @param input Raw JSON string or object + * @param mode Conflict resolution mode: 'skip' (default) or 'overwrite' + */ + public importTemplates( + input: string | any, + mode: 'skip' | 'overwrite' = 'skip' + ): ImportTemplatesResult { + let data: any; + if (typeof input === 'string') { + try { + data = JSON.parse(input); + } catch (e: any) { + throw new Error(`Invalid JSON format: ${e.message}`); + } + } else { + data = input; + } + + if (!data || typeof data !== 'object') { + throw new Error('Invalid JSON content: expected an object.'); + } + + if (!Array.isArray(data.templates)) { + throw new Error('Invalid format: missing "templates" array.'); + } + + for (let i = 0; i < data.templates.length; i++) { + const item = data.templates[i]; + if (!item || typeof item !== 'object') { + throw new Error(`Invalid item at index ${i}: expected object.`); + } + if (typeof item.name !== 'string' || !item.name.trim()) { + throw new Error(`Invalid item at index ${i}: "name" must be a non-empty string.`); + } + if (typeof item.prompt !== 'string') { + throw new Error(`Invalid item at index ${i}: "prompt" must be a string.`); + } + } + + let imported = 0; + let updated = 0; + let skipped = 0; + + const runImport = this.db.transaction(() => { + for (const item of data.templates) { + const name = item.name.trim(); + const prompt = item.prompt; + const existing = this.findByName(name); + + if (existing) { + if (mode === 'overwrite') { + this.updateByName(name, { prompt }); + updated++; + } else { + skipped++; + } + } else { + this.create({ name, prompt }); + imported++; + } + } + }); + + runImport(); + + return { + imported, + updated, + skipped, + total: data.templates.length, + }; + } + /** * Map a DB row to TemplateRecord */ @@ -139,3 +250,4 @@ export class TemplateRepository { }; } } + diff --git a/tests/commands/registerSlashCommands.test.ts b/tests/commands/registerSlashCommands.test.ts index f98e38f..eac0269 100644 --- a/tests/commands/registerSlashCommands.test.ts +++ b/tests/commands/registerSlashCommands.test.ts @@ -50,10 +50,12 @@ jest.mock('discord.js', () => { setName: jest.fn().mockReturnThis(), setDescription: jest.fn().mockReturnThis(), setRequired: jest.fn().mockReturnThis(), + addChoices: jest.fn().mockReturnThis(), }; optFn(option); return sub; }), + addChannelOption: jest.fn().mockImplementation((optFn: (option: any) => void) => { const option = { setName: jest.fn().mockReturnThis(), diff --git a/tests/commands/slashCommandHandler.test.ts b/tests/commands/slashCommandHandler.test.ts index fcad2cd..8306a1c 100644 --- a/tests/commands/slashCommandHandler.test.ts +++ b/tests/commands/slashCommandHandler.test.ts @@ -9,6 +9,8 @@ const mockTemplateRepo = { create: jest.fn(), deleteByName: jest.fn(), updateByName: jest.fn(), + exportTemplates: jest.fn(), + importTemplates: jest.fn(), }; describe('SlashCommandHandler', () => { @@ -97,6 +99,42 @@ describe('SlashCommandHandler', () => { expect(result.success).toBe(false); }); + it('exports templates via the export subcommand', async () => { + mockTemplateRepo.findAll.mockReturnValue([{ id: 1, name: 't1', prompt: 'p1' }]); + mockTemplateRepo.exportTemplates.mockReturnValue('{"version":1,"templates":[{"name":"t1","prompt":"p1"}]}'); + + const result = await handler.handleCommand('template', ['export']); + expect(result.success).toBe(true); + expect(result.prompt).toContain('"t1"'); + }); + + it('handles export subcommand when no templates exist', async () => { + mockTemplateRepo.findAll.mockReturnValue([]); + const result = await handler.handleCommand('template', ['export']); + expect(result.success).toBe(true); + expect(result.message).toContain('No templates registered'); + }); + + it('imports templates via the import subcommand', async () => { + mockTemplateRepo.importTemplates.mockReturnValue({ imported: 1, updated: 0, skipped: 0, total: 1 }); + const jsonText = '{"version":1,"templates":[{"name":"t1","prompt":"p1"}]}'; + + const result = await handler.handleCommand('template', ['import', jsonText, 'skip']); + expect(result.success).toBe(true); + expect(mockTemplateRepo.importTemplates).toHaveBeenCalledWith(jsonText, 'skip'); + expect(result.message).toContain('Imported 1'); + }); + + it('returns error when import subcommand fails', async () => { + mockTemplateRepo.importTemplates.mockImplementation(() => { + throw new Error('Invalid format'); + }); + + const result = await handler.handleCommand('template', ['import', 'bad json']); + expect(result.success).toBe(false); + expect(result.message).toContain('Failed to import templates: Invalid format'); + }); + it('rejects old plural alias "templates"', async () => { const result = await handler.handleCommand('templates', []); expect(result.success).toBe(false); @@ -104,3 +142,4 @@ describe('SlashCommandHandler', () => { }); }); }); + diff --git a/tests/database/templateRepository.test.ts b/tests/database/templateRepository.test.ts index 906210e..d11c84d 100644 --- a/tests/database/templateRepository.test.ts +++ b/tests/database/templateRepository.test.ts @@ -108,4 +108,127 @@ describe('TemplateRepository', () => { expect(updated).toBe(false); }); }); + + describe('exportTemplates - export templates to JSON', () => { + it('exports all templates as a formatted JSON string', () => { + repo.create({ name: 'code-review', prompt: 'Review code' }); + repo.create({ name: 'bug-fix', prompt: 'Fix bug' }); + + const jsonStr = repo.exportTemplates(); + const parsed = JSON.parse(jsonStr); + + expect(parsed.version).toBe(1); + expect(parsed.templates).toHaveLength(2); + expect(parsed.templates).toEqual([ + { name: 'code-review', prompt: 'Review code' }, + { name: 'bug-fix', prompt: 'Fix bug' }, + ]); + }); + + it('exports empty templates list when repository is empty', () => { + const jsonStr = repo.exportTemplates(); + const parsed = JSON.parse(jsonStr); + + expect(parsed.version).toBe(1); + expect(parsed.templates).toEqual([]); + }); + }); + + describe('importTemplates - import templates from JSON', () => { + it('imports new templates successfully with skip mode (default)', () => { + const payload = JSON.stringify({ + version: 1, + templates: [ + { name: 't1', prompt: 'Prompt 1' }, + { name: 't2', prompt: 'Prompt 2' }, + ], + }); + + const result = repo.importTemplates(payload); + expect(result).toEqual({ + imported: 2, + updated: 0, + skipped: 0, + total: 2, + }); + + expect(repo.findAll()).toHaveLength(2); + expect(repo.findByName('t1')?.prompt).toBe('Prompt 1'); + }); + + it('skips duplicate template names in skip mode', () => { + repo.create({ name: 't1', prompt: 'Original Prompt' }); + + const payload = { + version: 1, + templates: [ + { name: 't1', prompt: 'New Prompt' }, + { name: 't2', prompt: 'Prompt 2' }, + ], + }; + + const result = repo.importTemplates(payload, 'skip'); + expect(result).toEqual({ + imported: 1, + updated: 0, + skipped: 1, + total: 2, + }); + + expect(repo.findByName('t1')?.prompt).toBe('Original Prompt'); + expect(repo.findByName('t2')?.prompt).toBe('Prompt 2'); + }); + + it('overwrites existing templates in overwrite mode', () => { + repo.create({ name: 't1', prompt: 'Original Prompt' }); + + const payload = { + version: 1, + templates: [ + { name: 't1', prompt: 'Updated Prompt' }, + { name: 't2', prompt: 'Prompt 2' }, + ], + }; + + const result = repo.importTemplates(payload, 'overwrite'); + expect(result).toEqual({ + imported: 1, + updated: 1, + skipped: 0, + total: 2, + }); + + expect(repo.findByName('t1')?.prompt).toBe('Updated Prompt'); + expect(repo.findByName('t2')?.prompt).toBe('Prompt 2'); + }); + + it('throws an error for invalid JSON string', () => { + expect(() => { + repo.importTemplates('invalid json'); + }).toThrow('Invalid JSON format'); + }); + + it('throws an error for missing templates array', () => { + expect(() => { + repo.importTemplates(JSON.stringify({ version: 1 })); + }).toThrow('missing "templates" array'); + }); + + it('throws an error for invalid template item schema', () => { + expect(() => { + repo.importTemplates({ + version: 1, + templates: [{ name: '', prompt: 'valid' }], + }); + }).toThrow('"name" must be a non-empty string'); + + expect(() => { + repo.importTemplates({ + version: 1, + templates: [{ name: 'valid', prompt: 123 as any }], + }); + }).toThrow('"prompt" must be a string'); + }); + }); }); +