diff --git a/README.md b/README.md index 550d2a6..2d3b899 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,24 @@ p351, p360, p361, p362, p363, p364, p374, p376, ED +### Tortoise TTS Voices + +Tortoise is a high-quality multi-speaker model. Specify the voice name in the `speaker` field. + +**Available voices:** +`angie`, `applejack`, `daniel`, `deniro`, `emma`, `freeman`, `geralt`, `halle`, `jlaw`, `lj`, `mol`, `myself`, `pat`, `pat2`, `rainbow`, `snakes`, `tim_reynolds`, `tom`, `train_docks`, `weaver`, `william` + +### Bark TTS Speakers + +Bark is a multilingual model. Specify the speaker ID in the `speaker` field. + +**English speakers:** +`v2/en_speaker_0` through `v2/en_speaker_9` + +**Other languages:** +Replace `en` with language code (e.g., `v2/de_speaker_0`, `v2/fr_speaker_0`). +Supported: `en`, `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `pl`, `pt`, `ru`, `tr`, `zh` + ### XTTS v2 Speakers XTTS v2 is primarily a voice cloning model. Use the `voiceRef` option to clone any voice: diff --git a/github.ts b/github.ts index 22f21bd..a18d54e 100644 --- a/github.ts +++ b/github.ts @@ -46,7 +46,7 @@ interface GitHubConfig { } const CONFIG_PATH = join(homedir(), ".config", "opencode", "github.json") -const ISSUE_FILE = ".github-issue" +const ISSUE_FILE = ".github-issue.md" const MAX_COMMENT_LENGTH = 65000 // GitHub's limit is 65536 // Debug logging diff --git a/package.json b/package.json index 0425ebb..2a5b196 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "test:load": "node --import tsx --test test/plugin-load.test.ts", "test:reflection-static": "node --import tsx --test test/reflection-static.eval.test.ts", "typecheck": "npx tsc --noEmit", - "install:global": "mkdir -p ~/.config/opencode/plugin && cp reflection.ts telegram.ts tts.ts worktree.ts github.ts ~/.config/opencode/plugin/ && node scripts/ensure-deps.js && cd ~/.config/opencode && bun install", "install:telegram": "mkdir -p ~/.config/opencode/plugin && cp telegram.ts ~/.config/opencode/plugin/ && node scripts/ensure-deps.js && cd ~/.config/opencode && bun install", "install:tts": "mkdir -p ~/.config/opencode/plugin && cp tts.ts ~/.config/opencode/plugin/ && node scripts/ensure-deps.js && cd ~/.config/opencode && bun install", "install:reflection-static": "mkdir -p ~/.config/opencode/plugin && cp reflection-static.ts ~/.config/opencode/plugin/ && rm -f ~/.config/opencode/plugin/reflection.ts && node scripts/ensure-deps.js && cd ~/.config/opencode && bun install", diff --git a/reflection-static.ts b/reflection-static.ts index 79d0ed6..f4daa03 100644 --- a/reflection-static.ts +++ b/reflection-static.ts @@ -9,6 +9,8 @@ */ import type { Plugin } from "@opencode-ai/plugin" +import { readFile } from "fs/promises" +import { join } from "path" const DEBUG = process.env.REFLECTION_DEBUG === "1" const JUDGE_RESPONSE_TIMEOUT = 120_000 @@ -26,6 +28,22 @@ const STATIC_QUESTION = ` 4. **What improvements or next steps could be made?** Be specific and honest. If you're uncertain about completion, say so.` +/** + * Load custom reflection prompt from ./reflection.md in the working directory. + * Falls back to STATIC_QUESTION if file doesn't exist or can't be read. + */ +async function loadReflectionPrompt(directory: string): Promise { + try { + const reflectionPath = join(directory, "reflection.md") + const customPrompt = await readFile(reflectionPath, "utf-8") + debug("Loaded custom prompt from reflection.md") + return customPrompt.trim() + } catch (e) { + // File doesn't exist or can't be read - use default + return STATIC_QUESTION + } +} + export const ReflectionStaticPlugin: Plugin = async ({ client, directory }) => { // Track sessions to prevent duplicate reflection const reflectedSessions = new Set() @@ -36,23 +54,41 @@ export const ReflectionStaticPlugin: Plugin = async ({ client, directory }) => { // Track aborted sessions with timestamps (cooldown-based to handle rapid Esc presses) const recentlyAbortedSessions = new Map() // Count human messages per session - const lastReflectedMsgCount = new Map() + const lastReflectedMsgId = new Map() // Active reflections to prevent concurrent processing const activeReflections = new Set() - function countHumanMessages(messages: any[]): number { - let count = 0 - for (const msg of messages) { + function getMessageSignature(msg: any): string { + if (msg.id) return msg.id + // Fallback signature if ID is missing + const role = msg.info?.role || "unknown" + const time = msg.info?.time?.start || 0 + const textPart = msg.parts?.find((p: any) => p.type === "text")?.text?.slice(0, 20) || "" + return `${role}:${time}:${textPart}` + } + + function getLastRelevantUserMessageId(messages: any[]): string | null { + // Iterate backwards to find the last user message that isn't a reflection prompt + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] if (msg.info?.role === "user") { + let isReflection = false for (const part of msg.parts || []) { - if (part.type === "text" && part.text && !part.text.includes("## Self-Assessment")) { - count++ - break + if (part.type === "text" && part.text) { + // Check for static question + if (part.text.includes("1. **What was the task?**")) { + isReflection = true + break + } + // Check for other internal prompts if any (e.g. analysis prompts are usually in judge session, not here) } } + if (!isReflection) { + return getMessageSignature(msg) + } } } - return count + return null } function isJudgeSession(sessionId: string, messages: any[]): boolean { @@ -68,6 +104,49 @@ export const ReflectionStaticPlugin: Plugin = async ({ client, directory }) => { return false } + function isPlanMode(messages: any[]): boolean { + // 1. Check for System/Developer messages indicating Plan Mode + const hasSystemPlanMode = messages.some((m: any) => + (m.info?.role === "system" || m.info?.role === "developer") && + m.parts?.some((p: any) => + p.type === "text" && + p.text && + (p.text.includes("Plan Mode") || + p.text.includes("plan mode ACTIVE") || + p.text.includes("read-only mode")) + ) + ) + if (hasSystemPlanMode) { + debug("Plan Mode detected from system/developer message") + return true + } + + // 2. Check user intent for plan-related queries + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info?.role === "user") { + let isReflection = false + let text = "" + for (const part of msg.parts || []) { + if (part.type === "text" && part.text) { + text = part.text + if (part.text.includes("1. **What was the task?**")) { + isReflection = true + break + } + } + } + if (!isReflection && text) { + if (/plan mode/i.test(text)) return true + if (/\b(create|make|draft|generate|propose|write|update)\b.{1,30}\bplan\b/i.test(text)) return true + if (/^plan\b/i.test(text.trim())) return true + return false + } + } + } + return false + } + async function showToast(message: string, variant: "info" | "success" | "warning" | "error" = "info") { try { await client.tui.publish({ @@ -242,32 +321,45 @@ Rules: return } - const humanMsgCount = countHumanMessages(messages) - if (humanMsgCount === 0) { - debug("SKIP: no human messages") + if (isPlanMode(messages)) { + debug("SKIP: plan mode detected") + return + } + + const lastUserMsgId = getLastRelevantUserMessageId(messages) + if (!lastUserMsgId) { + debug("SKIP: no relevant human messages") return } - // Skip if already reflected for this message count - const lastCount = lastReflectedMsgCount.get(sessionId) || 0 - if (humanMsgCount <= lastCount) { - debug("SKIP: already reflected for this task") + // Skip if already reflected for this message ID + const lastReflectedId = lastReflectedMsgId.get(sessionId) + if (lastUserMsgId === lastReflectedId) { + debug("SKIP: already reflected for this task ID:", lastUserMsgId) return } + // Reset confirmedComplete if we have a NEW user message + if (lastUserMsgId !== lastReflectedId && confirmedComplete.has(sessionId)) { + debug("New human message detected, resetting confirmedComplete status") + confirmedComplete.delete(sessionId) + } + // Skip if already confirmed complete for this session if (confirmedComplete.has(sessionId)) { debug("SKIP: agent already confirmed complete") return } - // Step 1: Ask the static question + // Step 1: Ask the static question (or custom prompt from reflection.md) debug("Asking static self-assessment question...") await showToast("Asking for self-assessment...", "info") + const reflectionPrompt = await loadReflectionPrompt(directory) + await client.session.promptAsync({ path: { id: sessionId }, - body: { parts: [{ type: "text", text: STATIC_QUESTION }] } + body: { parts: [{ type: "text", text: reflectionPrompt }] } }) // Wait for agent's self-assessment @@ -275,7 +367,7 @@ Rules: if (!selfAssessment) { debug("SKIP: no self-assessment response") - lastReflectedMsgCount.set(sessionId, humanMsgCount) + lastReflectedMsgId.set(sessionId, lastUserMsgId) return } debug("Got self-assessment, length:", selfAssessment.length) @@ -285,17 +377,42 @@ Rules: const analysis = await analyzeResponse(selfAssessment) debug("Analysis result:", JSON.stringify(analysis)) - // Update tracking - lastReflectedMsgCount.set(sessionId, humanMsgCount) - // Step 3: Act on the analysis if (analysis.complete) { // Agent says task is complete - stop here + lastReflectedMsgId.set(sessionId, lastUserMsgId) confirmedComplete.add(sessionId) await showToast("Task confirmed complete", "success") debug("Agent confirmed task complete, stopping") } else if (analysis.shouldContinue) { // Agent identified improvements - push them to continue + // NOTE: We do NOT update lastReflectedMsgId here. + // This ensures that when the agent finishes the pushed work (and idles), + // we re-run reflection to verify the new state (which will still map to the same user Msg ID, + // or a new one if we consider the push as a user message). + + // Actually, if "Push" is a user message, getLastRelevantUserMessageId will return IT next time. + // So we don't need to manually block the update. + // BUT, if we want to reflect on the RESULT of the push, we should let the loop happen. + // If we update lastReflectedMsgId here, and next time getLastRelevantUserMessageId returns the SAME id (because push is the last one), + // we would skip. + // Wait, "Please continue..." IS a user message. + // So next time, lastUserMsgId will be the ID of "Please continue...". + // It will differ from the current lastUserMsgId (which is the original request). + // So we will reflect again. + // So it is SAFE to update lastReflectedMsgId here? + // No, if we update it here to "Original Request ID", and next time we see "Push ID", we reflect. Correct. + // What if we DON'T update it? + // Next time we see "Push ID". "Push ID" != "Original Request ID". We reflect. Correct. + + // The only risk is if "Push" message is NOT considered a relevant user message (e.g. if we filter it out). + // My filter is `!part.text.includes("1. **What was the task?**")`. + // "Please continue..." passes this filter. So it IS a relevant user message. + + // So we can just let the natural logic handle it. + // I will NOT update it here just to be safe and consistent with previous logic + // (treating the "Push" phase as part of the same transaction until completion). + await showToast("Pushing agent to continue...", "info") debug("Pushing agent to continue improvements") @@ -310,6 +427,7 @@ Rules: }) } else { // Agent stopped for valid reason (needs user input, etc.) + lastReflectedMsgId.set(sessionId, lastUserMsgId) await showToast(`Stopped: ${analysis.reason}`, "warning") debug("Agent stopped for valid reason:", analysis.reason) } diff --git a/reflection.ts b/reflection.ts index 54e2717..a8c3f59 100644 --- a/reflection.ts +++ b/reflection.ts @@ -49,13 +49,13 @@ function debug(...args: any[]) { export const ReflectionPlugin: Plugin = async ({ client, directory }) => { - // Track attempts per (sessionId, humanMsgCount) - resets automatically for new messages + // Track attempts per (sessionId, humanMsgId) - resets automatically for new messages const attempts = new Map() - // Track which human message count we last completed reflection on - const lastReflectedMsgCount = new Map() + // Track which human message ID we last completed reflection on + const lastReflectedMsgId = new Map() const activeReflections = new Set() - // Track aborted message counts per session - only skip reflection for the aborted task, not future tasks - const abortedMsgCounts = new Map>() + // Track aborted message IDs per session - only skip reflection for the aborted task, not future tasks + const abortedMsgIds = new Map>() const judgeSessionIds = new Set() // Track judge session IDs to skip them // Track session last-seen timestamps for cleanup const sessionTimestamps = new Map() @@ -164,8 +164,8 @@ export const ReflectionPlugin: Plugin = async ({ client, directory }) => { if (now - timestamp > SESSION_MAX_AGE) { // Clean up all data for this old session sessionTimestamps.delete(sessionId) - lastReflectedMsgCount.delete(sessionId) - abortedMsgCounts.delete(sessionId) + lastReflectedMsgId.delete(sessionId) + abortedMsgIds.delete(sessionId) // Clean attempt keys for this session for (const key of attempts.keys()) { if (key.startsWith(sessionId)) attempts.delete(key) @@ -285,13 +285,45 @@ export const ReflectionPlugin: Plugin = async ({ client, directory }) => { return false } - // Check if the CURRENT task (identified by human message count) was aborted + function getMessageSignature(msg: any): string { + if (msg.id) return msg.id + // Fallback signature if ID is missing + const role = msg.info?.role || "unknown" + const time = msg.info?.time?.start || 0 + const textPart = msg.parts?.find((p: any) => p.type === "text")?.text?.slice(0, 20) || "" + return `${role}:${time}:${textPart}` + } + + function getLastRelevantUserMessageId(messages: any[]): string | null { + // Iterate backwards to find the last user message that isn't a reflection prompt + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info?.role === "user") { + let isReflection = false + for (const part of msg.parts || []) { + if (part.type === "text" && part.text) { + // Check for reflection feedback + if (part.text.includes("## Reflection:")) { + isReflection = true + break + } + } + } + if (!isReflection) { + return getMessageSignature(msg) + } + } + } + return null + } + + // Check if the CURRENT task (identified by human message ID) was aborted // Returns true only if the most recent assistant response for this task was aborted // This allows reflection to run on NEW tasks after an abort - function wasCurrentTaskAborted(sessionId: string, messages: any[], humanMsgCount: number): boolean { - // Fast path: check if this specific message count was already marked as aborted - const abortedCounts = abortedMsgCounts.get(sessionId) - if (abortedCounts?.has(humanMsgCount)) return true + function wasCurrentTaskAborted(sessionId: string, messages: any[], humanMsgId: string): boolean { + // Fast path: check if this specific message ID was already marked as aborted + const abortedIds = abortedMsgIds.get(sessionId) + if (abortedIds?.has(humanMsgId)) return true // Check if the LAST assistant message has an abort error // Only the last message matters - previous aborts don't block new tasks @@ -303,45 +335,29 @@ export const ReflectionPlugin: Plugin = async ({ client, directory }) => { // Check for MessageAbortedError if (error.name === "MessageAbortedError") { - // Mark this specific message count as aborted - if (!abortedMsgCounts.has(sessionId)) { - abortedMsgCounts.set(sessionId, new Set()) + // Mark this specific message ID as aborted + if (!abortedMsgIds.has(sessionId)) { + abortedMsgIds.set(sessionId, new Set()) } - abortedMsgCounts.get(sessionId)!.add(humanMsgCount) - debug("Marked task as aborted:", sessionId.slice(0, 8), "msgCount:", humanMsgCount) + abortedMsgIds.get(sessionId)!.add(humanMsgId) + debug("Marked task as aborted:", sessionId.slice(0, 8), "msgId:", humanMsgId) return true } // Also check error message content for abort indicators const errorMsg = error.data?.message || error.message || "" if (typeof errorMsg === "string" && errorMsg.toLowerCase().includes("abort")) { - if (!abortedMsgCounts.has(sessionId)) { - abortedMsgCounts.set(sessionId, new Set()) + if (!abortedMsgIds.has(sessionId)) { + abortedMsgIds.set(sessionId, new Set()) } - abortedMsgCounts.get(sessionId)!.add(humanMsgCount) - debug("Marked task as aborted:", sessionId.slice(0, 8), "msgCount:", humanMsgCount) + abortedMsgIds.get(sessionId)!.add(humanMsgId) + debug("Marked task as aborted:", sessionId.slice(0, 8), "msgId:", humanMsgId) return true } return false } - function countHumanMessages(messages: any[]): number { - let count = 0 - for (const msg of messages) { - if (msg.info?.role === "user") { - // Don't count reflection feedback as human input - for (const part of msg.parts || []) { - if (part.type === "text" && part.text && !part.text.includes("## Reflection:")) { - count++ - break - } - } - } - } - return count - } - function extractTaskAndResult(messages: any[]): { task: string; result: string; tools: string; isResearch: boolean; humanMessages: string[] } | null { const humanMessages: string[] = [] // ALL human messages in order (excluding reflection feedback) let result = "" @@ -409,9 +425,9 @@ export const ReflectionPlugin: Plugin = async ({ client, directory }) => { return null } - // Generate a key for tracking attempts per task (session + human message count) - function getAttemptKey(sessionId: string, humanMsgCount: number): string { - return `${sessionId}:${humanMsgCount}` + // Generate a key for tracking attempts per task (session + human message ID) + function getAttemptKey(sessionId: string, humanMsgId: string): string { + return `${sessionId}:${humanMsgId}` } // Check if a session is currently idle (agent not responding) @@ -968,36 +984,36 @@ Guidelines for nudgeMessage: return } - // Count human messages to determine current "task" - const humanMsgCount = countHumanMessages(messages) - debug("humanMsgCount:", humanMsgCount) - if (humanMsgCount === 0) { - debug("SKIP: no human messages") + // Identify current task by ID (robust against context compression) + const humanMsgId = getLastRelevantUserMessageId(messages) + debug("humanMsgId:", humanMsgId) + if (!humanMsgId) { + debug("SKIP: no relevant human messages") return } // Skip if current task was aborted/cancelled by user (Esc key) // This only skips the specific aborted task, not future tasks in the same session - if (wasCurrentTaskAborted(sessionId, messages, humanMsgCount)) { + if (wasCurrentTaskAborted(sessionId, messages, humanMsgId)) { debug("SKIP: current task was aborted") return } - // Check if we already completed reflection for this exact message count - const lastReflected = lastReflectedMsgCount.get(sessionId) || 0 - if (humanMsgCount <= lastReflected) { - debug("SKIP: already reflected for this message count", { humanMsgCount, lastReflected }) + // Check if we already completed reflection for this exact message ID + const lastReflected = lastReflectedMsgId.get(sessionId) + if (humanMsgId === lastReflected) { + debug("SKIP: already reflected for this message ID:", humanMsgId) return } - // Get attempt count for THIS specific task (session + message count) - const attemptKey = getAttemptKey(sessionId, humanMsgCount) + // Get attempt count for THIS specific task (session + message ID) + const attemptKey = getAttemptKey(sessionId, humanMsgId) const attemptCount = attempts.get(attemptKey) || 0 debug("attemptCount:", attemptCount, "/ MAX:", MAX_ATTEMPTS) if (attemptCount >= MAX_ATTEMPTS) { // Max attempts for this task - mark as reflected and stop - lastReflectedMsgCount.set(sessionId, humanMsgCount) + lastReflectedMsgId.set(sessionId, humanMsgId) await showToast(`Max attempts (${MAX_ATTEMPTS}) reached`, "warning") debug("SKIP: max attempts reached") return @@ -1170,7 +1186,8 @@ Reply with JSON only (no other text): "severity": "NONE|LOW|MEDIUM|HIGH|BLOCKER", "feedback": "brief explanation of verdict", "missing": ["list of missing required steps or evidence"], - "next_actions": ["concrete commands or checks to run"] + "next_actions": ["concrete commands or checks to run"], + "requires_human_action": true/false // NEW: set true ONLY if user must physically act (auth, hardware, 2FA) }` await client.session.promptAsync({ @@ -1184,7 +1201,7 @@ Reply with JSON only (no other text): if (!response) { debug("SKIP: waitForResponse returned null (timeout)") // Timeout - mark this task as reflected to avoid infinite retries - lastReflectedMsgCount.set(sessionId, humanMsgCount) + lastReflectedMsgId.set(sessionId, humanMsgId) return } debug("judge response received, length:", response.length) @@ -1192,7 +1209,7 @@ Reply with JSON only (no other text): const jsonMatch = response.match(/\{[\s\S]*\}/) if (!jsonMatch) { debug("SKIP: no JSON found in response") - lastReflectedMsgCount.set(sessionId, humanMsgCount) + lastReflectedMsgId.set(sessionId, humanMsgId) return } @@ -1220,7 +1237,7 @@ Reply with JSON only (no other text): if (isComplete) { // COMPLETE: mark this task as reflected, show toast only (no prompt!) - lastReflectedMsgCount.set(sessionId, humanMsgCount) + lastReflectedMsgId.set(sessionId, humanMsgId) attempts.delete(attemptKey) const toastMsg = severity === "NONE" ? "Task complete ✓" : `Task complete ✓ (${severity})` await showToast(toastMsg, "success") @@ -1231,7 +1248,7 @@ Reply with JSON only (no other text): if (abortTime && abortTime > reflectionStartTime) { debug("SKIP feedback: session was aborted after reflection started", "abortTime:", abortTime, "reflectionStart:", reflectionStartTime) - lastReflectedMsgCount.set(sessionId, humanMsgCount) // Mark as reflected to prevent retry + lastReflectedMsgId.set(sessionId, humanMsgId) // Mark as reflected to prevent retry return } @@ -1240,7 +1257,7 @@ Reply with JSON only (no other text): // The agent cannot complete these tasks - it's up to the user if (verdict.requires_human_action) { debug("REQUIRES_HUMAN_ACTION: notifying user, not agent") - lastReflectedMsgCount.set(sessionId, humanMsgCount) // Mark as reflected to prevent retry + lastReflectedMsgId.set(sessionId, humanMsgId) // Mark as reflected to prevent retry attempts.delete(attemptKey) // Reset attempts since this isn't agent's fault // Show helpful toast with what user needs to do @@ -1256,7 +1273,7 @@ Reply with JSON only (no other text): const hasMissingItems = verdict.missing?.length > 0 || verdict.next_actions?.length > 0 if (severity === "NONE" && !hasMissingItems) { debug("SKIP feedback: severity NONE and no missing items means waiting for user input") - lastReflectedMsgCount.set(sessionId, humanMsgCount) // Mark as reflected + lastReflectedMsgId.set(sessionId, humanMsgId) // Mark as reflected await showToast("Awaiting user input", "info") return } @@ -1284,30 +1301,32 @@ Reply with JSON only (no other text): body: { parts: [{ type: "text", - text: `## Reflection: Task Incomplete (${attemptCount + 1}/${MAX_ATTEMPTS}) [${severity}] + text: `## Reflection: Task Incomplete (${severity}) +${verdict.feedback} +${missing} +${nextActions} -${verdict.feedback || "Please review and complete the task."}${missing}${nextActions} - -Please address the above and continue.` +Please address these issues and continue.` }] } }) - // Schedule a nudge in case the agent gets stuck after receiving feedback + + // Schedule a nudge to ensure the agent continues if it gets stuck after feedback scheduleNudge(sessionId, STUCK_CHECK_DELAY, "reflection") - // Don't mark as reflected yet - we want to check again after agent responds } + + } catch (e) { + debug("Error in reflection evaluation:", e) } finally { - // Always clean up judge session to prevent clutter in /session list await cleanupJudgeSession() } + } catch (e) { - // On error, don't mark as reflected - allow retry debug("ERROR in runReflection:", e) } finally { activeReflections.delete(sessionId) } } - /** * Check all sessions for stuck state on startup. * This handles the case where OpenCode is restarted with -c (continue) diff --git a/telegram.ts b/telegram.ts index 2cd6efd..363acd6 100644 --- a/telegram.ts +++ b/telegram.ts @@ -1001,4 +1001,11 @@ export const TelegramPlugin: Plugin = async ({ client, directory }) => { } } +export const _test_internal = { + transcribeAudio, + findPython3, + findPython311, + startWhisperServer +} + export default TelegramPlugin diff --git a/test/telegram-internal.test.ts b/test/telegram-internal.test.ts new file mode 100644 index 0000000..4260b8c --- /dev/null +++ b/test/telegram-internal.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, jest, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import { _test_internal } from '../telegram.js'; + +const { transcribeAudio } = _test_internal; + +describe('Telegram Plugin Internals', () => { + const originalFetch = global.fetch; + + beforeAll(() => { + global.fetch = jest.fn() as any; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + beforeEach(() => { + (global.fetch as any).mockClear(); + }); + + it('transcribeAudio calls the correct endpoint /transcribe-base64', async () => { + const mockFetch = global.fetch as any; + + // Mock sequence: + // 1. /health -> 200 OK (server running) + // 2. /transcribe-base64 -> 200 OK (transcription result) + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: "healthy" }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ text: "Hello World", language: "en", duration: 1.0 }) + }); + + const config = { + whisper: { enabled: true, port: 9999 } + }; + + const result = await transcribeAudio("base64data", config); + + expect(result).toBe("Hello World"); + + // Verify calls + // Note: It might be called more times if retries happen, but we expect at least these 2 + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call: Health check + expect(mockFetch).toHaveBeenNthCalledWith(1, + expect.stringContaining("http://127.0.0.1:9999/health"), + expect.anything() + ); + + // Second call: Transcription (THE CRITICAL CHECK) + // This ensures we are calling /transcribe-base64 and NOT /transcribe + expect(mockFetch).toHaveBeenNthCalledWith(2, + expect.stringContaining("http://127.0.0.1:9999/transcribe-base64"), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("base64data") + }) + ); + }); + + it('transcribeAudio handles missing configuration gracefully', async () => { + const config = { + whisper: { enabled: false } + }; + const result = await transcribeAudio("data", config); + expect(result).toBeNull(); + }); +}); diff --git a/test/telegram.test.ts b/test/telegram.test.ts index 80b8507..c7b0f0c 100644 --- a/test/telegram.test.ts +++ b/test/telegram.test.ts @@ -133,7 +133,7 @@ describe("Message Delivery: OpenCode -> Telegram", () => { // Small delay to avoid rate limiting await new Promise(r => setTimeout(r, 500)) } - }) + }, 30000) }) // ============================================================================