diff --git a/apps/web-ui/app/api/agent-ops/settings/telegram/route.ts b/apps/web-ui/app/api/agent-ops/settings/telegram/route.ts index 14701a5c..b6b1ed38 100644 --- a/apps/web-ui/app/api/agent-ops/settings/telegram/route.ts +++ b/apps/web-ui/app/api/agent-ops/settings/telegram/route.ts @@ -11,6 +11,8 @@ import { TenantConfigService } from '@/lib/tenant-config-service'; import { getSessionTenantId, getAuthSession } from '@/lib/auth-session'; import { AuditService } from '@/lib/audit-service'; import { authorize } from '@/lib/rbac/authorize'; +import { getTelegramBotLinkRepository } from '@/lib/db/repository-factory'; +import { TelegramBotLinkConflictError } from '@/lib/db/repositories/telegram-bot-link/interface'; import type { TelegramIntegrationConfig } from '@/lib/agent-ops/types'; const CONFIG_KEY = 'agent-ops-telegram'; @@ -93,6 +95,24 @@ export async function PUT(req: Request) { ); } + // Inbound Telegram updates carry no tenant-identifying field — the secret + // token is the only value that can resolve which tenant a request belongs + // to (see TelegramBotLink), so keep the reverse-index in sync with every save. + const linkRepo = getTelegramBotLinkRepository(); + if (secretToken !== existing?.secretToken) { + // Secret is new or rotating — drop any previous link for this tenant so a + // stale row from the old secret doesn't linger in the lookup table. + await linkRepo.deleteLinkForTenant(tenantId); + } + try { + await linkRepo.upsertLink({ secretToken, tenantId }); + } catch (error) { + if (error instanceof TelegramBotLinkConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + throw error; + } + const config: TelegramIntegrationConfig = { botToken, secretToken, @@ -149,6 +169,7 @@ export async function DELETE() { const tenantId = await getSessionTenantId(); await TenantConfigService.deleteConfig(CONFIG_KEY, tenantId); + await getTelegramBotLinkRepository().deleteLinkForTenant(tenantId); console.log('[API /agent-ops/settings/telegram] Reset Telegram config for tenant:', tenantId); diff --git a/apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts b/apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts index 070e227e..379b4542 100644 --- a/apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts +++ b/apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildMemoryPart, humanizeReflection, humanizePlanning, isWorkingMemoryPayload, stripWorkingMemoryPrelude } from '@/app/api/chat/stream-parts'; +import { buildUsagePart } from '../stream-parts'; const WM_JSON = JSON.stringify({ summary: 'User asked to connect Jira; site resource retrieved.', @@ -229,3 +230,9 @@ describe('stripWorkingMemoryPrelude', () => { expect(stripWorkingMemoryPrelude(raw)).toBe(raw); }); }); + +describe('buildUsagePart', () => { + it('builds a data-usage part', () => { + expect(buildUsagePart(3, 4)).toEqual({ type: 'data-usage', data: { input: 3, output: 4 } }); + }); +}); diff --git a/apps/web-ui/app/api/chat/route.ts b/apps/web-ui/app/api/chat/route.ts index 776b4a31..87eb8b65 100644 --- a/apps/web-ui/app/api/chat/route.ts +++ b/apps/web-ui/app/api/chat/route.ts @@ -9,7 +9,7 @@ import { triageChatMessage } from '@/lib/agent/triage'; import { respondDirect } from './direct-chat'; import { resolveKnowledgeBaseIds } from '@/lib/agent/auto-kb-select'; import { acquireThreadLock, releaseThreadLock as releaseThreadLockDb } from '@/lib/agent/thread-lock'; -import { buildPlanPart, buildPhasePart, buildInterruptParts, buildMemoryPart, humanizeReflection, humanizePlanning, isWorkingMemoryPayload, stripWorkingMemoryPrelude } from './stream-parts'; +import { buildPlanPart, buildPhasePart, buildInterruptParts, buildMemoryPart, buildUsagePart, humanizeReflection, humanizePlanning, isWorkingMemoryPayload, stripWorkingMemoryPrelude } from './stream-parts'; import { resolveResumedToolCallId, type ResumedPendingCall } from './resume-tool-id'; import type { PlanStep } from '@/lib/agent/agent-shared'; @@ -707,6 +707,8 @@ function processStream( // annotate persisted AI messages with phase markers for history replay. const phaseList: AgentPhase[] = []; + const runUsage = { input: 0, output: 0 }; + // Flag to track if we're resuming from a HITL approval // and need to use the original toolCallId let isResumedFromApproval = !!resumedToolCallId; @@ -930,6 +932,16 @@ function processStream( } } else if (event.event === "on_chat_model_end") { + const endUsage = (event.data?.output as { usage_metadata?: { input_tokens?: number; output_tokens?: number } } | undefined)?.usage_metadata; + if (endUsage) { + const inTok = Number(endUsage.input_tokens) || 0; + const outTok = Number(endUsage.output_tokens) || 0; + if (inTok || outTok) { + runUsage.input += inTok; + runUsage.output += outTok; + safeEnqueue(buildUsagePart(inTok, outTok) as UIMessageChunk); + } + } if (streamStarted) { if (!safeEnqueue({ type: 'text-end', id: currentPartId })) break; streamStarted = false; @@ -1218,6 +1230,18 @@ function processStream( return { role, content, metadata: Object.keys(metadata).length ? metadata : undefined }; }); + if (runUsage.input || runUsage.output) { + for (let i = mapped.length - 1; i >= 0; i--) { + if (mapped[i].role === 'ai') { + mapped[i].metadata = { + ...(mapped[i].metadata ?? {}), + usage_metadata: { input_tokens: runUsage.input, output_tokens: runUsage.output }, + }; + break; + } + } + } + // Insert display-only memory messages so history matches the live view. // Phase markers are prepended here (these aren't graph-state messages, so // they bypass the marker loop above); the history route reconstructs them diff --git a/apps/web-ui/app/api/chat/stream-parts.ts b/apps/web-ui/app/api/chat/stream-parts.ts index 7ef605a0..fd881ac3 100644 --- a/apps/web-ui/app/api/chat/stream-parts.ts +++ b/apps/web-ui/app/api/chat/stream-parts.ts @@ -12,6 +12,10 @@ export function buildPhasePart(phase: string, node: string): DataPart { return { type: 'data-phase', data: { phase, node, ts: Date.now() } }; } +export function buildUsagePart(input: number, output: number): DataPart { + return { type: 'data-usage', data: { input, output } }; +} + /** * One data-memory part per memory model run (recall or save). `count` is the * number of markdown bullet lines in the accumulated summary (matches diff --git a/apps/web-ui/app/api/threads/[threadId]/history/route.ts b/apps/web-ui/app/api/threads/[threadId]/history/route.ts index ed5b6205..177dda96 100644 --- a/apps/web-ui/app/api/threads/[threadId]/history/route.ts +++ b/apps/web-ui/app/api/threads/[threadId]/history/route.ts @@ -4,6 +4,8 @@ import { getChatHistory } from '@/lib/agent/persistence'; import { getSessionTenantId, getSessionUserId } from '@/lib/auth-session'; import { AIMessage, HumanMessage, ToolMessage, BaseMessage } from '@langchain/core/messages'; import { normalizeLegacyContent } from '@/lib/agent-chat/legacy-normalizer'; +import { reconstructAiContentParts } from '@/lib/agent-chat/ai-content-parts'; +import { parseUsageMetadata } from '@/lib/agent-chat/token-usage'; import { humanizePlanning, stripWorkingMemoryPrelude } from '@/app/api/chat/stream-parts'; interface HistoryMessage { @@ -114,12 +116,23 @@ function convertPlainMessage(msg: { role: string; content: string; metadata?: Re } if (role === 'ai') { const toolCalls = metadata?.tool_calls as Array<{ id?: string; name: string; args: Record }> | undefined; - const parts: HistoryMessage['parts'] = content ? [...(buildPhaseParts(content) ?? [])] : []; + // Content-block arrays (extended-thinking) are reconstructed block-by-block; everything else keeps the marker path. + const reconstructed = reconstructAiContentParts(msg.content); + const parts: HistoryMessage['parts'] = reconstructed !== null + ? [...reconstructed] + : (content ? [...(buildPhaseParts(content) ?? [])] : []); + const displayContent = reconstructed !== null + ? reconstructed.filter((p) => p.type === 'text').map((p) => p.text).join('') + : content; for (const tc of toolCalls ?? []) { parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args, state: 'call' }); } + const usage = parseUsageMetadata(metadata?.usage_metadata); + if (usage && parts.length > 0) { + parts.push({ type: 'data-usage', data: { input: usage.input, output: usage.output } }); + } if (parts.length === 0) return null; - return { id: `history-${index}`, role: 'assistant', content, parts }; + return { id: `history-${index}`, role: 'assistant', content: displayContent, parts }; } if (role === 'tool') { const toolCallId = metadata?.tool_call_id as string | undefined; @@ -138,12 +151,18 @@ function convertMessage(msg: BaseMessage, index: number): HistoryMessage | null } if (msgType === 'ai') { const aiMsg = msg as AIMessage; - const parts: HistoryMessage['parts'] = content ? [...(buildPhaseParts(content) ?? [])] : []; + const reconstructed = reconstructAiContentParts(aiMsg.content); + const parts: HistoryMessage['parts'] = reconstructed !== null + ? [...reconstructed] + : (content ? [...(buildPhaseParts(content) ?? [])] : []); + const displayContent = reconstructed !== null + ? reconstructed.filter((p) => p.type === 'text').map((p) => p.text).join('') + : (content || ''); for (const tc of aiMsg.tool_calls ?? []) { parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args as Record, state: 'call' }); } if (parts.length === 0) return null; - return { id: `history-${index}`, role: 'assistant', content: content || '', parts }; + return { id: `history-${index}`, role: 'assistant', content: displayContent, parts }; } if (msgType === 'tool') { const toolMsg = msg as ToolMessage; diff --git a/apps/web-ui/components/agent/chat/__tests__/run-rail.test.tsx b/apps/web-ui/components/agent/chat/__tests__/run-rail.test.tsx index 555de7bf..6a648a83 100644 --- a/apps/web-ui/components/agent/chat/__tests__/run-rail.test.tsx +++ b/apps/web-ui/components/agent/chat/__tests__/run-rail.test.tsx @@ -13,6 +13,7 @@ const EMPTY_RUN_STATE: RunState = { pendingClarifications: [], hasStructuredData: false, hasApprovalData: false, + tokenUsage: { input: 0, output: 0 }, } const CONTEXT = { accountNames: [], modelLabel: '', skillName: null, toolCount: null, kbLabel: 'No knowledge base' } diff --git a/apps/web-ui/components/agent/chat/__tests__/run-state.test.ts b/apps/web-ui/components/agent/chat/__tests__/run-state.test.ts index 0f09164f..0fef2183 100644 --- a/apps/web-ui/components/agent/chat/__tests__/run-state.test.ts +++ b/apps/web-ui/components/agent/chat/__tests__/run-state.test.ts @@ -241,3 +241,23 @@ describe('isRejectedToolResult', () => { expect(isRejectedToolResult(['Rejected by user'])).toBe(false); }); }); + +describe('deriveRunState token usage', () => { + const asst = (parts: any[]) => ({ role: 'assistant', id: 'm', parts }); + + it('sums data-usage parts across the thread', () => { + const state = deriveRunState([ + asst([ + { type: 'data-usage', data: { input: 100, output: 20 } }, + { type: 'data-usage', data: { input: 50, output: 10 } }, + ]), + asst([{ type: 'data-usage', data: { input: 5, output: 1 } }]), + ], new Set()); + expect(state.tokenUsage).toEqual({ input: 155, output: 31 }); + }); + + it('defaults to zero when there are no data-usage parts', () => { + const state = deriveRunState([asst([{ type: 'text', text: 'hi' }])], new Set()); + expect(state.tokenUsage).toEqual({ input: 0, output: 0 }); + }); +}); diff --git a/apps/web-ui/components/agent/chat/run-state.ts b/apps/web-ui/components/agent/chat/run-state.ts index bf8b7b03..d05c97bb 100644 --- a/apps/web-ui/components/agent/chat/run-state.ts +++ b/apps/web-ui/components/agent/chat/run-state.ts @@ -24,6 +24,8 @@ export interface RunState { /** True when ANY data-approval part was seen in the thread — such threads use * Mission Control batch cards, never the legacy inline per-tool Confirmation. */ hasApprovalData: boolean; + /** Cumulative billed token totals summed from every data-usage part in the thread. */ + tokenUsage: { input: number; output: number }; } interface LoosePart { type: string; data?: any; text?: string } @@ -40,6 +42,8 @@ export function deriveRunState( const clarifications: PendingClarification[] = []; let hasStructuredData = false; let hasApprovalData = false; + let tokenIn = 0; + let tokenOut = 0; // Tool calls with an output-bearing tool part ANYWHERE in the thread. An // executed / rejected / answered tool can never be pending again, no matter // what a stale data-approval or data-clarification part claims (defense @@ -92,6 +96,11 @@ export function deriveRunState( } break; } + case 'data-usage': { + tokenIn += Number(part.data?.input) || 0; + tokenOut += Number(part.data?.output) || 0; + break; + } } } } @@ -112,6 +121,7 @@ export function deriveRunState( pendingClarifications, hasStructuredData, hasApprovalData, + tokenUsage: { input: tokenIn, output: tokenOut }, }; } diff --git a/apps/web-ui/components/agent/workspace/__tests__/agent-turn.test.tsx b/apps/web-ui/components/agent/workspace/__tests__/agent-turn.test.tsx index e297efcd..bc4b6880 100644 --- a/apps/web-ui/components/agent/workspace/__tests__/agent-turn.test.tsx +++ b/apps/web-ui/components/agent/workspace/__tests__/agent-turn.test.tsx @@ -18,6 +18,7 @@ const EMPTY_RUN_STATE: RunState = { pendingClarifications: [], hasStructuredData: false, hasApprovalData: false, + tokenUsage: { input: 0, output: 0 }, } function baseProps(events: TranscriptEvent[]) { diff --git a/apps/web-ui/components/agent/workspace/__tests__/composer.test.tsx b/apps/web-ui/components/agent/workspace/__tests__/composer.test.tsx index b14697b7..17c4b574 100644 --- a/apps/web-ui/components/agent/workspace/__tests__/composer.test.tsx +++ b/apps/web-ui/components/agent/workspace/__tests__/composer.test.tsx @@ -208,9 +208,10 @@ describe('Composer', () => { expect(screen.queryByTestId('char-counter')).toBeNull() }) + // Thresholds live in composer.tsx: MAX_CHARS 6000, CHAR_WARNING_THRESHOLD 5400. it('char counter is visible near the limit', () => { - render() - expect(screen.getByTestId('char-counter').textContent).toBe('1900/2000') + render() + expect(screen.getByTestId('char-counter').textContent).toBe('5500/6000') }) it('shows Send while idle and calls onSubmit when clicked', () => { diff --git a/apps/web-ui/components/agent/workspace/__tests__/transcript-header.test.tsx b/apps/web-ui/components/agent/workspace/__tests__/transcript-header.test.tsx index 22462739..0989d44e 100644 --- a/apps/web-ui/components/agent/workspace/__tests__/transcript-header.test.tsx +++ b/apps/web-ui/components/agent/workspace/__tests__/transcript-header.test.tsx @@ -29,6 +29,7 @@ const EMPTY_RUN_STATE: RunState = { pendingClarifications: [], hasStructuredData: false, hasApprovalData: false, + tokenUsage: { input: 0, output: 0 }, } function planOf(doneCount: number, total: number): RunState['plan'] { @@ -54,6 +55,43 @@ describe('TranscriptHeader', () => { expect(screen.queryByTestId('phase-stepper')).toBeNull() }) + it('shows the token counter when tokenUsage is nonzero', () => { + const runState: RunState = { + ...EMPTY_RUN_STATE, + tokenUsage: { input: 48200, output: 6100 }, + } + + render( + + ) + + const counter = screen.getByTestId('token-usage') + expect(counter.textContent).toContain('48.2k') + expect(counter.textContent).toContain('6.1k') + expect(counter.getAttribute('title')).toContain('Incoming') + expect(counter.getAttribute('title')).toContain('Outgoing') + }) + + it('hides the token counter when tokenUsage is zero', () => { + render( + + ) + + expect(screen.queryByTestId('token-usage')).toBeNull() + }) + it('execution phase with 12/19 plan: Plan done, Execute active showing 12/19', () => { const runState: RunState = { ...EMPTY_RUN_STATE, diff --git a/apps/web-ui/components/agent/workspace/composer.tsx b/apps/web-ui/components/agent/workspace/composer.tsx index 57d4ba19..4e38f6d4 100644 --- a/apps/web-ui/components/agent/workspace/composer.tsx +++ b/apps/web-ui/components/agent/workspace/composer.tsx @@ -15,8 +15,8 @@ import { AccountChip, KbSection, ModelChip, SkillChip, ToolsSection, type Compos export type { ComposerContext } from "./composer-pickers"; -const MAX_CHARS = 2000; -const CHAR_WARNING_THRESHOLD = 1800; +const MAX_CHARS = 6000; +const CHAR_WARNING_THRESHOLD = 5400; const TEXTAREA_MAX_HEIGHT_PX = 192; // matches max-h-48 // Must stay in sync with the modes /api/chat accepts (fast | plan | deep) — diff --git a/apps/web-ui/components/agent/workspace/transcript-header.tsx b/apps/web-ui/components/agent/workspace/transcript-header.tsx index 91b5b56e..f8b2c3b0 100644 --- a/apps/web-ui/components/agent/workspace/transcript-header.tsx +++ b/apps/web-ui/components/agent/workspace/transcript-header.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { RunState } from "@/components/agent/chat/run-state"; +import { formatTokens } from "@/lib/agent-chat/token-usage"; export type TranscriptMenuAction = "export-report" | "export-md" | "export-pdf" | "copy" | "schedule" | "skill" | "clear"; @@ -137,6 +138,15 @@ export function TranscriptHeader({ title, runState, isStreaming, elapsedMs, onMe )}
+ {(runState.tokenUsage.input > 0 || runState.tokenUsage.output > 0) && ( + + ↓ {formatTokens(runState.tokenUsage.input)} · ↑ {formatTokens(runState.tokenUsage.output)} + + )} {elapsedMs != null && ( {formatElapsed(elapsedMs)} diff --git a/apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts b/apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts new file mode 100644 index 00000000..9d23220c --- /dev/null +++ b/apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { reconstructAiContentParts } from '../ai-content-parts'; +import { buildTranscript } from '../events'; + +const noOpts = { + isStreaming: false, + toolVisibility: new Map(), + decisions: new Map(), +}; + +describe('reconstructAiContentParts', () => { + it('returns null for a plain (non-array) string so the caller keeps the marker path', () => { + expect(reconstructAiContentParts('PLANNING_PHASE_START\nplan text')).toBeNull(); + expect(reconstructAiContentParts('just an answer')).toBeNull(); + }); + + it('returns [] for an array of only empty-text reasoning blocks', () => { + const raw = JSON.stringify([{ type: 'reasoning', reasoning: '', signature: 'sig' }]); + expect(reconstructAiContentParts(raw)).toEqual([]); + }); + + it('reconstructs a reasoning block that has text into a single reasoning part', () => { + const raw = JSON.stringify([{ type: 'reasoning', reasoning: 'Let me think about X.' }]); + expect(reconstructAiContentParts(raw)).toEqual([{ type: 'reasoning', text: 'Let me think about X.' }]); + }); + + it('reconstructs a mixed [reasoning, text] array preserving order', () => { + const raw = JSON.stringify([ + { type: 'thinking', thinking: 'thinking hard' }, + { type: 'text', text: 'the answer' }, + ]); + expect(reconstructAiContentParts(raw)).toEqual([ + { type: 'reasoning', text: 'thinking hard' }, + { type: 'text', text: 'the answer' }, + ]); + }); + + it('extracts the raw Bedrock reasoningContent shape', () => { + const raw = JSON.stringify([{ reasoningContent: { reasoningText: { text: 'deep thought' } } }]); + expect(reconstructAiContentParts(raw)).toEqual([{ type: 'reasoning', text: 'deep thought' }]); + }); + + it('drops a reasoningContent block whose text is null', () => { + const raw = JSON.stringify([{ reasoningContent: { reasoningText: { text: null } } }]); + expect(reconstructAiContentParts(raw)).toEqual([]); + }); + + it('skips tool_use blocks (tool calls come from metadata)', () => { + const raw = JSON.stringify([ + { type: 'reasoning', reasoning: '' }, + { type: 'tool_use', id: 't1', name: 'do_it', input: {} }, + ]); + expect(reconstructAiContentParts(raw)).toEqual([]); + }); + + it('skips empty/whitespace text blocks', () => { + const raw = JSON.stringify([{ type: 'text', text: ' ' }, { type: 'text', text: 'real' }]); + expect(reconstructAiContentParts(raw)).toEqual([{ type: 'text', text: 'real' }]); + }); + + it('accepts an already-parsed array (checkpoint-fallback path)', () => { + expect(reconstructAiContentParts([{ type: 'text', text: 'hi' }])).toEqual([{ type: 'text', text: 'hi' }]); + }); +}); + +describe('render parity with live (via buildTranscript)', () => { + it('a reasoning part with text yields a thinking event, like live', () => { + const parts = reconstructAiContentParts(JSON.stringify([{ type: 'reasoning', reasoning: 'hmm' }]))!; + const events = buildTranscript({ id: 'm1', role: 'assistant', parts } as any, noOpts); + expect(events).toEqual([expect.objectContaining({ kind: 'thinking', text: 'hmm' })]); + }); + + it('the reducer renders nothing for an empty reasoning part (documents live behavior)', () => { + const events = buildTranscript({ id: 'm1', role: 'assistant', parts: [{ type: 'reasoning', text: '' }] } as any, noOpts); + expect(events).toEqual([]); + }); +}); diff --git a/apps/web-ui/lib/agent-chat/__tests__/events.test.ts b/apps/web-ui/lib/agent-chat/__tests__/events.test.ts index ce90bb77..90184a58 100644 --- a/apps/web-ui/lib/agent-chat/__tests__/events.test.ts +++ b/apps/web-ui/lib/agent-chat/__tests__/events.test.ts @@ -322,3 +322,10 @@ describe('rail/card-only parts', () => { expect(events).toEqual([]); }); }); + +describe('data-usage parts', () => { + it('produce no transcript event', () => { + const events = buildTranscript(msg([{ type: 'data-usage', data: { input: 10, output: 2 } }]), noOpts); + expect(events).toEqual([]); + }); +}); diff --git a/apps/web-ui/lib/agent-chat/__tests__/token-usage.test.ts b/apps/web-ui/lib/agent-chat/__tests__/token-usage.test.ts new file mode 100644 index 00000000..1923008f --- /dev/null +++ b/apps/web-ui/lib/agent-chat/__tests__/token-usage.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { parseUsageMetadata, formatTokens } from '../token-usage'; + +describe('parseUsageMetadata', () => { + it('reads the LangChain input_tokens/output_tokens shape', () => { + expect(parseUsageMetadata({ input_tokens: 3, output_tokens: 4 })).toEqual({ input: 3, output: 4 }); + }); + it('reads the {input,output} data-part shape', () => { + expect(parseUsageMetadata({ input: 5, output: 6 })).toEqual({ input: 5, output: 6 }); + }); + it('returns null when both are zero/absent, or meta is not an object', () => { + expect(parseUsageMetadata({ input_tokens: 0, output_tokens: 0 })).toBeNull(); + expect(parseUsageMetadata({})).toBeNull(); + expect(parseUsageMetadata(null)).toBeNull(); + expect(parseUsageMetadata('x')).toBeNull(); + }); + it('keeps a positive side even if the other is missing', () => { + expect(parseUsageMetadata({ output_tokens: 9 })).toEqual({ input: 0, output: 9 }); + }); +}); + +describe('formatTokens', () => { + it('formats by magnitude', () => { + expect(formatTokens(0)).toBe('0'); + expect(formatTokens(999)).toBe('999'); + expect(formatTokens(1000)).toBe('1.0k'); + expect(formatTokens(48200)).toBe('48.2k'); + expect(formatTokens(1_500_000)).toBe('1.5m'); + }); +}); diff --git a/apps/web-ui/lib/agent-chat/ai-content-parts.ts b/apps/web-ui/lib/agent-chat/ai-content-parts.ts new file mode 100644 index 00000000..16a5b6ab --- /dev/null +++ b/apps/web-ui/lib/agent-chat/ai-content-parts.ts @@ -0,0 +1,53 @@ +// Reconstructs UI parts from a persisted content-block array (Anthropic/Bedrock shapes); without this, history renders raw JSON. +type AiContentPart = { type: 'reasoning' | 'text'; text: string }; + +function normalizeToBlockArray(raw: unknown): unknown[] | null { + if (Array.isArray(raw)) return raw; + if (typeof raw === 'string') { + if (!raw.trimStart().startsWith('[')) return null; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + } + return null; +} + +function isReasoningBlock(b: Record): boolean { + if ('reasoningContent' in b || 'reasoning_content' in b) return true; + const t = b.type; + return t === 'reasoning' || t === 'thinking' || t === 'reasoning_content' + || t === 'redacted_reasoning' || t === 'redacted_thinking'; +} + +function extractReasoningText(b: Record): string { + if (typeof b.reasoning === 'string') return b.reasoning; + if (typeof b.thinking === 'string') return b.thinking; + const rc = b.reasoningContent as { reasoningText?: { text?: unknown } } | undefined; + if (rc?.reasoningText && typeof rc.reasoningText.text === 'string') return rc.reasoningText.text; + return ''; +} + +// null = not a content-block array (caller keeps the string/marker path); otherwise reconstructed parts (possibly empty). +export function reconstructAiContentParts(rawContent: unknown): AiContentPart[] | null { + const blocks = normalizeToBlockArray(rawContent); + if (blocks === null) return null; + + const parts: AiContentPart[] = []; + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type === 'text' && typeof b.text === 'string') { + if (b.text.trim().length > 0) parts.push({ type: 'text', text: b.text }); + continue; + } + if (isReasoningBlock(b)) { + const text = extractReasoningText(b); + if (text.trim().length > 0) parts.push({ type: 'reasoning', text }); + } + // tool_use / image / unknown → skipped (tool calls come from metadata.tool_calls) + } + return parts; +} diff --git a/apps/web-ui/lib/agent-chat/token-usage.ts b/apps/web-ui/lib/agent-chat/token-usage.ts new file mode 100644 index 00000000..3a2f4e05 --- /dev/null +++ b/apps/web-ui/lib/agent-chat/token-usage.ts @@ -0,0 +1,19 @@ +export interface TokenUsage { input: number; output: number } + +// Accepts the LangChain shape (input_tokens/output_tokens) or the data-usage shape (input/output). +export function parseUsageMetadata(meta: unknown): TokenUsage | null { + if (!meta || typeof meta !== 'object') return null; + const m = meta as Record; + const input = Number(m.input_tokens ?? m.input) || 0; + const output = Number(m.output_tokens ?? m.output) || 0; + if (!input && !output) return null; + return { input, output }; +} + +// <1000 -> integer; >=1000 -> one-decimal "k"; >=1e6 -> "m". +export function formatTokens(n: number): string { + if (!Number.isFinite(n) || n <= 0) return '0'; + if (n < 1000) return String(Math.round(n)); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}m`; +} diff --git a/apps/web-ui/lib/agent-ops/agent-ops-service.ts b/apps/web-ui/lib/agent-ops/agent-ops-service.ts index 9619e052..06fdf049 100644 --- a/apps/web-ui/lib/agent-ops/agent-ops-service.ts +++ b/apps/web-ui/lib/agent-ops/agent-ops-service.ts @@ -201,6 +201,25 @@ export async function findAwaitingRunBySlackThread( return getAgentOpsRunRepository().findAwaitingRunBySlackThread(channelId, threadTs); } +/** + * Resolve the run a Telegram follow-up should feed into: the chat's run that is + * currently awaiting the user's answer (within the idle window). Once a run has + * completed, the next message is a new task — so this only ever returns a run + * that is actively waiting on input. + */ +export async function findResumableTelegramRun(chatId: number, idleCutoff: Date): Promise { + return getAgentOpsRunRepository().findResumableTelegramRun(chatId, idleCutoff); +} + +/** + * End the current Telegram conversation (the /new command): cancel the run that is + * awaiting input so the pending question is dropped and the next message starts a + * fresh run instead of being read as the answer. + */ +export async function closeTelegramSession(tenantId: string, runId: string): Promise { + await updateRunStatus(tenantId, runId, 'cancelled'); +} + /** * Update the slackMessageTs on an existing approvalRequest (after posting Block Kit message). */ @@ -235,5 +254,7 @@ export const agentOpsService = { findAwaitingRunByJiraIssue, findAwaitingApprovalRunByJiraIssue, findAwaitingRunBySlackThread, + findResumableTelegramRun, + closeTelegramSession, findAwaitingApprovalRun, }; diff --git a/apps/web-ui/lib/agent/agent-shared.ts b/apps/web-ui/lib/agent/agent-shared.ts index 9dc0e3c3..fb83ab48 100644 --- a/apps/web-ui/lib/agent/agent-shared.ts +++ b/apps/web-ui/lib/agent/agent-shared.ts @@ -674,6 +674,28 @@ function stripReasoningBlocks(msg: BaseMessage): BaseMessage { }); } +// Empty AI content (post-strip) with no tool calls — rewritten, not dropped, so Bedrock role alternation is preserved. +function isEmptyAiMessageContent(ai: AIMessage): boolean { + if (Array.isArray(ai.tool_calls) && ai.tool_calls.length > 0) return false; + + const content = ai.content; + if (content == null) return true; + if (typeof content === 'string') return content.trim() === ''; + if (!Array.isArray(content)) return false; // unknown non-array content — treat as present + if (content.length === 0) return true; + + // Non-empty array: empty only if EVERY block is an empty/whitespace text block. + return (content as unknown[]).every((block) => { + if (block && typeof block === 'object') { + const b = block as Record; + if (b.type === 'text') { + return typeof b.text !== 'string' || b.text.trim() === ''; + } + } + return false; // tool_use, image, or any other block => real content + }); +} + export function sanitizeMessagesForBedrock(messages: BaseMessage[]): BaseMessage[] { // Bedrock requires every toolResult to IMMEDIATELY follow the assistant // toolUse that owns it — "answered somewhere in the array" is not enough. @@ -710,7 +732,22 @@ export function sanitizeMessagesForBedrock(messages: BaseMessage[]): BaseMessage // Strip reasoning/thinking blocks from AI messages — they come back with a // null reasoningText after a checkpoint round-trip and Bedrock rejects them. - const cleaned = stripReasoningBlocks(msg); + let cleaned = stripReasoningBlocks(msg); + + // Rewrite (never drop) an emptied reasoning-only message: empty content crashes Bedrock, and dropping breaks role alternation. + if (cleaned._getType() === 'ai' && isEmptyAiMessageContent(cleaned as AIMessage)) { + const ai = cleaned as AIMessage; + cleaned = new AIMessage({ + content: '(reasoning omitted)', + tool_calls: ai.tool_calls, + additional_kwargs: ai.additional_kwargs, + response_metadata: ai.response_metadata, + id: ai.id, + name: ai.name, + usage_metadata: ai.usage_metadata, + }); + } + result.push(cleaned); if (msg._getType() !== 'ai') continue; diff --git a/apps/web-ui/lib/agent/model-factory.ts b/apps/web-ui/lib/agent/model-factory.ts index 11d00ddd..68d5ffa4 100644 --- a/apps/web-ui/lib/agent/model-factory.ts +++ b/apps/web-ui/lib/agent/model-factory.ts @@ -40,10 +40,10 @@ export interface AgentModels { reflector: BaseChatModel; } -/** Conservative default output-token cap when a provider record doesn't specify one. - * 4096 is safe across small/local models and matches the Anthropic/Bedrock defaults — - * the previous 40000 OpenAI-path default overflowed 8k–32k context windows. */ -export const DEFAULT_MAX_OUTPUT_TOKENS = 4096; +/** Default output-token cap when a provider record doesn't specify one. 8192 suits the + * Anthropic/Bedrock Claude models used by AI Ops; a small/local model with a lower hard + * output limit should set maxTokens on its ProviderModel record to override this. */ +export const DEFAULT_MAX_OUTPUT_TOKENS = 8192; /** Provider-level context-window estimates (tokens). The ProviderModel schema has no * per-model context-window field, so we approximate at the provider family level — no diff --git a/apps/web-ui/lib/agent/sanitize-bedrock.test.ts b/apps/web-ui/lib/agent/sanitize-bedrock.test.ts index c0ca89da..0614cf35 100644 --- a/apps/web-ui/lib/agent/sanitize-bedrock.test.ts +++ b/apps/web-ui/lib/agent/sanitize-bedrock.test.ts @@ -44,4 +44,90 @@ describe('sanitizeMessagesForBedrock — reasoning content', () => { const out = sanitizeMessagesForBedrock(input); expect(out[1]).toBe(ai); }); + + it('rewrites a reasoning-only AI message (empty after strip, no tool_calls) to a non-empty placeholder', () => { + const reasoningOnly = new AIMessage({ + content: [ + { reasoningContent: { reasoningText: { text: null, signature: 'sig' } } } as any, + ], + }); + const out = sanitizeMessagesForBedrock([ + new HumanMessage('first question'), + reasoningOnly, + new HumanMessage('Proceed.'), + ]); + + // No AI message may leave sanitize with empty content. + const emptyAi = out.find((m) => { + if (m._getType() !== 'ai') return false; + const c = m.content; + if (typeof c === 'string') return c.trim() === ''; + return Array.isArray(c) && c.length === 0; + }); + expect(emptyAi).toBeUndefined(); + + // The rewritten message is present, in place, with the placeholder text. + expect(out).toHaveLength(3); + expect(out[1]._getType()).toBe('ai'); + expect(out[1].content).toBe('(reasoning omitted)'); + }); + + it('preserves message count and role order (no consecutive same-role pair)', () => { + const input = [ + new HumanMessage('q1'), + new AIMessage({ content: [{ reasoningContent: { reasoningText: { text: null } } } as any] }), + new HumanMessage('Proceed.'), + new AIMessage({ content: 'the real answer' }), + new HumanMessage('q2'), + ]; + const out = sanitizeMessagesForBedrock(input); + + expect(out.map((m) => m._getType())).toEqual(['human', 'ai', 'human', 'ai', 'human']); + for (let i = 1; i < out.length; i++) { + expect(out[i]._getType()).not.toBe(out[i - 1]._getType()); + } + }); + + it('does NOT rewrite an AI message that is empty-after-strip but has tool_calls', () => { + const toolTurn = new AIMessage({ + content: [ + { reasoningContent: { reasoningText: { text: null } } } as any, + { type: 'tool_use', id: 't1', name: 'do_it', input: {} } as any, + ], + tool_calls: [{ id: 't1', name: 'do_it', args: {}, type: 'tool_call' }], + }); + const out = sanitizeMessagesForBedrock([ + toolTurn, + new ToolMessage({ content: 'ok', tool_call_id: 't1' }), + ]); + + const ai = out[0] as AIMessage; + expect(ai._getType()).toBe('ai'); + expect(ai.content).not.toBe('(reasoning omitted)'); // untouched, not rewritten + expect(Array.isArray(ai.content)).toBe(true); + expect((ai.content as any[]).some((b) => b.type === 'tool_use')).toBe(true); + expect(ai.tool_calls?.[0]?.id).toBe('t1'); + // tool result still re-emitted immediately after its owning AI message + expect(out[1]._getType()).toBe('tool'); + expect((out[1] as any).tool_call_id).toBe('t1'); + }); + + it('leaves an AI message untouched when it has a tool_use content block but no normalized tool_calls', () => { + const toolBlockOnly = new AIMessage({ + content: [ + { reasoningContent: { reasoningText: { text: null } } } as any, + { type: 'tool_use', id: 't9', name: 'do_it', input: {} } as any, + ], + // no tool_calls — the checkpoint-round-trip divergence the file warns about + }); + const out = sanitizeMessagesForBedrock([ + toolBlockOnly, + new ToolMessage({ content: 'ok', tool_call_id: 't9' }), + ]); + const ai = out[0] as AIMessage; + expect(ai.content).not.toBe('(reasoning omitted)'); + expect(Array.isArray(ai.content)).toBe(true); + expect((ai.content as any[]).some((b) => b.type === 'tool_use')).toBe(true); + expect(out[1]._getType()).toBe('tool'); + }); }); diff --git a/apps/web-ui/lib/db/repositories/agent-ops-run/interface.ts b/apps/web-ui/lib/db/repositories/agent-ops-run/interface.ts index dd36c54a..04283346 100644 --- a/apps/web-ui/lib/db/repositories/agent-ops-run/interface.ts +++ b/apps/web-ui/lib/db/repositories/agent-ops-run/interface.ts @@ -57,4 +57,5 @@ export interface IAgentOpsRunRepository { findAwaitingApprovalRunByJiraIssue(issueKey: string): Promise; findAwaitingRunByJiraIssue(issueKey: string): Promise; findAwaitingRunBySlackThread(channelId: string, threadTs: string): Promise; + findResumableTelegramRun(chatId: number, idleCutoff: Date): Promise; } diff --git a/apps/web-ui/lib/db/repositories/agent-ops-run/postgres.ts b/apps/web-ui/lib/db/repositories/agent-ops-run/postgres.ts index 570081f7..dcf681f8 100644 --- a/apps/web-ui/lib/db/repositories/agent-ops-run/postgres.ts +++ b/apps/web-ui/lib/db/repositories/agent-ops-run/postgres.ts @@ -286,4 +286,22 @@ export class AgentOpsRunPostgresRepository implements IAgentOpsRunRepository { if (trigger?.threadTs !== threadTs) return null; return toAgentOpsRun(record); } + + // Cross-tenant: a Telegram follow-up continues a run ONLY while that run is + // paused waiting for the user's answer (awaiting_input) — a message then feeds + // that answer into the same run/thread. Once a run has completed and returned a + // result, the next message is a brand-new task. /new cancels a pending run, so + // 'cancelled' is naturally excluded; the idle window abandons a stale question. + async findResumableTelegramRun(chatId: number, idleCutoff: Date): Promise { + const record = await getPrismaClient().agentOpsRun.findFirst({ + where: { + source: 'telegram', + status: 'awaiting_input', + updatedAt: { gte: idleCutoff }, + trigger: { path: ['chatId'], equals: chatId }, + }, + orderBy: { updatedAt: 'desc' }, + }); + return record ? toAgentOpsRun(record) : null; + } } diff --git a/apps/web-ui/lib/db/repositories/telegram-bot-link/interface.ts b/apps/web-ui/lib/db/repositories/telegram-bot-link/interface.ts new file mode 100644 index 00000000..8f22dc43 --- /dev/null +++ b/apps/web-ui/lib/db/repositories/telegram-bot-link/interface.ts @@ -0,0 +1,48 @@ +/** + * ITelegramBotLinkRepository + * + * Reverse index from a Telegram bot's webhook secret token to the tenant that + * registered it. Inbound Telegram updates carry no tenant-identifying field at + * all, so the secret token echoed back in the X-Telegram-Bot-Api-Secret-Token + * header is the only value that can resolve which tenant a request belongs to. + */ +export interface TelegramBotLinkRecord { + secretToken: string; + tenantId: string; +} + +/** Thrown when a secret token is already linked to a different tenant. */ +export class TelegramBotLinkConflictError extends Error { + constructor() { + super('This secret token is already linked to a different tenant'); + this.name = 'TelegramBotLinkConflictError'; + } +} + +export interface ITelegramBotLinkRepository { + /** + * Resolve the tenantId that owns a Telegram bot by its secret token. + * Returns null if no tenant has linked this secret token. + */ + findTenantIdBySecretToken(secretToken: string): Promise; + + /** + * Link a secret token to a tenant (upsert on secretToken). Throws if the + * secretToken is already linked to a *different* tenant — a secret token + * can only ever belong to one tenant. + */ + upsertLink(params: { secretToken: string; tenantId: string }): Promise; + + /** + * Look up the current link for a tenant, if any. + */ + getLinkForTenant(tenantId: string): Promise; + + /** + * Drop a tenant's bot link. Called when the tenant rotates or resets its + * Telegram configuration: a stale link left behind would keep routing + * inbound requests for the old secret to a tenant that no longer holds it. + * Returns the number of rows removed (0 when the tenant had no link). + */ + deleteLinkForTenant(tenantId: string): Promise; +} diff --git a/apps/web-ui/lib/db/repositories/telegram-bot-link/postgres.ts b/apps/web-ui/lib/db/repositories/telegram-bot-link/postgres.ts new file mode 100644 index 00000000..3b401301 --- /dev/null +++ b/apps/web-ui/lib/db/repositories/telegram-bot-link/postgres.ts @@ -0,0 +1,87 @@ +/** + * TelegramBotLinkPostgresRepository + * + * PostgreSQL implementation of ITelegramBotLinkRepository using Prisma ORM. + * Reads/writes the `telegram_bot_links` table (defined in libs/prisma/schema.prisma). + * + * findTenantIdBySecretToken is intentionally NOT tenant-scoped — resolving + * which tenant owns an inbound secret token is the whole point of this table, + * so it uses the unscoped Prisma client rather than getTenantClient(). + */ +import { getPrismaClient } from '@/lib/db/pg-config'; +import { + TelegramBotLinkConflictError, + type ITelegramBotLinkRepository, + type TelegramBotLinkRecord, +} from './interface'; + +export class TelegramBotLinkPostgresRepository implements ITelegramBotLinkRepository { + async findTenantIdBySecretToken(secretToken: string): Promise { + try { + const record = await getPrismaClient().telegramBotLink.findUnique({ + where: { secretToken }, + select: { tenantId: true }, + }); + return record?.tenantId ?? null; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[TelegramBotLinkPostgresRepository] Error resolving secretToken:', error); + throw new Error(`Failed to resolve Telegram secret token: ${msg}`); + } + } + + async upsertLink(params: { secretToken: string; tenantId: string }): Promise { + const { secretToken, tenantId } = params; + try { + const existing = await getPrismaClient().telegramBotLink.findUnique({ + where: { secretToken }, + select: { tenantId: true }, + }); + if (existing && existing.tenantId !== tenantId) { + throw new TelegramBotLinkConflictError(); + } + + await getPrismaClient().telegramBotLink.upsert({ + where: { secretToken }, + update: {}, + create: { secretToken, tenantId }, + }); + console.log(`[TelegramBotLinkPostgresRepository] Linked Telegram bot to tenant "${tenantId}"`); + } catch (error: unknown) { + if (error instanceof TelegramBotLinkConflictError) throw error; + const msg = error instanceof Error ? error.message : String(error); + console.error('[TelegramBotLinkPostgresRepository] Error upserting link:', error); + throw new Error(`Failed to link Telegram bot: ${msg}`); + } + } + + async getLinkForTenant(tenantId: string): Promise { + try { + const record = await getPrismaClient().telegramBotLink.findFirst({ + where: { tenantId }, + }); + if (!record) return null; + return { secretToken: record.secretToken, tenantId: record.tenantId }; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[TelegramBotLinkPostgresRepository] Error getting link for tenant:', error); + throw new Error(`Failed to get Telegram bot link: ${msg}`); + } + } + + async deleteLinkForTenant(tenantId: string): Promise { + try { + const { count } = await getPrismaClient().telegramBotLink.deleteMany({ + where: { tenantId }, + }); + if (count > 0) { + console.log(`[TelegramBotLinkPostgresRepository] Unlinked Telegram bot for tenant "${tenantId}"`); + } + return count; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[TelegramBotLinkPostgresRepository] Error deleting link for tenant:', error); + throw new Error(`Failed to unlink Telegram bot: ${msg}`); + } + } +} diff --git a/apps/web-ui/lib/db/repository-factory.ts b/apps/web-ui/lib/db/repository-factory.ts index 460cbc44..7a65e477 100644 --- a/apps/web-ui/lib/db/repository-factory.ts +++ b/apps/web-ui/lib/db/repository-factory.ts @@ -29,6 +29,7 @@ import type { IAgentMemoryRepository } from './repositories/agent-memory/interfa import type { ISkillRepository } from './repositories/skill/interface'; import type { ISlackWorkspaceLinkRepository } from './repositories/slack-workspace-link/interface'; import type { IDashboardRepository } from './repositories/dashboard/interface'; +import type { ITelegramBotLinkRepository } from './repositories/telegram-bot-link/interface'; export function getTenantConfigRepository(): ITenantConfigRepository { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -144,6 +145,12 @@ export function getDashboardRepository(): IDashboardRepository { return new DashboardPostgresRepository(); } +export function getTelegramBotLinkRepository(): ITelegramBotLinkRepository { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { TelegramBotLinkPostgresRepository } = require('./repositories/telegram-bot-link/postgres'); + return new TelegramBotLinkPostgresRepository(); +} + /** * Feature flag helper — kept for backward compat with existing callers. * Always returns true since all entities now use PostgreSQL. diff --git a/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts b/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts index f6cc4442..90059d1b 100644 --- a/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts +++ b/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts @@ -8,6 +8,8 @@ import type { NextRequest } from 'next/server'; import { TenantConfigService } from '@/lib/tenant-config-service'; +import { getTelegramBotLinkRepository } from '@/lib/db/repository-factory'; +import { agentOpsService } from '@/lib/agent-ops/agent-ops-service'; import { env } from '@/env'; import { buildDashboardRespondUrl, buildDashboardRunUrl } from '@/lib/gateway/utils/dashboard-url'; import { ChannelRateLimiter } from '@/lib/gateway/utils/rate-limiter'; @@ -31,6 +33,12 @@ import type { const TELEGRAM_API_BASE = 'https://api.telegram.org'; +/** A Telegram chat is one ongoing conversation until it goes quiet this long. */ +const CONVERSATION_IDLE_MS = 30 * 60 * 1000; + +/** Commands that end the current conversation and start a fresh one. */ +const RESET_COMMANDS = new Set(['/new', '/n']); + // ─── Telegram Config Interface ──────────────────────────────────────── export interface TelegramIntegrationConfig { @@ -56,6 +64,22 @@ async function readBody(req: NextRequest): Promise { return text; } +/** + * Cache the secretToken → tenantId resolution across validateRequest → + * parseInbound so both use the same (correct) internal tenantId without a + * duplicate lookup. Value is `null` when resolution was attempted but no + * link exists. + */ +const tenantIdCache = new WeakMap(); + +async function resolveTenantId(req: NextRequest, secretToken: string): Promise { + if (!secretToken) return null; + if (tenantIdCache.has(req)) return tenantIdCache.get(req) ?? null; + const tenantId = await getTelegramBotLinkRepository().findTenantIdBySecretToken(secretToken).catch(() => null); + tenantIdCache.set(req, tenantId); + return tenantId; +} + // ─── Markdown Helpers ───────────────────────────────────────────────── /** @@ -90,28 +114,21 @@ export class TelegramAdapter implements ChannelAdapter { // Read body to cache it for parseInbound await readBody(req); - // Try to resolve tenant from x-tenant-id header or body - let tenantId = req.headers.get('x-tenant-id') || ''; - if (!tenantId) { - try { - const body = await readBody(req); - const payload = JSON.parse(body); - tenantId = String( - payload.message?.chat?.id ?? - payload.callback_query?.message?.chat?.id ?? - '', - ); - } catch { /* ignore parse errors */ } - } + // Telegram updates carry no tenant-identifying field at all — the secret + // token echoed back in this header is the only value that ties a request + // back to the tenant who registered this bot's webhook (see TelegramBotLink). + const tenantId = await resolveTenantId(req, secretHeader); - // Load secret: tenant config first, env var fallback + // Load secret: tenant config first, env var fallback (single-tenant setups) let expectedSecret = ''; + let enabled = true; if (tenantId) { const config = await TenantConfigService.getConfig( 'agent-ops-telegram', tenantId, ).catch(() => null); expectedSecret = config?.secretToken || ''; + enabled = config?.enabled ?? true; } if (!expectedSecret) { expectedSecret = env.TELEGRAM_SECRET_TOKEN || ''; @@ -121,28 +138,31 @@ export class TelegramAdapter implements ChannelAdapter { return false; } - return secretHeader === expectedSecret; + return enabled && secretHeader === expectedSecret; } async parseInbound(req: NextRequest): Promise { const body = await readBody(req); const payload = JSON.parse(body); - const tenantId = req.headers.get('x-tenant-id') || ''; + const secretHeader = req.headers.get('x-telegram-bot-api-secret-token') || ''; + // Resolve via the same TelegramBotLink lookup validateRequest already did + // (cached on req) — falls back to null, handled per-branch below. + const resolvedTenantId = await resolveTenantId(req, secretHeader); // ── Callback query (inline keyboard button press) ─────────── if (payload.callback_query) { - return this.parseCallbackQuery(payload.callback_query, tenantId); + return this.parseCallbackQuery(payload.callback_query, resolvedTenantId); } // ── Message ───────────────────────────────────────────────── if (payload.message) { - return this.parseMessage(payload.message, tenantId); + return this.parseMessage(payload.message, resolvedTenantId); } // Fallback for unsupported update types return { channelType: 'telegram', - tenantId, + tenantId: resolvedTenantId || '', taskDescription: '', channelMeta: {}, }; @@ -158,9 +178,9 @@ export class TelegramAdapter implements ChannelAdapter { if (chatId) { try { - const config = await this.loadConfig( - req.headers.get('x-tenant-id') || String(chatId), - ); + const secretHeader = req.headers.get('x-telegram-bot-api-secret-token') || ''; + const tenantId = (await resolveTenantId(req, secretHeader)) || String(chatId); + const config = await this.loadConfig(tenantId); const botToken = config?.botToken || env.TELEGRAM_BOT_TOKEN || ''; if (botToken) { const res = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/sendMessage`, { @@ -366,10 +386,10 @@ export class TelegramAdapter implements ChannelAdapter { ).catch(() => null); } - private parseMessage( + private async parseMessage( message: Record, - headerTenantId: string, - ): GatewayMessage { + resolvedTenantId: string | null, + ): Promise { const from = message.from as Record | undefined; const chat = message.chat as Record | undefined; const userId = (from?.id as number) ?? 0; @@ -377,48 +397,76 @@ export class TelegramAdapter implements ChannelAdapter { const messageId = message.message_id as number | undefined; const text = (message.text as string) || ''; const entities = (message.entities as Array>) ?? []; - const tenantId = headerTenantId || String(chatId); + // chat.id is Telegram's own bookkeeping, not our tenantId — only fall back + // to it if no TelegramBotLink exists (e.g. env-var-only setups), matching + // prior (already-broken) behavior rather than silently dropping the run. + if (!resolvedTenantId) { + console.warn('[TelegramAdapter] No tenant linked for this bot, falling back to raw chat.id:', chatId); + } + const tenantId = resolvedTenantId || String(chatId); - // Check for bot command entity const botCommandEntity = entities.find(e => e.type === 'bot_command'); if (botCommandEntity) { const offset = (botCommandEntity.offset as number) ?? 0; const length = (botCommandEntity.length as number) ?? 0; + const command = text.slice(offset, offset + length).split('@')[0].toLowerCase(); const taskDescription = text.slice(offset + length).trim(); - return { - channelType: 'telegram', - tenantId, - taskDescription, - userId: String(userId), - channelMeta: { userId, chatId, messageId }, - }; - } - - // Check for reply to bot message (clarification response) - const replyToMessage = message.reply_to_message as Record | undefined; - if (replyToMessage) { - const replyFrom = replyToMessage.from as Record | undefined; - const isBot = replyFrom?.is_bot === true; - - if (isBot) { + // /new (or /n) ends the current conversation. Bare → reset + confirm, and + // the next message begins fresh. With text → fall through to a brand-new + // task below (that new run simply supersedes the old one as the newest). + if (RESET_COMMANDS.has(command) && !taskDescription) { + const current = await this.findSessionRun(chatId); return { channelType: 'telegram', tenantId, - taskDescription: text, + taskDescription: '', userId: String(userId), replyContext: { - runId: '', // Will be resolved by gateway orchestrator - action: 'clarification_response', - content: text, - tenantId, + runId: current?.runId ?? '', + action: 'reset', + tenantId: current?.tenantId ?? tenantId, }, channelMeta: { userId, chatId, messageId }, }; } + + // Any other command (/cloudops …, /start) — or /new with text — begins a + // fresh task; commands are an explicit "new request" signal, never a + // continuation of the running conversation. + return { + channelType: 'telegram', + tenantId, + taskDescription, + userId: String(userId), + channelMeta: { userId, chatId, messageId }, + }; + } + + // If a run is currently waiting for the user's answer, this message IS that + // answer — feed it into the same run/thread rather than spawning a new run. + // Telegram DMs have no thread concept, so the chatId is the conversation key; + // the user just types, no need for Telegram's native reply-to feature. Once a + // run has finished, findSessionRun returns null and the message below starts + // a fresh task. + const sessionRun = await this.findSessionRun(chatId); + if (sessionRun) { + return { + channelType: 'telegram', + tenantId, + taskDescription: text, + userId: String(userId), + replyContext: { + runId: sessionRun.runId, + action: 'clarification_response', + content: text, + tenantId: sessionRun.tenantId, + }, + channelMeta: { userId, chatId, messageId }, + }; } - // Plain message (no command, no reply) + // Nothing awaiting input → a brand-new task (starts a fresh run/thread). return { channelType: 'telegram', tenantId, @@ -428,9 +476,38 @@ export class TelegramAdapter implements ChannelAdapter { }; } + /** The run this chat's next message should continue, or null to start fresh. */ + private findSessionRun(chatId: number) { + return agentOpsService.findResumableTelegramRun( + chatId, + new Date(Date.now() - CONVERSATION_IDLE_MS), + ); + } + + /** Confirm to the chat that /new started a fresh conversation. */ + async sendSessionReset(tenantId: string, chatId: number): Promise { + const config = await this.loadConfig(tenantId); + const botToken = config?.botToken || env.TELEGRAM_BOT_TOKEN || ''; + if (!botToken) return; + const text = [ + '*New conversation started*', + '', + escapeMarkdownV2('Your next message begins a fresh task — earlier context is cleared.'), + ].join('\n'); + try { + await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'MarkdownV2' }), + }); + } catch (err) { + console.error('[TelegramAdapter] sendSessionReset error:', err); + } + } + private parseCallbackQuery( callbackQuery: Record, - headerTenantId: string, + resolvedTenantId: string | null, ): GatewayMessage { const from = callbackQuery.from as Record | undefined; const message = callbackQuery.message as Record | undefined; @@ -444,7 +521,7 @@ export class TelegramAdapter implements ChannelAdapter { // Parse callback_data format: "action:runId:tenantId" const [action, runId, tenantIdFromData] = data.split(':'); - const tenantId = headerTenantId || tenantIdFromData || String(chatId); + const tenantId = resolvedTenantId || tenantIdFromData || String(chatId); const replyContext: ReplyContext = { runId: runId || '', diff --git a/apps/web-ui/lib/gateway/gateway-service.ts b/apps/web-ui/lib/gateway/gateway-service.ts index 567f8ead..76587cee 100644 --- a/apps/web-ui/lib/gateway/gateway-service.ts +++ b/apps/web-ui/lib/gateway/gateway-service.ts @@ -42,8 +42,17 @@ export class GatewayService { return this.handleResume(adapter, message, req); } - // 4. If no taskDescription → 400 + // 4. If no taskDescription → ack or 400, depending on delivery semantics. + // 'streaming' channels (Telegram, Discord) push updates async and retry + // forever on a non-2xx response — e.g. Telegram's automatic /start + // handshake has no text, and a 400 there would jam that channel's entire + // queue behind an endless retry. 'callback' channels (Slack, Jira, + // webhook) get a synchronous reply the caller displays immediately, so + // the 400 there is a normal validation response, not a delivery signal. if (!message.taskDescription || message.taskDescription.trim() === '') { + if (adapter.deliveryMode === 'streaming') { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } return new Response( JSON.stringify({ error: 'Missing task description' }), { status: 400 }, @@ -177,6 +186,18 @@ export class GatewayService { return ackResponse; } + case 'reset': { + // End the current conversation so the next message starts fresh. + if (runId) { + await agentOpsService.closeTelegramSession(tenantId, runId); + } + const chatId = Number((message.channelMeta as { chatId?: number })?.chatId); + if (adapter.sendSessionReset && Number.isFinite(chatId)) { + await adapter.sendSessionReset(tenantId, chatId); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + default: return new Response( JSON.stringify({ error: `Unknown action: ${action}` }), diff --git a/apps/web-ui/lib/gateway/types.ts b/apps/web-ui/lib/gateway/types.ts index ed1f70a6..d9e53c7f 100644 --- a/apps/web-ui/lib/gateway/types.ts +++ b/apps/web-ui/lib/gateway/types.ts @@ -30,7 +30,7 @@ export interface GatewayMessage { export interface ReplyContext { runId: string; - action: 'clarification_response' | 'approve' | 'reject'; + action: 'clarification_response' | 'approve' | 'reject' | 'reset'; content?: string; tenantId?: string; } @@ -87,5 +87,11 @@ export interface ChannelAdapter { */ sendScheduledNotification?(task: ScheduledTask, run: AgentOpsRun, outcome: ScheduledOutcome): Promise; + /** + * Confirm to a channel that its conversation was reset (e.g. Telegram /new). + * Optional — only channels with a persistent per-chat conversation implement it. + */ + sendSessionReset?(tenantId: string, chatId: number): Promise; + getConfig(tenantId: string): Promise>; } diff --git a/apps/web-ui/middleware.ts b/apps/web-ui/middleware.ts index f1be636f..32562858 100644 --- a/apps/web-ui/middleware.ts +++ b/apps/web-ui/middleware.ts @@ -72,8 +72,18 @@ export default withAuth( } ); +// Only the externally-signed webhook channels are exempted from the NextAuth gate — +// each verifies its own signature/secret in the adapter's validateRequest (Telegram's +// X-Telegram-Bot-Api-Secret-Token, Slack's HMAC signature, Discord's Ed25519, Jira's +// shared secret), and none of them can carry a session cookie. +// +// Deliberately NOT exempted: api/v1/gateway/api and api/v1/gateway/stream. Those are +// session-authenticated UI paths — ApiAdapter.validateRequest accepts ANY Authorization +// or x-api-key header without validating it, and this middleware is what overwrites a +// client-supplied x-tenant-id with the session's real tenantId. Exempting them would +// let an unauthenticated caller start an agent run under an arbitrary tenant. export const config = { matcher: [ - "/((?!api/auth|api/health|api/v1/trigger|_next/static|_next/image|favicon.ico|placeholder.*|smc-global-securities-logo.jpg|login|signup).*)", + "/((?!api/auth|api/health|api/v1/trigger|api/v1/gateway/telegram|api/v1/gateway/slack|api/v1/gateway/discord|api/v1/gateway/jira|api/v1/gateway/webhook|_next/static|_next/image|favicon.ico|placeholder.*|smc-global-securities-logo.jpg|login|signup).*)", ], }; diff --git a/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts b/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts index f00380cc..79b8c464 100644 --- a/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts +++ b/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { TelegramAdapter } from '@/lib/gateway/adapters/telegram-adapter'; import { TenantConfigService } from '@/lib/tenant-config-service'; +import { getTelegramBotLinkRepository } from '@/lib/db/repository-factory'; +import { agentOpsService } from '@/lib/agent-ops/agent-ops-service'; import type { ScheduledTask, AgentOpsRun } from '@/lib/agent-ops/types'; vi.mock('@/lib/tenant-config-service', () => ({ @@ -13,12 +15,39 @@ vi.mock('@/lib/tenant-config-service', () => ({ }, })); +vi.mock('@/lib/agent-ops/agent-ops-service', () => ({ + agentOpsService: { + findResumableTelegramRun: vi.fn().mockResolvedValue(null), + closeTelegramSession: vi.fn().mockResolvedValue(undefined), + }, +})); + +// chat.id is Telegram's own bookkeeping, never our tenantId — it must be +// translated via TelegramBotLink (the secret token is the only tenant-identifying +// value Telegram ever sends back), so every test needs this mocked. +vi.mock('@/lib/db/repository-factory', () => ({ + getTelegramBotLinkRepository: vi.fn().mockReturnValue({ + findTenantIdBySecretToken: vi.fn().mockResolvedValue('tenant-1'), + upsertLink: vi.fn().mockResolvedValue(undefined), + getLinkForTenant: vi.fn().mockResolvedValue(null), + deleteLinkForTenant: vi.fn().mockResolvedValue(0), + }), +})); + describe('TelegramAdapter', () => { let adapter: TelegramAdapter; beforeEach(() => { adapter = new TelegramAdapter(); vi.restoreAllMocks(); + vi.mocked(getTelegramBotLinkRepository).mockReturnValue({ + findTenantIdBySecretToken: vi.fn().mockResolvedValue('tenant-1'), + upsertLink: vi.fn().mockResolvedValue(undefined), + getLinkForTenant: vi.fn().mockResolvedValue(null), + deleteLinkForTenant: vi.fn().mockResolvedValue(0), + } as any); + vi.mocked(agentOpsService.findResumableTelegramRun).mockResolvedValue(null); + vi.mocked(agentOpsService.closeTelegramSession).mockResolvedValue(undefined); }); it('has correct channel metadata', () => { @@ -72,6 +101,42 @@ describe('TelegramAdapter', () => { expect(msg.channelMeta).toMatchObject({ userId: 12345, chatId: 67890 }); }); + it('parseInbound resolves tenantId from the secret token link, not chat.id', async () => { + const payload = { + message: { + message_id: 100, + from: { id: 12345 }, + chat: { id: 67890 }, // Telegram's own id — must NOT leak through as tenantId + text: 'hello', + }, + }; + const req = new Request('http://localhost/api/v1/gateway/telegram', { + method: 'POST', + headers: { 'x-telegram-bot-api-secret-token': 'tg-secret', 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + const msg = await adapter.parseInbound(req as any); + expect(msg.tenantId).toBe('tenant-1'); + expect(msg.tenantId).not.toBe(String(payload.message.chat.id)); + }); + + it('parseInbound falls back to raw chat.id when no bot link exists', async () => { + vi.mocked(getTelegramBotLinkRepository).mockReturnValue({ + findTenantIdBySecretToken: vi.fn().mockResolvedValue(null), + upsertLink: vi.fn().mockResolvedValue(undefined), + getLinkForTenant: vi.fn().mockResolvedValue(null), + deleteLinkForTenant: vi.fn().mockResolvedValue(0), + } as any); + const payload = { message: { message_id: 1, from: { id: 1 }, chat: { id: 67890 }, text: 'hello' } }; + const req = new Request('http://localhost/api/v1/gateway/telegram', { + method: 'POST', + headers: { 'x-telegram-bot-api-secret-token': 'tg-secret', 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + const msg = await adapter.parseInbound(req as any); + expect(msg.tenantId).toBe('67890'); + }); + it('parseInbound detects callback query as ReplyContext', async () => { const payload = { callback_query: { @@ -94,6 +159,77 @@ describe('TelegramAdapter', () => { }); }); + const plainMessageReq = (text: string, chatId = 67890) => { + const payload = { message: { message_id: 100, from: { id: 12345 }, chat: { id: chatId }, text } }; + return new Request('http://localhost/api/v1/gateway/telegram', { + method: 'POST', + headers: { 'x-telegram-bot-api-secret-token': 'tg-secret', 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + }; + + const commandReq = (text: string, commandLen: number) => { + const payload = { + message: { + message_id: 100, from: { id: 12345 }, chat: { id: 67890 }, text, + entities: [{ type: 'bot_command', offset: 0, length: commandLen }], + }, + }; + return new Request('http://localhost/api/v1/gateway/telegram', { + method: 'POST', + headers: { 'x-telegram-bot-api-secret-token': 'tg-secret', 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + }; + + it('answers the pending clarification when a run is awaiting the user input', async () => { + vi.mocked(agentOpsService.findResumableTelegramRun).mockResolvedValue({ + runId: 'run-9', tenantId: 'tenant-1', status: 'awaiting_input', + } as unknown as AgentOpsRun); + const msg = await adapter.parseInbound(plainMessageReq('use ap-south-1') as any); + expect(vi.mocked(agentOpsService.findResumableTelegramRun)).toHaveBeenCalledWith(67890, expect.any(Date)); + expect(msg.replyContext).toEqual({ + runId: 'run-9', + action: 'clarification_response', + content: 'use ap-south-1', + tenantId: 'tenant-1', + }); + }); + + it('starts a new run when nothing is awaiting input (finished/no conversation)', async () => { + vi.mocked(agentOpsService.findResumableTelegramRun).mockResolvedValue(null); + const msg = await adapter.parseInbound(plainMessageReq('now show me the RDS databases') as any); + expect(msg.replyContext).toBeUndefined(); + expect(msg.taskDescription).toBe('now show me the RDS databases'); + }); + + it('"/new" with no text resets the session, referencing the current run', async () => { + vi.mocked(agentOpsService.findResumableTelegramRun).mockResolvedValue({ + runId: 'run-9', tenantId: 'tenant-1', status: 'completed', + } as unknown as AgentOpsRun); + const msg = await adapter.parseInbound(commandReq('/new', 4) as any); + expect(msg.replyContext).toEqual({ runId: 'run-9', action: 'reset', tenantId: 'tenant-1' }); + expect(msg.taskDescription).toBe(''); + }); + + it('"/new " starts a fresh task without continuing the old run', async () => { + vi.mocked(agentOpsService.findResumableTelegramRun).mockResolvedValue({ + runId: 'run-9', tenantId: 'tenant-1', status: 'completed', + } as unknown as AgentOpsRun); + const msg = await adapter.parseInbound(commandReq('/new list RDS databases', 4) as any); + expect(msg.replyContext).toBeUndefined(); + expect(msg.taskDescription).toBe('list RDS databases'); + }); + + it('bot command "/cloudops ..." starts a new task even with an active session', async () => { + vi.mocked(agentOpsService.findResumableTelegramRun).mockResolvedValue({ + runId: 'run-9', tenantId: 'tenant-1', status: 'completed', + } as unknown as AgentOpsRun); + const msg = await adapter.parseInbound(commandReq('/cloudops Check Lambda configs', 9) as any); + expect(msg.replyContext).toBeUndefined(); + expect(msg.taskDescription).toBe('Check Lambda configs'); + }); + it('sendAck returns 200', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, result: { message_id: 200 } }) }); const req = new Request('http://localhost', { diff --git a/apps/web-ui/tests/gateway/gateway-service.test.ts b/apps/web-ui/tests/gateway/gateway-service.test.ts index b8355b6c..69a534ee 100644 --- a/apps/web-ui/tests/gateway/gateway-service.test.ts +++ b/apps/web-ui/tests/gateway/gateway-service.test.ts @@ -19,6 +19,7 @@ vi.mock('@/lib/agent-ops/agent-ops-service', () => ({ }), updateRunStatus: vi.fn().mockResolvedValue(undefined), recordEvent: vi.fn().mockResolvedValue(undefined), + closeTelegramSession: vi.fn().mockResolvedValue(undefined), findAwaitingApprovalRun: vi.fn().mockResolvedValue({ runId: 'run-1', tenantId: 'tenant-1', source: 'slack', status: 'awaiting_approval', taskDescription: 'test', trigger: {}, @@ -54,6 +55,7 @@ function makeMockAdapter(): ChannelAdapter { sendError: vi.fn().mockResolvedValue(undefined), sendClarification: vi.fn().mockResolvedValue(undefined), sendApprovalRequest: vi.fn().mockResolvedValue(undefined), + sendSessionReset: vi.fn().mockResolvedValue(undefined), getConfig: vi.fn().mockResolvedValue({}), } as any; } @@ -98,6 +100,20 @@ describe('GatewayService', () => { expect(res.status).toBe(400); }); + it('acks with 200 (not 400) when task description is empty on a streaming channel', async () => { + // 'streaming' channels (Telegram, Discord) push updates async and retry + // forever on a non-2xx — e.g. Telegram's automatic /start handshake has + // no text. A 400 here would jam the whole channel behind an endless retry. + (adapter as any).deliveryMode = 'streaming'; + (adapter.parseInbound as any).mockResolvedValue({ + channelType: 'slack', tenantId: 'tenant-1', taskDescription: '', channelMeta: {}, + }); + const req = new Request('http://localhost', { method: 'POST', body: 'text=' }); + const res = await service.handleInbound('slack', req as any); + expect(res.status).toBe(200); + expect(adapter.sendAck).not.toHaveBeenCalled(); + }); + it('routes HIL resume when replyContext is present (approve)', async () => { (adapter.parseInbound as any).mockResolvedValue({ channelType: 'slack', tenantId: 'tenant-1', taskDescription: '', @@ -135,4 +151,17 @@ describe('GatewayService', () => { expect(executeAgentRun).toHaveBeenCalled(); expect(res.status).toBe(200); }); + + it('resets a conversation: closes the session run and confirms to the channel', async () => { + (adapter.parseInbound as any).mockResolvedValue({ + channelType: 'slack', tenantId: 'tenant-1', taskDescription: '', + channelMeta: { chatId: 67890 }, replyContext: { runId: 'run-1', action: 'reset', tenantId: 'tenant-1' }, + }); + const { agentOpsService } = await import('@/lib/agent-ops/agent-ops-service'); + const req = new Request('http://localhost', { method: 'POST', body: 'payload={}' }); + const res = await service.handleInbound('slack', req as any); + expect(agentOpsService.closeTelegramSession).toHaveBeenCalledWith('tenant-1', 'run-1'); + expect(adapter.sendSessionReset).toHaveBeenCalledWith('tenant-1', 67890); + expect(res.status).toBe(200); + }); }); diff --git a/docs/superpowers/plans/2026-07-21-aiops-history-reasoning-render.md b/docs/superpowers/plans/2026-07-21-aiops-history-reasoning-render.md new file mode 100644 index 00000000..65fce8ea --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-aiops-history-reasoning-render.md @@ -0,0 +1,335 @@ +# AI Ops History Reasoning-Block Rendering — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** In a reopened AI Ops history session, render reasoning that was persisted as an array of content blocks the same way the live stream does (empty reasoning → nothing; reasoning-with-text → a "Thought for Ns" block), instead of showing raw `[{"type":"reasoning",...}]` JSON. + +**Architecture:** A new pure module `lib/agent-chat/ai-content-parts.ts` reconstructs typed UI parts from an AI message's content-block array (reasoning → `reasoning` part, dropping empty ones; text → `text` part; tool_use skipped). The history route (`app/api/threads/[threadId]/history/route.ts`) calls it first in the AI branch of `convertPlainMessage` and `convertMessage`, falling back to the existing string/marker path when it returns `null`. Read-path only — storage, the write path, and the live stream are untouched, so it fixes existing and future sessions with no migration. + +**Tech Stack:** TypeScript, LangChain `@langchain/core/messages` (`AIMessage`), Next.js App Router route handler, Vitest (web-ui), Bun. + +## Global Constraints + +- Language/style: `app/api/threads/[threadId]/history/route.ts` and `lib/agent-chat/*` use **4-space indentation**. Match it. +- Comments: default to none; a single concise line only where the WHY is non-obvious (user + repo convention). No multi-line comment blocks. +- Surgical: only create `lib/agent-chat/ai-content-parts.ts` + its test, and modify `app/api/threads/[threadId]/history/route.ts`. No refactoring of adjacent code, no change to the write path (`app/api/chat/route.ts`) or storage. +- Do NOT export non-handler functions from `route.ts` (Next.js route files accept only HTTP-method/config exports) — the reusable logic lives in the lib module. +- "Match live exactly": empty-text reasoning renders as nothing; only non-empty reasoning becomes a `reasoning` part. This mirrors the reducer at `lib/agent-chat/events.ts:159-161`. +- The reconstruction is gated to the **AI role only**; the human multimodal path (`extractDisplayText`) and the tool path stay unchanged. +- Tests run from `apps/web-ui`: full suite `bun run test`; a single file `bunx vitest run `. +- Do NOT commit unless the human explicitly asks. The commit step at the end runs only on explicit go-ahead. + +--- + +### Task 1: `reconstructAiContentParts` helper (pure module + tests) + +**Files:** +- Create: `apps/web-ui/lib/agent-chat/ai-content-parts.ts` +- Test: `apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts` + +**Interfaces:** +- Consumes: nothing from other tasks. Imports `buildTranscript` from `../events` in the test only (to prove render parity). +- Produces: `export function reconstructAiContentParts(rawContent: unknown): Array<{ type: 'reasoning' | 'text'; text: string }> | null`. Returns `null` when `rawContent` is not a content-block array (caller keeps its existing string path); returns a parts array (possibly empty) when it is. Task 2 consumes this. + +- [ ] **Step 1: Write the failing tests** + +Create `apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { reconstructAiContentParts } from '../ai-content-parts'; +import { buildTranscript } from '../events'; + +const noOpts = { + isStreaming: false, + toolVisibility: new Map(), + decisions: new Map(), +}; + +describe('reconstructAiContentParts', () => { + it('returns null for a plain (non-array) string so the caller keeps the marker path', () => { + expect(reconstructAiContentParts('PLANNING_PHASE_START\nplan text')).toBeNull(); + expect(reconstructAiContentParts('just an answer')).toBeNull(); + }); + + it('returns [] for an array of only empty-text reasoning blocks', () => { + const raw = JSON.stringify([{ type: 'reasoning', reasoning: '', signature: 'sig' }]); + expect(reconstructAiContentParts(raw)).toEqual([]); + }); + + it('reconstructs a reasoning block that has text into a single reasoning part', () => { + const raw = JSON.stringify([{ type: 'reasoning', reasoning: 'Let me think about X.' }]); + expect(reconstructAiContentParts(raw)).toEqual([{ type: 'reasoning', text: 'Let me think about X.' }]); + }); + + it('reconstructs a mixed [reasoning, text] array preserving order', () => { + const raw = JSON.stringify([ + { type: 'thinking', thinking: 'thinking hard' }, + { type: 'text', text: 'the answer' }, + ]); + expect(reconstructAiContentParts(raw)).toEqual([ + { type: 'reasoning', text: 'thinking hard' }, + { type: 'text', text: 'the answer' }, + ]); + }); + + it('extracts the raw Bedrock reasoningContent shape', () => { + const raw = JSON.stringify([{ reasoningContent: { reasoningText: { text: 'deep thought' } } }]); + expect(reconstructAiContentParts(raw)).toEqual([{ type: 'reasoning', text: 'deep thought' }]); + }); + + it('drops a reasoningContent block whose text is null', () => { + const raw = JSON.stringify([{ reasoningContent: { reasoningText: { text: null } } }]); + expect(reconstructAiContentParts(raw)).toEqual([]); + }); + + it('skips tool_use blocks (tool calls come from metadata)', () => { + const raw = JSON.stringify([ + { type: 'reasoning', reasoning: '' }, + { type: 'tool_use', id: 't1', name: 'do_it', input: {} }, + ]); + expect(reconstructAiContentParts(raw)).toEqual([]); + }); + + it('skips empty/whitespace text blocks', () => { + const raw = JSON.stringify([{ type: 'text', text: ' ' }, { type: 'text', text: 'real' }]); + expect(reconstructAiContentParts(raw)).toEqual([{ type: 'text', text: 'real' }]); + }); + + it('accepts an already-parsed array (checkpoint-fallback path)', () => { + expect(reconstructAiContentParts([{ type: 'text', text: 'hi' }])).toEqual([{ type: 'text', text: 'hi' }]); + }); +}); + +describe('render parity with live (via buildTranscript)', () => { + it('a reasoning part with text yields a thinking event, like live', () => { + const parts = reconstructAiContentParts(JSON.stringify([{ type: 'reasoning', reasoning: 'hmm' }]))!; + const events = buildTranscript({ id: 'm1', role: 'assistant', parts } as any, noOpts); + expect(events).toEqual([expect.objectContaining({ kind: 'thinking', text: 'hmm' })]); + }); + + it('the reducer renders nothing for an empty reasoning part (documents live behavior)', () => { + const events = buildTranscript({ id: 'm1', role: 'assistant', parts: [{ type: 'reasoning', text: '' }] } as any, noOpts); + expect(events).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd apps/web-ui && bunx vitest run lib/agent-chat/__tests__/ai-content-parts.test.ts` +Expected: FAIL — `reconstructAiContentParts` cannot be imported (module does not exist yet). + +- [ ] **Step 3: Create the helper module** + +Create `apps/web-ui/lib/agent-chat/ai-content-parts.ts`: + +```ts +// Reconstructs typed UI parts from an AI message whose persisted content is an +// array of content blocks (Anthropic extended-thinking / Bedrock shapes). Without +// this, the history read path renders such arrays as raw JSON. +type AiContentPart = { type: 'reasoning' | 'text'; text: string }; + +function normalizeToBlockArray(raw: unknown): unknown[] | null { + if (Array.isArray(raw)) return raw; + if (typeof raw === 'string') { + if (!raw.trimStart().startsWith('[')) return null; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + } + return null; +} + +function isReasoningBlock(b: Record): boolean { + if ('reasoningContent' in b || 'reasoning_content' in b) return true; + const t = b.type; + return t === 'reasoning' || t === 'thinking' || t === 'reasoning_content' + || t === 'redacted_reasoning' || t === 'redacted_thinking'; +} + +function extractReasoningText(b: Record): string { + if (typeof b.reasoning === 'string') return b.reasoning; + if (typeof b.thinking === 'string') return b.thinking; + const rc = b.reasoningContent as { reasoningText?: { text?: unknown } } | undefined; + if (rc?.reasoningText && typeof rc.reasoningText.text === 'string') return rc.reasoningText.text; + return ''; +} + +// Returns null when rawContent is not a content-block array (caller keeps the +// string/marker path); otherwise the reconstructed parts (possibly empty). +export function reconstructAiContentParts(rawContent: unknown): AiContentPart[] | null { + const blocks = normalizeToBlockArray(rawContent); + if (blocks === null) return null; + + const parts: AiContentPart[] = []; + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type === 'text' && typeof b.text === 'string') { + if (b.text.trim().length > 0) parts.push({ type: 'text', text: b.text }); + continue; + } + if (isReasoningBlock(b)) { + const text = extractReasoningText(b); + if (text.trim().length > 0) parts.push({ type: 'reasoning', text }); + } + // tool_use / image / unknown → skipped (tool calls come from metadata.tool_calls) + } + return parts; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd apps/web-ui && bunx vitest run lib/agent-chat/__tests__/ai-content-parts.test.ts` +Expected: PASS — all tests green. + +- [ ] **Step 5: Lint the new file** + +Run: `cd apps/web-ui && bun run lint` +Expected: no new errors attributable to `lib/agent-chat/ai-content-parts.ts` (the repo has pre-existing lint failures elsewhere; compare against baseline if unsure — the new file should add none). + +--- + +### Task 2: Wire the helper into the history route + +**Files:** +- Modify: `apps/web-ui/app/api/threads/[threadId]/history/route.ts` (add import; rewrite the AI branch of `convertPlainMessage` ~lines 115-123 and `convertMessage` ~lines 139-147) + +**Interfaces:** +- Consumes: `reconstructAiContentParts` from Task 1 (`@/lib/agent-chat/ai-content-parts`). +- Produces: no new exports. `convertPlainMessage`/`convertMessage` behavior for array-content AI messages changes to emit reconstructed reasoning/text parts; all other inputs are unchanged. + +- [ ] **Step 1: Add the import** + +In `apps/web-ui/app/api/threads/[threadId]/history/route.ts`, add to the imports (after line 6, the `normalizeLegacyContent` import): + +```ts +import { reconstructAiContentParts } from '@/lib/agent-chat/ai-content-parts'; +``` + +- [ ] **Step 2: Rewrite the AI branch of `convertPlainMessage`** + +Replace the current AI branch (lines 115-123): + +```ts + if (role === 'ai') { + const toolCalls = metadata?.tool_calls as Array<{ id?: string; name: string; args: Record }> | undefined; + const parts: HistoryMessage['parts'] = content ? [...(buildPhaseParts(content) ?? [])] : []; + for (const tc of toolCalls ?? []) { + parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args, state: 'call' }); + } + if (parts.length === 0) return null; + return { id: `history-${index}`, role: 'assistant', content, parts }; + } +``` + +with: + +```ts + if (role === 'ai') { + const toolCalls = metadata?.tool_calls as Array<{ id?: string; name: string; args: Record }> | undefined; + // Content-block arrays (extended-thinking) are reconstructed block-by-block; everything else keeps the marker path. + const reconstructed = reconstructAiContentParts(msg.content); + const parts: HistoryMessage['parts'] = reconstructed !== null + ? [...reconstructed] + : (content ? [...(buildPhaseParts(content) ?? [])] : []); + const displayContent = reconstructed !== null + ? reconstructed.filter((p) => p.type === 'text').map((p) => p.text).join('') + : content; + for (const tc of toolCalls ?? []) { + parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args, state: 'call' }); + } + if (parts.length === 0) return null; + return { id: `history-${index}`, role: 'assistant', content: displayContent, parts }; + } +``` + +(`msg.content` is the raw stored string; `content` at line 109 is the `extractDisplayText`-processed value used only by the fallback path.) + +- [ ] **Step 3: Rewrite the AI branch of `convertMessage` (checkpoint fallback)** + +Replace the current AI branch (lines 139-147): + +```ts + if (msgType === 'ai') { + const aiMsg = msg as AIMessage; + const parts: HistoryMessage['parts'] = content ? [...(buildPhaseParts(content) ?? [])] : []; + for (const tc of aiMsg.tool_calls ?? []) { + parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args as Record, state: 'call' }); + } + if (parts.length === 0) return null; + return { id: `history-${index}`, role: 'assistant', content: content || '', parts }; + } +``` + +with: + +```ts + if (msgType === 'ai') { + const aiMsg = msg as AIMessage; + const reconstructed = reconstructAiContentParts(aiMsg.content); + const parts: HistoryMessage['parts'] = reconstructed !== null + ? [...reconstructed] + : (content ? [...(buildPhaseParts(content) ?? [])] : []); + const displayContent = reconstructed !== null + ? reconstructed.filter((p) => p.type === 'text').map((p) => p.text).join('') + : (content || ''); + for (const tc of aiMsg.tool_calls ?? []) { + parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args as Record, state: 'call' }); + } + if (parts.length === 0) return null; + return { id: `history-${index}`, role: 'assistant', content: displayContent, parts }; + } +``` + +(`aiMsg.content` may be an array directly on the checkpoint path; `reconstructAiContentParts` accepts arrays as well as JSON strings.) + +- [ ] **Step 4: Type-check the touched route file** + +Run: `cd apps/web-ui && bunx tsc --noEmit 2>&1 | grep -E "history/route|ai-content-parts" || echo "no type errors in touched files"` +Expected: `no type errors in touched files` (the repo may have unrelated pre-existing type output; only the two touched files must be clean). + +- [ ] **Step 5: Run the full web-ui test suite (regression)** + +Run: `cd apps/web-ui && bun run test` +Expected: PASS with no NEW failures versus the known baseline (the same pre-existing failures observed in Phase 1 remain; nothing new). The Task 1 `ai-content-parts.test.ts` is green. Existing `lib/agent-chat/__tests__/*` (events, legacy-normalizer) must stay green — they lock the untouched string/marker path. + +- [ ] **Step 6: Lint** + +Run: `cd apps/web-ui && bun run lint` +Expected: no new errors attributable to `history/route.ts`. + +- [ ] **Step 7: Commit (only if the human has explicitly asked to commit)** + +```bash +git add apps/web-ui/lib/agent-chat/ai-content-parts.ts apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts "apps/web-ui/app/api/threads/[threadId]/history/route.ts" docs/superpowers/specs/2026-07-21-aiops-history-reasoning-render-design.md docs/superpowers/plans/2026-07-21-aiops-history-reasoning-render.md +git commit -m "fix(aiops): render array-content reasoning as thinking blocks in history + +History replay stored extended-thinking as JSON.stringify'd content arrays and +rendered them as raw JSON. Reconstruct such arrays on the read path into +reasoning/text parts (dropping empty reasoning, matching the live reducer), so a +reopened session renders reasoning like the live stream. Read-path only; storage +and the write path are unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Manual / integration verification (after Task 2) + +1. Reopen a previous session that currently shows a raw `[{"type":"reasoning",...}]` blob → the blob is gone; empty reasoning shows nothing, reasoning-with-text shows a "Thought for Ns" block. +2. Reopen a session with planning/reflection (marker-string) content → unchanged from before. +3. Reopen a session with tool calls → tool cards render once (no duplicates), answers intact. +4. Send a NEW message in a reopened session → still works (Phase 1 behavior unaffected). + +## Self-Review (completed by plan author) + +- **Spec coverage:** helper + block handling → Task 1 Step 3; empty→dropped and text→reasoning → Task 1 tests + parity tests; wiring in both convert functions, AI-role-only → Task 2 Steps 2-3; `content` string field set from text blocks → Task 2 Steps 2-3; regression for marker/human paths → Task 1 null-return test + Task 2 Step 5 (existing events/legacy-normalizer suites) + manual check 2; no write/storage change → nothing in the plan touches `app/api/chat/route.ts`. +- **Placeholder scan:** none — every code step contains full code and exact commands. +- **Type consistency:** `reconstructAiContentParts` signature identical in Task 1 Produces, the helper source, and both Task 2 call sites; return element type `{ type: 'reasoning' | 'text'; text: string }` is assignable to `HistoryMessage['parts']` elements (`type: string`, `text?: string`). diff --git a/docs/superpowers/plans/2026-07-21-aiops-multiturn-crash-fix.md b/docs/superpowers/plans/2026-07-21-aiops-multiturn-crash-fix.md new file mode 100644 index 00000000..09ca0926 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-aiops-multiturn-crash-fix.md @@ -0,0 +1,231 @@ +# AI Ops Multi-Turn / Resume Crash Fix — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop turn-2 (and resume-and-continue) from crashing Bedrock with "content field is empty" by rewriting reasoning-only AI messages that become empty after reasoning-block stripping into a non-empty placeholder, inside `sanitizeMessagesForBedrock()`. + +**Architecture:** On any turn after the first, the backend replays the LangGraph Postgres checkpoint through `sanitizeMessagesForBedrock()` before invoking Bedrock. A turn-1 reasoning-only AI message survives windowing, then `stripReasoningBlocks()` empties its content and it is sent to Bedrock as empty → ValidationException. The fix adds one predicate + in-place content replacement in that single function; message count and role order are preserved so Bedrock's strict Human/AI alternation cannot regress. + +**Tech Stack:** TypeScript, LangChain `@langchain/core/messages` (`AIMessage`, `HumanMessage`, `ToolMessage`), Vitest (web-ui), Bun. + +## Global Constraints + +- Language/style: web-ui files use 4-space indentation in `lib/` service/agent files. Match the existing style in `agent-shared.ts`. +- No comments unless the "why" is non-obvious (repo + user convention). A short "why" comment on the new predicate IS warranted (it prevents a Bedrock crash) — keep it to a couple of lines, no multi-line docblock beyond the existing helper style. +- Surgical change: touch only `sanitizeMessagesForBedrock()` and its new local helper in `apps/web-ui/lib/agent/agent-shared.ts`, plus the test file. No refactoring of adjacent code. +- Placeholder text is exactly `"(reasoning omitted)"`. +- Tests run from `apps/web-ui` with `bun run test` (Vitest `vitest run`, not watch). A single test file runs with `bunx vitest run `. +- Do NOT commit unless the human explicitly asks (user global rule). Steps below include a commit step; execute the `git commit` only on explicit user go-ahead — otherwise stop at `git add` / leave staging to the user. + +--- + +### Task 1: Rewrite empty-after-strip AI messages to a placeholder in `sanitizeMessagesForBedrock` + +**Files:** +- Modify: `apps/web-ui/lib/agent/agent-shared.ts` (add a local helper `isEmptyAiMessageContent` near `stripReasoningBlocks` around line 676; edit the push site at lines 711-714) +- Test: `apps/web-ui/lib/agent/sanitize-bedrock.test.ts` + +**Interfaces:** +- Consumes: existing `stripReasoningBlocks(msg: BaseMessage): BaseMessage`, `sanitizeMessagesForBedrock(messages: BaseMessage[]): BaseMessage[]`, and `isReasoningBlock` (all already in `agent-shared.ts`). LangChain `AIMessage`, `HumanMessage`, `ToolMessage`, `BaseMessage`. +- Produces: no new exported symbols. `sanitizeMessagesForBedrock` keeps its signature; its behavior changes so that an AI message whose content is empty after stripping and which has no tool calls is emitted with content `"(reasoning omitted)"` instead of empty content. A new module-private helper `isEmptyAiMessageContent(ai: AIMessage): boolean` is added (not exported). + +--- + +- [ ] **Step 1: Write the failing tests** + +Append these three tests to `apps/web-ui/lib/agent/sanitize-bedrock.test.ts` inside the existing `describe('sanitizeMessagesForBedrock — reasoning content', ...)` block (or a new adjacent `describe`). The imports `AIMessage, HumanMessage, ToolMessage` already exist at the top of the file. + +```ts + it('rewrites a reasoning-only AI message (empty after strip, no tool_calls) to a non-empty placeholder', () => { + const reasoningOnly = new AIMessage({ + content: [ + { reasoningContent: { reasoningText: { text: null, signature: 'sig' } } } as any, + ], + }); + const out = sanitizeMessagesForBedrock([ + new HumanMessage('first question'), + reasoningOnly, + new HumanMessage('Proceed.'), + ]); + + // No AI message may leave sanitize with empty content. + const emptyAi = out.find((m) => { + if (m._getType() !== 'ai') return false; + const c = m.content; + if (typeof c === 'string') return c.trim() === ''; + return Array.isArray(c) && c.length === 0; + }); + expect(emptyAi).toBeUndefined(); + + // The rewritten message is present, in place, with the placeholder text. + expect(out).toHaveLength(3); + expect(out[1]._getType()).toBe('ai'); + expect(out[1].content).toBe('(reasoning omitted)'); + }); + + it('preserves message count and role order (no consecutive same-role pair)', () => { + const input = [ + new HumanMessage('q1'), + new AIMessage({ content: [{ reasoningContent: { reasoningText: { text: null } } } as any] }), + new HumanMessage('Proceed.'), + new AIMessage({ content: 'the real answer' }), + new HumanMessage('q2'), + ]; + const out = sanitizeMessagesForBedrock(input); + + expect(out.map((m) => m._getType())).toEqual(['human', 'ai', 'human', 'ai', 'human']); + for (let i = 1; i < out.length; i++) { + expect(out[i]._getType()).not.toBe(out[i - 1]._getType()); + } + }); + + it('does NOT rewrite an AI message that is empty-after-strip but has tool_calls', () => { + const toolTurn = new AIMessage({ + content: [ + { reasoningContent: { reasoningText: { text: null } } } as any, + { type: 'tool_use', id: 't1', name: 'do_it', input: {} } as any, + ], + tool_calls: [{ id: 't1', name: 'do_it', args: {}, type: 'tool_call' }], + }); + const out = sanitizeMessagesForBedrock([ + toolTurn, + new ToolMessage({ content: 'ok', tool_call_id: 't1' }), + ]); + + const ai = out[0] as AIMessage; + expect(ai._getType()).toBe('ai'); + expect(ai.content).not.toBe('(reasoning omitted)'); // untouched, not rewritten + expect(Array.isArray(ai.content)).toBe(true); + expect((ai.content as any[]).some((b) => b.type === 'tool_use')).toBe(true); + expect(ai.tool_calls?.[0]?.id).toBe('t1'); + // tool result still re-emitted immediately after its owning AI message + expect(out[1]._getType()).toBe('tool'); + expect((out[1] as any).tool_call_id).toBe('t1'); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd apps/web-ui && bunx vitest run lib/agent/sanitize-bedrock.test.ts` + +Expected: the first two new tests FAIL. The first fails because today the reasoning-only message is pushed with `content: []` (so `out[1].content` is `[]`, not `'(reasoning omitted)'`, and `emptyAi` is found). The third test (tool_calls guard) may already PASS — that is fine; it is a regression guard, not a red test. + +- [ ] **Step 3: Add the `isEmptyAiMessageContent` helper** + +In `apps/web-ui/lib/agent/agent-shared.ts`, immediately AFTER the `stripReasoningBlocks` function (it ends at line 675 with its closing `}`), add: + +```ts +/** + * True when an AI message carries no Bedrock-acceptable content AND no tool calls + * — i.e. an empty/whitespace string, or an array with no tool_use block and no + * non-empty text block. After stripReasoningBlocks() empties a reasoning-only + * message, sending it to Bedrock raises "content field ... is empty"; we rewrite + * such a message to a placeholder (never drop it, so role alternation is kept). + */ +function isEmptyAiMessageContent(ai: AIMessage): boolean { + if (Array.isArray(ai.tool_calls) && ai.tool_calls.length > 0) return false; + + const content = ai.content; + if (content == null) return true; + if (typeof content === 'string') return content.trim() === ''; + if (!Array.isArray(content)) return false; // unknown non-array content — treat as present + if (content.length === 0) return true; + + // Non-empty array: empty only if EVERY block is an empty/whitespace text block. + return (content as unknown[]).every((block) => { + if (block && typeof block === 'object' && (block as any).type === 'text') { + const text = (block as any).text; + return typeof text !== 'string' || text.trim() === ''; + } + return false; // tool_use, image, or any other block => real content + }); +} +``` + +- [ ] **Step 4: Rewrite the push site to substitute the placeholder** + +In `sanitizeMessagesForBedrock`, replace the two lines at 711-714 (the comment + `const cleaned = stripReasoningBlocks(msg); result.push(cleaned);`). Current code: + +```ts + // Strip reasoning/thinking blocks from AI messages — they come back with a + // null reasoningText after a checkpoint round-trip and Bedrock rejects them. + const cleaned = stripReasoningBlocks(msg); + result.push(cleaned); +``` + +Replace with: + +```ts + // Strip reasoning/thinking blocks from AI messages — they come back with a + // null reasoningText after a checkpoint round-trip and Bedrock rejects them. + let cleaned = stripReasoningBlocks(msg); + + // A reasoning-only AI message becomes empty after stripping. Sending empty + // content to Bedrock raises "content field ... is empty" on the next turn. + // Rewrite (never drop) so message count + Human/AI alternation are preserved. + if (cleaned._getType() === 'ai' && isEmptyAiMessageContent(cleaned as AIMessage)) { + const ai = cleaned as AIMessage; + cleaned = new AIMessage({ + content: '(reasoning omitted)', + tool_calls: ai.tool_calls, + additional_kwargs: ai.additional_kwargs, + response_metadata: ai.response_metadata, + id: ai.id, + name: ai.name, + usage_metadata: ai.usage_metadata, + }); + } + + result.push(cleaned); +``` + +Note: the block below this (`if (msg._getType() !== 'ai') continue;` … `pendingIds` …) is unchanged. For a rewritten message `pendingIds` is empty (no tool calls), so it is a no-op — correct. + +- [ ] **Step 5: Run the new tests to verify they pass** + +Run: `cd apps/web-ui && bunx vitest run lib/agent/sanitize-bedrock.test.ts` + +Expected: all tests in the file PASS (the three new ones plus the three pre-existing reasoning tests). + +- [ ] **Step 6: Run the full web-ui test suite to confirm no regression** + +Run: `cd apps/web-ui && bun run test` + +Expected: PASS with no new failures. Pay attention to `tests/agent-ops/agent-shared.test.ts` (the other file that exercises `sanitizeMessagesForBedrock`) — it must remain green. + +- [ ] **Step 7: Type-check / lint the changed file** + +Run: `cd apps/web-ui && bun run lint` + +Expected: no new errors in `lib/agent/agent-shared.ts`. + +- [ ] **Step 8: Commit (only if the human has explicitly asked to commit)** + +```bash +git add apps/web-ui/lib/agent/agent-shared.ts apps/web-ui/lib/agent/sanitize-bedrock.test.ts docs/superpowers/specs/2026-07-21-aiops-multiturn-crash-fix-design.md docs/superpowers/plans/2026-07-21-aiops-multiturn-crash-fix.md +git commit -m "fix(agent): rewrite empty-after-strip reasoning messages to placeholder + +A turn-1 reasoning-only AI message becomes empty after stripReasoningBlocks() +and crashed Bedrock on turn 2 (and on history-session resume) with +\"content field is empty\". Rewrite it in place to a non-empty placeholder inside +sanitizeMessagesForBedrock() so message count and Human/AI alternation are +preserved. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Manual / integration verification (after Task 1) + +These are not automated here (they require the running app + Bedrock) but MUST be checked before considering #2/#3 closed: + +1. Fresh chat → ask a question that triggers heavy reasoning → ask a **second** question on the same chat. Expected: turn 2 answers, no `ValidationException`. +2. Reopen a **previous** history session (one that previously failed on the second ask) → ask a new question. Expected: it continues with prior context, no crash. +3. Confirm a normal single-query chat still works unchanged. + +## Self-Review (completed by plan author) + +- **Spec coverage:** Fix (replace-not-drop) → Task 1 Steps 3-4. Predicate → `isEmptyAiMessageContent` (Step 3). Testing items 1-4 → Step 1 tests + pre-existing untouched-reference test; item 5 → Step 6. Alternation-safety rationale → enforced by "replace not drop" and asserted by the Step 1 alternation test. Out-of-scope items (#1 render, Gap A) → intentionally absent. +- **Placeholder scan:** none — every code step shows full code and exact commands. +- **Type consistency:** helper named `isEmptyAiMessageContent` consistently in Steps 3 and 4; placeholder string `"(reasoning omitted)"` identical in spec, helper doc, Step 1, and Step 4. diff --git a/docs/superpowers/plans/2026-07-24-aiops-token-counter.md b/docs/superpowers/plans/2026-07-24-aiops-token-counter.md new file mode 100644 index 00000000..3f3f94d0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-aiops-token-counter.md @@ -0,0 +1,400 @@ +# AI Ops Chat Token Counter — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a cumulative incoming/outgoing token counter in the AI Ops chat header that updates live and survives reload/history, by routing the `usage_metadata` already produced by every model call through the existing `data-*` part pipeline. + +**Architecture:** The counter = sum of all `data-usage` parts. The server accumulates a request-scoped run total across every `on_chat_model_end`, streams a `data-usage` part per call (live), and persists the run total onto the turn's final AI message. The history route reconstructs one `data-usage` part per turn from that stored total. The client sums `data-usage` parts in `deriveRunState` and the header renders `runState.tokenUsage`. + +**Tech Stack:** TypeScript, Next.js App Router, LangGraph `streamEvents`, AI SDK UIMessage stream, React, Vitest. + +## Global Constraints + +- **Do NOT commit.** The user will test the changes, then commit. Every task ends WITHOUT a commit; leave all changes in the working tree. Do NOT touch `.env.example`. +- Indentation: `app/api/chat/*` and `lib/agent-chat/*` use 4-space; `.tsx` components use 2-space. Match the file you edit. +- Comments: default none; a single concise WHY line only where non-obvious. No multi-line comment blocks. +- The counter number is the **cumulative billed total** (Σ input_tokens, Σ output_tokens across every counted model call). +- Persisted usage lives in `chat_messages` `metadata.usage_metadata = { input_tokens, output_tokens }` on the turn's LAST AI message (one record per turn). +- `data-usage` part shape is exactly `{ type: 'data-usage', data: { input: number, output: number } }`. +- Scope: streaming chat path only. Guard-node model calls are already skipped upstream and stay uncounted. Non-streaming fallback path, direct-chat greetings, pre-graph triage calls, and the separate `/api/deep-agent` route are OUT of scope. +- Tests run from `apps/web-ui`: full suite `bun run test`; one file `bunx vitest run `. + +--- + +### Task 1: `token-usage.ts` pure helpers (parse + format) + +**Files:** +- Create: `apps/web-ui/lib/agent-chat/token-usage.ts` +- Test: `apps/web-ui/lib/agent-chat/__tests__/token-usage.test.ts` + +**Interfaces:** +- Produces: `export interface TokenUsage { input: number; output: number }`; + `export function parseUsageMetadata(meta: unknown): TokenUsage | null`; + `export function formatTokens(n: number): string`. Consumed by Task 4 (history route + header). + +- [ ] **Step 1: Write the failing test** + +Create `apps/web-ui/lib/agent-chat/__tests__/token-usage.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { parseUsageMetadata, formatTokens } from '../token-usage'; + +describe('parseUsageMetadata', () => { + it('reads the LangChain input_tokens/output_tokens shape', () => { + expect(parseUsageMetadata({ input_tokens: 3, output_tokens: 4 })).toEqual({ input: 3, output: 4 }); + }); + it('reads the {input,output} data-part shape', () => { + expect(parseUsageMetadata({ input: 5, output: 6 })).toEqual({ input: 5, output: 6 }); + }); + it('returns null when both are zero/absent, or meta is not an object', () => { + expect(parseUsageMetadata({ input_tokens: 0, output_tokens: 0 })).toBeNull(); + expect(parseUsageMetadata({})).toBeNull(); + expect(parseUsageMetadata(null)).toBeNull(); + expect(parseUsageMetadata('x')).toBeNull(); + }); + it('keeps a positive side even if the other is missing', () => { + expect(parseUsageMetadata({ output_tokens: 9 })).toEqual({ input: 0, output: 9 }); + }); +}); + +describe('formatTokens', () => { + it('formats by magnitude', () => { + expect(formatTokens(0)).toBe('0'); + expect(formatTokens(999)).toBe('999'); + expect(formatTokens(1000)).toBe('1.0k'); + expect(formatTokens(48200)).toBe('48.2k'); + expect(formatTokens(1_500_000)).toBe('1.5m'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd apps/web-ui && bunx vitest run lib/agent-chat/__tests__/token-usage.test.ts` +Expected: FAIL — `../token-usage` cannot be imported. + +- [ ] **Step 3: Create the module** + +Create `apps/web-ui/lib/agent-chat/token-usage.ts`: + +```ts +// Pure helpers for the chat token counter: parse persisted usage metadata and format counts. +export interface TokenUsage { input: number; output: number } + +// Accepts the LangChain shape (input_tokens/output_tokens) or the data-usage shape (input/output). +export function parseUsageMetadata(meta: unknown): TokenUsage | null { + if (!meta || typeof meta !== 'object') return null; + const m = meta as Record; + const input = Number(m.input_tokens ?? m.input) || 0; + const output = Number(m.output_tokens ?? m.output) || 0; + if (!input && !output) return null; + return { input, output }; +} + +// <1000 -> integer; >=1000 -> one-decimal "k"; >=1e6 -> "m". +export function formatTokens(n: number): string { + if (!Number.isFinite(n) || n <= 0) return '0'; + if (n < 1000) return String(Math.round(n)); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}m`; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd apps/web-ui && bunx vitest run lib/agent-chat/__tests__/token-usage.test.ts` +Expected: PASS. + +- [ ] **Step 5: Do NOT commit.** Leave changes in the working tree (per Global Constraints). + +--- + +### Task 2: `data-usage` part builder + client accumulation + +**Files:** +- Modify: `apps/web-ui/app/api/chat/stream-parts.ts` (add `buildUsagePart`) +- Modify: `apps/web-ui/components/agent/chat/run-state.ts` (add `tokenUsage` to `RunState` + `case 'data-usage'`) +- Test: `apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts` (append) +- Test: `apps/web-ui/components/agent/chat/__tests__/run-state.test.ts` (append) +- Test: `apps/web-ui/lib/agent-chat/__tests__/events.test.ts` (append — reducer no-op) + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `export function buildUsagePart(input: number, output: number): DataPart` (server); `RunState.tokenUsage: { input: number; output: number }` (client). Task 3 consumes `buildUsagePart`; Task 4 (header) consumes `RunState.tokenUsage`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts`: + +```ts +import { buildUsagePart } from '../stream-parts'; + +describe('buildUsagePart', () => { + it('builds a data-usage part', () => { + expect(buildUsagePart(3, 4)).toEqual({ type: 'data-usage', data: { input: 3, output: 4 } }); + }); +}); +``` + +Append to `apps/web-ui/components/agent/chat/__tests__/run-state.test.ts`: + +```ts +import { deriveRunState } from '../run-state'; + +describe('deriveRunState token usage', () => { + const asst = (parts: any[]) => ({ role: 'assistant', id: 'm', parts }); + + it('sums data-usage parts across the thread', () => { + const state = deriveRunState([ + asst([ + { type: 'data-usage', data: { input: 100, output: 20 } }, + { type: 'data-usage', data: { input: 50, output: 10 } }, + ]), + asst([{ type: 'data-usage', data: { input: 5, output: 1 } }]), + ], new Set()); + expect(state.tokenUsage).toEqual({ input: 155, output: 31 }); + }); + + it('defaults to zero when there are no data-usage parts', () => { + const state = deriveRunState([asst([{ type: 'text', text: 'hi' }])], new Set()); + expect(state.tokenUsage).toEqual({ input: 0, output: 0 }); + }); +}); +``` + +Append to `apps/web-ui/lib/agent-chat/__tests__/events.test.ts`: + +```ts +describe('data-usage parts', () => { + it('produce no transcript event', () => { + const events = buildTranscript(msg([{ type: 'data-usage', data: { input: 10, output: 2 } }]), noOpts); + expect(events).toEqual([]); + }); +}); +``` + +(`buildTranscript`, `msg`, and `noOpts` already exist at the top of `events.test.ts`.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +``` +cd apps/web-ui && bunx vitest run app/api/chat/__tests__/stream-parts.test.ts components/agent/chat/__tests__/run-state.test.ts lib/agent-chat/__tests__/events.test.ts +``` +Expected: the `buildUsagePart` and `tokenUsage` tests FAIL (symbol/field missing). The events `data-usage` test likely already PASSES (reducer ignores unknown parts) — that's a regression guard, fine. + +- [ ] **Step 3: Add `buildUsagePart` to `stream-parts.ts`** + +In `apps/web-ui/app/api/chat/stream-parts.ts`, directly after `buildPhasePart` (ends line 13), add: + +```ts +export function buildUsagePart(input: number, output: number): DataPart { + return { type: 'data-usage', data: { input, output } }; +} +``` + +- [ ] **Step 4: Add `tokenUsage` accumulation to `run-state.ts`** + +In `apps/web-ui/components/agent/chat/run-state.ts`: + +(a) Add the field to the `RunState` interface (after `hasApprovalData` at line 26): + +```ts + /** Cumulative billed token totals summed from every data-usage part in the thread. */ + tokenUsage: { input: number; output: number }; +``` + +(b) In `deriveRunState`, add two accumulators next to the other `let`s (after line 42 `let hasApprovalData = false;`): + +```ts + let tokenIn = 0; + let tokenOut = 0; +``` + +(c) Add a case inside the `switch (part.type)` (e.g. after the `data-phase` case, line 73): + +```ts + case 'data-usage': { + tokenIn += Number(part.data?.input) || 0; + tokenOut += Number(part.data?.output) || 0; + break; + } +``` + +(d) Add to the returned object (after `hasApprovalData,` at line 114): + +```ts + tokenUsage: { input: tokenIn, output: tokenOut }, +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: +``` +cd apps/web-ui && bunx vitest run app/api/chat/__tests__/stream-parts.test.ts components/agent/chat/__tests__/run-state.test.ts lib/agent-chat/__tests__/events.test.ts +``` +Expected: PASS. + +- [ ] **Step 6: Do NOT commit.** + +--- + +### Task 3: Server — accumulate, stream live, persist the run total + +**Files:** +- Modify: `apps/web-ui/app/api/chat/route.ts` (declare `runUsage`; emit in `on_chat_model_end`; persist to last AI message) + +**Interfaces:** +- Consumes: `buildUsagePart` from Task 2 (import from `./stream-parts`). +- Produces: streams `data-usage` parts live; writes `metadata.usage_metadata = { input_tokens, output_tokens }` onto the last AI message in the streaming persist path. + +- [ ] **Step 1: Declare the request-scoped accumulator** + +In `apps/web-ui/app/api/chat/route.ts`, inside `processStream` next to the other per-run accumulators, add after line 708 (`const phaseList: AgentPhase[] = [];`): + +```ts + const runUsage = { input: 0, output: 0 }; +``` + +- [ ] **Step 2: Ensure `buildUsagePart` is imported** + +The route already imports from `./stream-parts` (line 12). Add `buildUsagePart` to that existing import list. + +- [ ] **Step 3: Extract + emit usage at `on_chat_model_end`** + +In the `else if (event.event === "on_chat_model_end") {` branch (line 932), insert at the very top of the branch, BEFORE the existing `if (streamStarted) {` block (line 933): + +```ts + const endUsage = (event.data?.output as { usage_metadata?: { input_tokens?: number; output_tokens?: number } } | undefined)?.usage_metadata; + if (endUsage) { + const inTok = Number(endUsage.input_tokens) || 0; + const outTok = Number(endUsage.output_tokens) || 0; + if (inTok || outTok) { + runUsage.input += inTok; + runUsage.output += outTok; + safeEnqueue(buildUsagePart(inTok, outTok) as UIMessageChunk); + } + } +``` + +(Guard-node model runs never reach here — they're short-circuited by `isGuardModelRun` at line 837 — so they stay uncounted, matching the spec.) + +- [ ] **Step 4: Persist the run total onto the last AI message** + +In the streaming persist block, immediately AFTER the `const mapped = newMessages.map(...)` assignment ends (the closing `});` at line 1219) and BEFORE the `if (memoryRecallText.trim()) {` block (line 1225), add: + +```ts + if (runUsage.input || runUsage.output) { + for (let i = mapped.length - 1; i >= 0; i--) { + if (mapped[i].role === 'ai') { + mapped[i].metadata = { + ...(mapped[i].metadata ?? {}), + usage_metadata: { input_tokens: runUsage.input, output_tokens: runUsage.output }, + }; + break; + } + } + } +``` + +(If the mapped element's `metadata` field is typed too narrowly to reassign, widen the map's return type to `metadata?: Record` — do not change its runtime shape.) + +- [ ] **Step 5: Type-check the touched file** + +Run: `cd apps/web-ui && bunx tsc --noEmit 2>&1 | grep -E "app/api/chat/route.ts" || echo "no type errors in route.ts"` +Expected: `no type errors in route.ts`. + +- [ ] **Step 6: Full suite (regression)** + +Run: `cd apps/web-ui && bun run test` +Expected: no NEW failures vs. the known pre-existing baseline; Task 1/2 tests green. + +Note: this task has no isolated unit test (it is streaming glue over LangGraph events); its pure pieces are unit-tested in Tasks 1-2, and its behavior is verified by the manual live check below. + +- [ ] **Step 7: Do NOT commit.** + +--- + +### Task 4: History reconstruction + header rendering + +**Files:** +- Modify: `apps/web-ui/app/api/threads/[threadId]/history/route.ts` (reconstruct a `data-usage` part from `metadata.usage_metadata`) +- Modify: `apps/web-ui/components/agent/workspace/transcript-header.tsx` (render `runState.tokenUsage`) + +**Interfaces:** +- Consumes: `parseUsageMetadata` + `formatTokens` from Task 1; `RunState.tokenUsage` from Task 2. +- Produces: reopened sessions carry `data-usage` parts; the header shows the counter. + +- [ ] **Step 1: Reconstruct the usage part on history load** + +In `apps/web-ui/app/api/threads/[threadId]/history/route.ts`: + +(a) Add the import after the existing `legacy-normalizer` / `ai-content-parts` imports near the top: + +```ts +import { parseUsageMetadata } from '@/lib/agent-chat/token-usage'; +``` + +(b) In `convertPlainMessage`, in the `if (role === 'ai')` branch, AFTER the tool-invocation append loop and BEFORE the `if (parts.length === 0) return null;` guard, add: + +```ts + const usage = parseUsageMetadata(metadata?.usage_metadata); + if (usage && parts.length > 0) { + parts.push({ type: 'data-usage', data: { input: usage.input, output: usage.output } }); + } +``` + +(The `parts.length > 0` guard means usage never resurrects an otherwise-empty/dropped message; the run total is stored on the answer message, which has content. `convertMessage` — the checkpoint fallback — is intentionally NOT changed: the checkpoint holds per-message usage, not the run total, and would double-count; the primary `chat_messages` path is authoritative.) + +- [ ] **Step 2: Render the counter in the header** + +In `apps/web-ui/components/agent/workspace/transcript-header.tsx`: + +(a) Add the import after line 12 (`import type { RunState } ...`): + +```tsx +import { formatTokens } from "@/lib/agent-chat/token-usage"; +``` + +(b) Inside the `ml-auto` flex container (line 139), add as the FIRST child, before the `elapsedMs` span (line 140): + +```tsx + {(runState.tokenUsage.input > 0 || runState.tokenUsage.output > 0) && ( + + ↓ {formatTokens(runState.tokenUsage.input)} · ↑ {formatTokens(runState.tokenUsage.output)} + + )} +``` + +- [ ] **Step 3: Type-check the touched files** + +Run: `cd apps/web-ui && bunx tsc --noEmit 2>&1 | grep -E "history/route.ts|transcript-header" || echo "no type errors in touched files"` +Expected: `no type errors in touched files`. + +- [ ] **Step 4: Full suite (regression)** + +Run: `cd apps/web-ui && bun run test` +Expected: no new failures; existing `run-state.test.ts` / history-related tests still green. + +- [ ] **Step 5: Do NOT commit.** + +--- + +## Manual / integration verification (after Task 4) + +1. Start a chat, send a reasoning/tool-heavy prompt → the header counter (`↓ … · ↑ …`) appears and **ticks up** as the agent runs (input grows fast across iterations; output grows with generated text). +2. Send a second message in the same chat → totals keep accumulating (don't reset). +3. Reload the page / reopen the session from the sidebar → the counter shows the **same** total it had before reload (not lower). +4. A brand-new empty chat shows **no** counter (hidden at zero). +5. Hover the counter → tooltip shows exact `Incoming N tokens · Outgoing N tokens`. + +## Self-Review (completed by plan author) + +- **Spec coverage:** cumulative sum model → Task 2 `deriveRunState`; live per-call emit + run-total persist → Task 3; history reconstruction → Task 4 Step 1; header render + format → Task 4 Step 2 + Task 1 `formatTokens`; `events.ts` no-op → Task 2 test; exclusions (guard/non-stream/direct/deep-agent/convertMessage) → noted in Global Constraints + Task 3/4 notes. +- **Placeholder scan:** none — every code step has full code and exact commands. +- **Type consistency:** `buildUsagePart(input, output)`, `parseUsageMetadata`, `formatTokens`, and `RunState.tokenUsage: { input; output }` are named identically in their definition and every consumer; the `data-usage` shape `{ input, output }` is identical in the builder, the reducer case, the history reconstruction, and the tests. diff --git a/docs/superpowers/specs/2026-07-21-aiops-history-reasoning-render-design.md b/docs/superpowers/specs/2026-07-21-aiops-history-reasoning-render-design.md new file mode 100644 index 00000000..e10b0d77 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-aiops-history-reasoning-render-design.md @@ -0,0 +1,164 @@ +# AI Ops Chat — History Reasoning-Block Rendering (Phase 2) + +Date: 2026-07-21 +Status: Approved (design) +Scope: Phase 2 of the 3-part effort. Read-path only. No storage/write change. + +## Problem + +When a user reopens a previous AI Ops chat session, reasoning content can render +as a raw JSON blob, e.g.: + + [{"type":"reasoning","reasoning":"","signature":"..."}] + +instead of rendering the way the live stream does (a collapsible "Thought for Ns" +block, or — for empty reasoning — nothing at all). Everything else in a reopened +session (plan, tool calls, answer) already renders correctly; only reasoning that +was stored as an array of content blocks is broken. + +## Root cause + +Live and history both feed the same reducer (`buildTranscript` in +`lib/agent-chat/events.ts`) → `AgentTurn` → `ThinkingBlock`. A `reasoning` part +becomes a `thinking` event and renders as the collapsible block. The divergence +is purely at the persistence boundary. + +**Write path** (`app/api/chat/route.ts`, `finally` block): +- `route.ts:1183` — the phase marker is prepended only when + `typeof msg.content === 'string'`. Array content gets no marker. +- `route.ts:1208` — `const content = typeof m.content === 'string' ? m.content : + JSON.stringify(m.content)`. Array content is stringified whole into storage. + +**Read path** (`app/api/threads/[threadId]/history/route.ts`): +- `extractDisplayText` (`:88-105`) parses the JSON array but keeps only + `type:'text'` blocks. A pure-reasoning array has no text block, so it returns + the raw JSON string unchanged. +- `convertPlainMessage` (`:107-129`) → `buildPhaseParts` (`:55-79`) → + `normalizeLegacyContent` (`legacy-normalizer.ts:24-31`) finds no marker → + classifies it as `phase: 'text'` → emits `[{ type: 'text', text: }]` + → the reducer renders it verbatim as an answer. + +Marker-prefixed **string** content (planning / reflection / revision / memory, +and execution/final prose) is unaffected — it reconstructs correctly today. Only +**array** content with reasoning blocks is broken. + +## Key behavioral fact (defines "match live") + +The shared reducer drops any reasoning part whose text is empty +(`events.ts:159-161`: `const text = stripSentinel(part.text ?? ''); if +(text.length === 0) return;`). In the live stream, the signature-only +`"reasoning":""` blocks therefore render as **nothing**. So matching live means: + +- empty-text reasoning → renders nothing (message vanishes, no empty box); +- reasoning with real text → a "Thought for Ns" collapsible block; +- text/answer blocks → the answer, as today. + +## Fix — read-path reconstruction (backward-compatible) + +All changes are in `app/api/threads/[threadId]/history/route.ts`. Nothing about +how messages are stored changes, so the fix repairs **already-stored** history +and future history alike, with no migration and no risk to the write path. + +### New helper: `reconstructAiContentParts(content)` + +- Input: an AI message's stored content — either the JSON string from + `chat_messages` (`convertPlainMessage`) or the raw `msg.content` value from the + checkpoint fallback (`convertMessage`). +- Normalize to an array: if the value is already an array use it; if it is a + string that JSON-parses to an array, use that; otherwise return `null`. +- Return `null` when the content is not a content-block array (so the caller + uses the existing string/marker path unchanged). +- When it is an array, map block-by-block, preserving order, into + `HistoryMessage['parts']`: + - `text` block (`type === 'text'`, string `text`) → `{ type: 'text', text }` + - reasoning block → `{ type: 'reasoning', text: }` **only if the + extracted text is non-empty**; empty reasoning blocks are skipped at the + source (mirrors the reducer's drop, so no empty turns). + - `tool_use` block → skipped (tool calls are reconstructed separately from + `metadata.tool_calls`; skipping avoids a duplicate tool part). + - any other block type → skipped. + +Reasoning-block detection + text extraction handles the shapes that occur: +- `{ type: 'reasoning', reasoning: string, signature? }` → text = `reasoning` +- `{ type: 'thinking', thinking: string }` → text = `thinking` +- `{ type: 'redacted_reasoning' | 'redacted_thinking' | 'reasoning_content', … }` + → typically no text → contributes nothing +- `{ reasoningContent: { reasoningText: { text: string } } }` (raw Bedrock) → + text = `reasoningText.text` + +The emitted `reasoning` part carries no `data-phase`; it renders as a generic +"Thought for Ns" block (phase-specific banner coloring is not reconstructed for +raw extended-thinking blocks — consistent with existing reload limitations noted +in `history/route.ts`). + +### Wiring + +- `convertPlainMessage` (DB path), **AI branch only**: call + `reconstructAiContentParts(msg.content)` first. If non-null, use those parts; + else fall back to today's `extractDisplayText` + `buildPhaseParts`. Then append + tool-invocation parts from `metadata.tool_calls` exactly as today, and keep the + existing `if (parts.length === 0) return null` guard — a message that + reconstructs to nothing (empty-reasoning-only, no tool calls) is dropped, so it + vanishes like live. The `HistoryMessage.content` string field is set to the + concatenation of the reconstructed `text` blocks (empty string when there are + none) — reasoning text is never placed in `content`, only in `reasoning` parts. +- `convertMessage` (checkpoint-fallback path), **AI branch only**: same + first-try/fallback, passing the raw `msg.content` (which may already be an + array). +- The human and tool branches are untouched. `extractDisplayText` continues to + handle user multimodal text+image arrays; the new reconstruction is gated to + the AI role. + +## Result + +- `[{"type":"reasoning","reasoning":"",...}]` → dropped → nothing shown. +- reasoning block with text → "Thought for Ns" block. +- mixed `[reasoning, text]` → a reasoning part + the answer, in order. +- planning / reflection / marker strings, tool calls, answers → unchanged. +- raw JSON → gone from the interactive transcript. + +## Why it is production-safe + +- Read path only. The write path, storage format, and live stream are untouched, + so nothing about how sessions are recorded or streamed changes. +- Additive branch: array content takes a new path; every existing string/marker + case falls through to today's exact code. Regression tests lock that. +- Gated to the AI role, so the human multimodal path cannot regress. +- No schema, infra, feature-flag, or API-contract change. + +## Testing + +Unit tests (Vitest) against the history route's conversion. If the target +functions are not exported, export `reconstructAiContentParts` (and, if needed, +`convertPlainMessage`) for testing, or add a thin exported wrapper — no behavior +change. + +1. Pure empty-reasoning array (`[{type:'reasoning',reasoning:''}]`) on an AI + message with no tool calls → message is dropped (`convertPlainMessage` returns + `null`). +2. Reasoning-with-text array → yields exactly one `reasoning` part carrying that + text, no `text` part. +3. Mixed `[reasoning(text), text]` → yields a `reasoning` part then a `text` + part, in that order. +4. `[reasoning(empty), tool_use]` + `metadata.tool_calls` for the same id → + exactly one tool-invocation part, no `reasoning` part, no duplicate tool part. +5. `{reasoningContent:{reasoningText:{text:'…'}}}` block → reasoning part with + that text (raw Bedrock shape). +6. Regression — marker string content (`PLANNING_PHASE_START\n…`, + `REFLECTION_PHASE_START\n…`) → identical output to today (reasoning part, + humanized where applicable). +7. Regression — human multimodal array (`[{type:'text'},{type:'image_url'}]`) → + unchanged (still routed through `extractDisplayText`, not the new helper). + +Beyond units: reopen a real session that currently shows raw JSON and confirm the +blob is gone and the transcript matches the live rendering. + +## Out of scope (flagged, not fixed here) + +- The write/storage format (`route.ts:1183`, `:1208`) — left as-is; the read path + interprets it. +- Export paths (`lib/chat-export.ts`, `lib/agent-ops/export-markdown.ts`, + `lib/agent-ops/export-pdf.ts`) may share the raw-JSON / "Thinking:" issue. + Separate follow-up unless explicitly pulled in. +- Phase-banner coloring for reconstructed raw extended-thinking blocks (they + render as generic "Thought" blocks). diff --git a/docs/superpowers/specs/2026-07-21-aiops-multiturn-crash-fix-design.md b/docs/superpowers/specs/2026-07-21-aiops-multiturn-crash-fix-design.md new file mode 100644 index 00000000..57295c1b --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-aiops-multiturn-crash-fix-design.md @@ -0,0 +1,145 @@ +# AI Ops Chat — Multi-Turn / Resume Crash Fix (Phase 1) + +Date: 2026-07-21 +Status: Approved (design) +Scope: Phase 1 of a 3-part effort. This spec covers only the crash-fix. + +## Problem + +On the AI Ops chat, a brand-new chat with a single query works. But a **second +query on the same chat fails**, and reopening a **previous history session and +asking again fails** the same way. Both surface as a Bedrock error: + +> ValidationException: The content field in the Message object at messages.N is empty. + +### Why turn 2+ and resume are the same bug + +On any turn after the first — whether a live chat's next query or a reopened +history session — the backend does **not** trust the browser's message array. It +reloads the full prior conversation from the LangGraph **Postgres checkpointer** +(keyed by `thread_id`, no TTL) and appends only the new user message +(`apps/web-ui/app/api/chat/route.ts:420-486`). This is the intended "resume where +it left off" design and it already exists. Both #2 (live multi-turn) and the +functional core of #3 (resume + ask again) flow through this identical replay +path. + +### Root cause + +On turn 1, the model can emit an AI message whose `content` array contains +**only a reasoning/thinking block** (no text block, no tool calls) — e.g. Claude +Sonnet consumes its output budget mid-reasoning. Turn 1 still succeeds because a +`finalize` step synthesizes the real answer, but that reasoning-only message is +checkpointed into thread state. + +On the next turn the checkpoint is replayed through +`sanitizeMessagesForBedrock()` (`apps/web-ui/lib/agent/agent-shared.ts`): + +- `getRecentMessages()` filters empty-content messages **before** stripping, so + it keeps this message (its content array is non-empty at that point). +- `sanitizeMessagesForBedrock()` → `stripReasoningBlocks()` removes the reasoning + block, leaving `content: []`, and then **pushes it unconditionally** + (`agent-shared.ts:713-714`) with no tool calls. +- The message is sent to Bedrock with empty content → ValidationException. + +Nothing re-checks for emptiness *after* stripping. The gap is currently +untested (`sanitize-bedrock.test.ts` covers null-text reasoning and +thinking+text+tool_use, but not reasoning-only-becomes-empty). + +## Fix + +Single change site: `sanitizeMessagesForBedrock()` in +`apps/web-ui/lib/agent/agent-shared.ts` (lines 711-714). This is the one function +every Bedrock invoke routes through (`fast-agent.ts:139`, +`planning-agent.ts:427` and `:767`). + +After `const cleaned = stripReasoningBlocks(msg)`: if `cleaned` is an AI message +with **empty content AND no tool calls**, **replace its content with a minimal +non-empty placeholder** (`"(reasoning omitted)"`) before pushing it. Do not drop +it. + +### Why replace, not drop + +Dropping the message breaks Bedrock's strict Human/AI role alternation. +`getRecentMessages()` runs *before* `sanitize` and already enforces alternation +(`agent-shared.ts:593-613`, inserting `"Proceed."` / `"Acknowledged."`); nothing +merges consecutive same-role messages anywhere before the Bedrock call. Turn 1 +persists both the reasoning-only AI message *and* the `finalize` answer as two +adjacent AI messages (`fast-agent.ts:270`), so on turn 2 the sequence is +`[Human(q1), AI(reasoning-only), Human("Proceed."), AI(final), Human(q2)]`. +Dropping `AI(reasoning-only)` leaves `Human(q1)` and `Human("Proceed.")` +adjacent → a *different* ValidationException. Replacing the emptied content with +a placeholder keeps the assistant slot filled, so alternation stays valid: +`[Human(q1), AI("(reasoning omitted)"), Human("Proceed."), AI(final), Human(q2)]`. +The placeholder is consistent with the synthetic messages the code already uses +(`"Proceed."`, `"Acknowledged."`, `"[Tool result unavailable — synthetic +placeholder]"`). + +### Predicate (the safety hinges here) + +- `hasToolCalls` = `tool_calls.length > 0` **OR** the content array contains a + `tool_use` block. +- `contentEmpty` = an empty array, or an array whose blocks are all empty / + whitespace-only `text` blocks; or a string that is empty / whitespace. +- **Replace content only when** `isAIMessage && contentEmpty && !hasToolCalls`. + +This never touches a valid tool-use turn (empty text but real `tool_calls` is +legitimate for Bedrock) and never touches a normal text answer. It rewrites only +the message that is otherwise guaranteed to trigger the empty-content +ValidationException, and leaves message count and roles intact. + +## Why it is production-safe + +- The turn-1 single-query path is unaffected: it does not replay old + reasoning-only messages, and a live turn never produces an already-empty + message at this boundary. +- Message count and role order are preserved (content is rewritten in place, not + removed), so role alternation cannot regress. +- The tool-result adjacency logic (`agent-shared.ts:718-752`) is untouched — the + replaced message has no tool calls, so the `pendingIds` block is a no-op for + it. +- No schema, infra, feature-flag, or API-contract change. One pure function. + +## Testing + +TDD — write the failing tests first, in +`apps/web-ui/lib/agent/sanitize-bedrock.test.ts`: + +1. **The bug (must fail before the fix):** a reasoning-only AI message + (`content: [ { reasoningContent: { reasoningText: { text: null } } } ]`, no + text, no tool_calls) placed between valid messages → after `sanitize` that + message is present with **non-empty** content (the `"(reasoning omitted)"` + placeholder) and no empty-content AI message survives. Today it survives with + `content: []`, so this fails before the fix. +2. **Alternation preserved:** given + `[Human, AI(reasoning-only), Human, AI(text), Human]`, the sanitized output + has the same length and role order with no consecutive same-role pair. +3. **The guard (must not over-rewrite):** an AI message with empty content + **but** `tool_calls` present → content left untouched, with its `ToolMessage` + still re-emitted immediately after it. +4. **Normal answer untouched:** a plain-text AI message is returned by the same + reference (unchanged), as the existing test at + `sanitize-bedrock.test.ts:41-46` asserts. +5. Full existing suite (`cd apps/web-ui && bun run test`) passes — no + regression. + +Beyond unit tests, verify manually: reproduce a real turn-2 on a fresh chat, and +reopen a history session and ask again — confirm no ValidationException and the +agent continues with prior context. + +## What this fixes + +- #2 — live multi-turn (second query on the same chat). +- #3 — the functional core of resume: reopening a history session and asking a + new question no longer crashes and continues from the restored checkpoint + context. + +## Out of scope (tracked for later phases) + +- **Phase 2 — reasoning render in history (#1):** array-content reasoning is + persisted via `JSON.stringify` (`route.ts:1208`) with no phase marker + (`route.ts:1183`), so on replay the history route renders it as a raw-JSON + `text` answer instead of a "Thought for Ns" thinking block. Separate spec. +- **Gap A — direct-chat continuity:** greeting / simple-Q&A turns + (`apps/web-ui/app/api/chat/direct-chat.ts`) bypass the checkpointer, so a + session made only of those has an empty checkpoint and falls back to a lossy + display-history replay. Triage separately. diff --git a/docs/superpowers/specs/2026-07-24-aiops-token-counter-design.md b/docs/superpowers/specs/2026-07-24-aiops-token-counter-design.md new file mode 100644 index 00000000..610ec673 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-aiops-token-counter-design.md @@ -0,0 +1,119 @@ +# AI Ops Chat — Token Counter (incoming / outgoing) Design + +Date: 2026-07-24 +Status: Approved (design) +Scope: Live + persisted cumulative token counter in the AI Ops chat header. + +## Goal + +Show a running "incoming / outgoing token" total in the AI Ops chat header +(next to the elapsed timer in `transcript-header.tsx`). The number is the +**cumulative billed total** — the sum of `input_tokens` and `output_tokens` +across every model call in the session — and it **survives page reload** and is +shown when reopening a past session. + +## Data source (already present) + +Every Bedrock/Anthropic response carries LangChain `AIMessage.usage_metadata` +(`{ input_tokens, output_tokens, total_tokens }`). Today it is read only for an +optional console audit line (`agent-shared.ts` `llmAuditLog`) and otherwise +discarded. `usage_metadata` also survives checkpoint round-trips +(`agent-shared.ts` copies it in `stripReasoningBlocks` / `sanitizeMessagesForBedrock`). + +## Core model: "sum of all `data-usage` parts" + +The counter equals the sum of every `data-usage` part the client has seen. The +live path and the history path both feed that one accumulator and are designed to +produce the **same** total. + +Why this needs care: one visible turn (one assistant bubble) involves many model +calls — planner, each executor iteration, reflector, guard, memory. The live +stream sees all of them via `on_chat_model_end`; that is the true billed total. +The persisted `chat_messages` are only the *visible* messages, so copying each +message's own `usage_metadata` would miss the hidden calls and the number would +drop on reload. To keep live and reload equal: + +- The server accumulates a **request-scoped run total** = Σ input / Σ output over + every `on_chat_model_end` in the request. +- **Live:** each model-call end streams a `data-usage` part (that call's input / + output). The client sums them → the counter ticks up as the agent works. +- **Persist:** the run total (covering hidden calls too) is attached to the + turn's final assistant message as `metadata.usage_metadata`. +- **Reload:** the history route reconstructs one `data-usage` part per turn from + that stored total. + +Live (N per-call parts summing to T) and reload (one part = T) both sum to T for +the turn, so the total is identical before and after reload. + +## Changes (6 files; follow the existing `data-phase` / `data-memory` pattern) + +### Server +1. `app/api/chat/stream-parts.ts` — add + `buildUsagePart(input: number, output: number)` returning + `{ type: 'data-usage', data: { input, output } }`. +2. `app/api/chat/route.ts`: + - In the `on_chat_model_end` branch (~line 932): read + `event.data?.output?.usage_metadata`; add `input_tokens` / `output_tokens` + to a request-scoped `runUsage` accumulator; `safeEnqueue(buildUsagePart(...))` + with that call's input/output. Guard for missing usage (skip if absent). + - In the persist mapping (~lines 1206-1219): on the **last** AI message of + `newMessages`, set `metadata.usage_metadata = { input_tokens: runUsage.input, + output_tokens: runUsage.output }` (alongside the existing `tool_calls` + metadata). Only one message per run carries it, to avoid double counting. +3. `app/api/threads/[threadId]/history/route.ts` — in `convertPlainMessage` + (AI branch), when `metadata.usage_metadata` is present, append + `{ type: 'data-usage', data: { input, output } }` to the parts (mirrors the + existing `data-phase` reconstruction). Read `input_tokens`/`output_tokens` + (fall back to `input`/`output`). Do the same in `convertMessage` only if the + checkpoint carries it (optional; primary path is `chat_messages`). + +### Client +4. `components/agent/chat/run-state.ts` — add + `tokenUsage: { input: number; output: number }` to `RunState` (default + `{ input: 0, output: 0 }`), and a `case 'data-usage':` in `deriveRunState` + that adds `part.data.input` / `part.data.output` across all parts. Flows + through `runState` automatically — no `use-chat-session.ts` change. +5. `components/agent/workspace/transcript-header.tsx` — render + `runState.tokenUsage` in the `ml-auto` row just before the elapsed timer / + menu, styled `font-mono text-xs text-muted-foreground` to match. Hidden when + both values are 0. Format: `↓ {in} · ↑ {out}` with a `title` tooltip showing + exact counts (↓ = incoming/input, ↑ = outgoing/output). +6. `formatTokens(n)` helper (colocate in `run-state.ts` or an existing util): + `< 1000` → the integer; `>= 1000` → one-decimal `k` (e.g. `48200 → "48.2k"`); + `>= 1_000_000` → one-decimal `m`. + +## `events.ts` interaction + +The transcript reducer (`lib/agent-chat/events.ts`) switches on `part.type`; a +`data-usage` part falls to the existing `default` branch and emits no transcript +event. No change needed there; a test locks this (no stray event). + +## Error handling + +- Missing/partial `usage_metadata` on a call → skip that call's contribution + (no part emitted); never throw. +- Persist step: if `runUsage` is `{0,0}` or there is no AI message in + `newMessages`, attach nothing. +- History: if `metadata.usage_metadata` is absent or malformed, emit no usage + part (the turn simply contributes 0). + +## Scope / known exclusions (optional follow-ups, not in this spec) + +- Pre-graph triage / auto-skill-select model calls made **before** + `graph.streamEvents` are not counted (small). +- Direct-chat greetings (`app/api/chat/direct-chat.ts`, separate persist path) + are not counted unless the same hook is added there. +- The separate `/api/deep-agent` workspace (different route/UI) is out of scope. + +## Testing + +- `run-state.ts`: `deriveRunState` sums multiple `data-usage` parts into + `tokenUsage`; absent parts → `{0,0}`. +- `stream-parts.ts`: `buildUsagePart(3,4)` → `{ type:'data-usage', data:{input:3,output:4} }`. +- history route: an AI message whose `metadata.usage_metadata` is set yields + exactly one `data-usage` part with the right numbers; absent → none. +- `events.ts`: a `data-usage` part produces no transcript event. +- `formatTokens`: `999→"999"`, `1000→"1.0k"`, `48200→"48.2k"`, `1_500_000→"1.5m"`. +- Consistency: for one turn, the sum of the live per-call parts equals the single + persisted run-total part (documented assertion in the run-state test using both + shapes). diff --git a/libs/prisma/migrations/20260717101822_add_telegram_bot_link/migration.sql b/libs/prisma/migrations/20260717101822_add_telegram_bot_link/migration.sql new file mode 100644 index 00000000..b80c4606 --- /dev/null +++ b/libs/prisma/migrations/20260717101822_add_telegram_bot_link/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - You are about to drop the column `contentHash` on the `inventory_resources` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "agent_memories_embedding_hnsw"; + +-- DropIndex +DROP INDEX "idx_inventory_search_vector"; + +-- DropIndex +DROP INDEX "idx_kb_document_chunks_embedding"; + +-- AlterTable +ALTER TABLE "inventory_resources" DROP COLUMN "contentHash"; + +-- AlterTable +ALTER TABLE "inventory_sync_status" ALTER COLUMN "errors" SET DEFAULT ARRAY[]::TEXT[]; + +-- CreateTable +CREATE TABLE "telegram_bot_links" ( + "id" TEXT NOT NULL, + "secretToken" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "telegram_bot_links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_bot_links_secretToken_key" ON "telegram_bot_links"("secretToken"); + +-- CreateIndex +CREATE INDEX "telegram_bot_links_tenantId_idx" ON "telegram_bot_links"("tenantId"); + +-- AddForeignKey +ALTER TABLE "telegram_bot_links" ADD CONSTRAINT "telegram_bot_links_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/libs/prisma/migrations/20260725120000_restore_raw_sql_indexes/migration.sql b/libs/prisma/migrations/20260725120000_restore_raw_sql_indexes/migration.sql new file mode 100644 index 00000000..53379ab0 --- /dev/null +++ b/libs/prisma/migrations/20260725120000_restore_raw_sql_indexes/migration.sql @@ -0,0 +1,27 @@ +-- Restore the three raw-SQL indexes that 20260717101822_add_telegram_bot_link dropped. +-- +-- Those indexes are created by earlier hand-written migrations and are invisible to +-- schema.prisma (pgvector `Unsupported("vector")` / tsvector columns have no Prisma +-- index syntax), so `prisma migrate dev` read them as drift and emitted DROP INDEX +-- alongside the intended telegram_bot_links table. Dropping them is silent: queries +-- still return correct results, they just fall back to sequential scans — memory +-- recall, KB semantic search and inventory full-text search all degrade under load. +-- +-- The upstream migration is left byte-identical (editing an already-applied migration +-- would fail `prisma migrate deploy` on a checksum mismatch); this forward migration +-- is idempotent, so it is safe whether or not the drops ever reached a given database. + +-- pgvector HNSW index for cosine similarity on agent memories +-- (originally: 20260701235449_agent_memory_foundation) +CREATE INDEX IF NOT EXISTS "agent_memories_embedding_hnsw" + ON "agent_memories" USING hnsw ("embedding" vector_cosine_ops); + +-- GIN index backing inventory full-text search +-- (originally: 20260405_add_inventory_search_vector, recreated by 20260408) +CREATE INDEX IF NOT EXISTS idx_inventory_search_vector + ON "inventory_resources" USING GIN ("searchVector"); + +-- IVFFlat vector index for KB document-chunk similarity search +-- (originally: 20260407194124_add_kb_document_chunks) +CREATE INDEX IF NOT EXISTS idx_kb_document_chunks_embedding + ON kb_document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); diff --git a/libs/prisma/schema.prisma b/libs/prisma/schema.prisma index 41589a29..fdb7f526 100644 --- a/libs/prisma/schema.prisma +++ b/libs/prisma/schema.prisma @@ -32,6 +32,7 @@ model Tenant { configs TenantConfig[] providerModels ProviderModel[] slackWorkspaceLinks SlackWorkspaceLink[] + telegramBotLinks TelegramBotLink[] @@index([status]) @@map("tenants") @@ -74,6 +75,25 @@ model SlackWorkspaceLink { @@map("slack_workspace_links") } +// TelegramBotLink — reverse index from a Telegram bot's webhook secret token to +// the tenant that registered it. Unlike Slack (which carries team_id on every +// payload), Telegram updates carry no tenant-identifying field at all — the +// secret token echoed back in the X-Telegram-Bot-Api-Secret-Token header is +// the only value that ties a request back to the tenant who registered that +// bot's webhook, so it doubles as both the auth check and the tenant lookup key. +model TelegramBotLink { + id String @id @default(cuid()) + secretToken String @unique + tenantId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@index([tenantId]) + @@map("telegram_bot_links") +} + // ProviderModel — tenant-scoped self-hosted LLM provider configurations // Stores OpenAI-compatible endpoint configs (Ollama, vLLM, LiteLLM, LocalAI) // Bedrock is the built-in default and is NOT stored here