From 47ba4add3495af298be1d38d493514f164223671 Mon Sep 17 00:00:00 2001 From: Omar Hussain Date: Tue, 21 Jul 2026 18:00:53 +0530 Subject: [PATCH 1/6] fix(aiops): rewrite empty-after-strip reasoning messages to placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn-1 reasoning-only AI message (content is a lone reasoning block, no text, no tool_calls) becomes empty after stripReasoningBlocks() and crashed Bedrock on turn 2 — and on history-session resume — with "The content field in the Message object ... is empty". Both paths replay the LangGraph checkpoint through sanitizeMessagesForBedrock(), so both hit it. Fix: in sanitizeMessagesForBedrock(), when an AI message is empty after stripping and has no tool calls, rewrite its content to "(reasoning omitted)" instead of leaving it empty. Rewriting (not dropping) preserves message count and Human/AI role alternation, which getRecentMessages enforces upstream and Bedrock requires. Adds a module-private isEmptyAiMessageContent() predicate that leaves tool-use turns and normal answers untouched. Includes design spec + implementation plan under docs/superpowers/. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web-ui/lib/agent/agent-shared.ts | 39 ++- .../web-ui/lib/agent/sanitize-bedrock.test.ts | 86 +++++++ .../2026-07-21-aiops-multiturn-crash-fix.md | 231 ++++++++++++++++++ ...-07-21-aiops-multiturn-crash-fix-design.md | 145 +++++++++++ 4 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-21-aiops-multiturn-crash-fix.md create mode 100644 docs/superpowers/specs/2026-07-21-aiops-multiturn-crash-fix-design.md 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/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/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/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. From 3df80a90f1f6a7642a7323f7fcb9ce64ab04be4b Mon Sep 17 00:00:00 2001 From: Omar Hussain Date: Wed, 22 Jul 2026 09:03:48 +0530 Subject: [PATCH 2/6] fix(aiops): render array-content reasoning as thinking blocks in history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopened history sessions rendered extended-thinking as a raw [{"type":"reasoning",...}] JSON blob. The write path JSON.stringify's AI messages whose content is a content-block array, and the read path only kept text blocks, so a pure-reasoning array fell through as a plain text answer. Add a read-path helper (lib/agent-chat/ai-content-parts.ts) that reconstructs such arrays block-by-block into reasoning/text parts — dropping empty-text reasoning to match the live reducer — and wire it into the AI branch of convertPlainMessage and convertMessage in the history route, with the existing string/marker path as the untouched fallback. Read-path only: storage, the write path, and the live stream are unchanged, so it fixes existing and future sessions with no migration. Gated to the AI role; human/tool paths untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/threads/[threadId]/history/route.ts | 22 +- .../__tests__/ai-content-parts.test.ts | 77 ++++ .../web-ui/lib/agent-chat/ai-content-parts.ts | 53 +++ ...26-07-21-aiops-history-reasoning-render.md | 335 ++++++++++++++++++ ...1-aiops-history-reasoning-render-design.md | 164 +++++++++ 5 files changed, 647 insertions(+), 4 deletions(-) create mode 100644 apps/web-ui/lib/agent-chat/__tests__/ai-content-parts.test.ts create mode 100644 apps/web-ui/lib/agent-chat/ai-content-parts.ts create mode 100644 docs/superpowers/plans/2026-07-21-aiops-history-reasoning-render.md create mode 100644 docs/superpowers/specs/2026-07-21-aiops-history-reasoning-render-design.md 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..54ca5aec 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,7 @@ 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 { humanizePlanning, stripWorkingMemoryPrelude } from '@/app/api/chat/stream-parts'; interface HistoryMessage { @@ -114,12 +115,19 @@ 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' }); } 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 +146,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/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/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/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/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). From 14fdc7470cb8026e63d1d908d237d362cc10e333 Mon Sep 17 00:00:00 2001 From: Omar Hussain Date: Wed, 22 Jul 2026 09:46:58 +0530 Subject: [PATCH 3/6] feat(aiops): raise chat input cap to 6000 chars and default output tokens to 8192 - Chat composer input limit 2000 -> 6000 chars (counter warning 1800 -> 5400). - DEFAULT_MAX_OUTPUT_TOKENS 4096 -> 8192 (fallback when a ProviderModel record sets no maxTokens); Bedrock/Anthropic Claude models used by AI Ops support it. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web-ui/components/agent/workspace/composer.tsx | 4 ++-- apps/web-ui/lib/agent/model-factory.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web-ui/components/agent/workspace/composer.tsx b/apps/web-ui/components/agent/workspace/composer.tsx index 97a42c21..d0160e79 100644 --- a/apps/web-ui/components/agent/workspace/composer.tsx +++ b/apps/web-ui/components/agent/workspace/composer.tsx @@ -14,8 +14,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/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 From 6716c282081bbcb575d41bc67830d2ecad1e0282 Mon Sep 17 00:00:00 2001 From: Omar Hussain Date: Fri, 17 Jul 2026 16:18:53 +0530 Subject: [PATCH 4/6] fix(agent-ops): Telegram tenant resolution + gateway webhook auth bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound Telegram webhooks were being redirected to /login by the NextAuth middleware (gateway routes were never exempted from the auth matcher, unlike api/v1/trigger), and even once reachable, the adapter resolved tenantId from Telegram's chat.id instead of an actual tenant — a value that never matches, so every request was rejected as unauthorized. Adds TelegramBotLink (secretToken -> tenantId), mirroring the existing SlackWorkspaceLink pattern, since Telegram's payload carries no tenant- identifying field of its own. Also stops the gateway from returning a hard 400 for text-less messages (e.g. Telegram's automatic /start) on async "streaming" channels, since a non-2xx there makes Telegram retry the same update forever and blocks its whole queue behind it. Co-Authored-By: Claude Sonnet 5 --- .../api/agent-ops/settings/telegram/route.ts | 21 +++++ .../telegram-bot-link/interface.ts | 48 ++++++++++ .../telegram-bot-link/postgres.ts | 87 +++++++++++++++++++ apps/web-ui/lib/db/repository-factory.ts | 7 ++ .../lib/gateway/adapters/telegram-adapter.ts | 71 +++++++++------ apps/web-ui/lib/gateway/gateway-service.ts | 11 ++- apps/web-ui/middleware.ts | 2 +- .../gateway/adapters/telegram-adapter.test.ts | 55 ++++++++++++ .../tests/gateway/gateway-service.test.ts | 14 +++ .../migration.sql | 40 +++++++++ libs/prisma/schema.prisma | 20 +++++ 11 files changed, 348 insertions(+), 28 deletions(-) create mode 100644 apps/web-ui/lib/db/repositories/telegram-bot-link/interface.ts create mode 100644 apps/web-ui/lib/db/repositories/telegram-bot-link/postgres.ts create mode 100644 libs/prisma/migrations/20260717101822_add_telegram_bot_link/migration.sql 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/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..3953ffef 100644 --- a/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts +++ b/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts @@ -8,6 +8,7 @@ import type { NextRequest } from 'next/server'; import { TenantConfigService } from '@/lib/tenant-config-service'; +import { getTelegramBotLinkRepository } from '@/lib/db/repository-factory'; import { env } from '@/env'; import { buildDashboardRespondUrl, buildDashboardRunUrl } from '@/lib/gateway/utils/dashboard-url'; import { ChannelRateLimiter } from '@/lib/gateway/utils/rate-limiter'; @@ -56,6 +57,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 +107,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 +131,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 +171,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`, { @@ -368,7 +381,7 @@ export class TelegramAdapter implements ChannelAdapter { private parseMessage( message: Record, - headerTenantId: string, + resolvedTenantId: string | null, ): GatewayMessage { const from = message.from as Record | undefined; const chat = message.chat as Record | undefined; @@ -377,7 +390,13 @@ 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'); @@ -430,7 +449,7 @@ export class TelegramAdapter implements ChannelAdapter { 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 +463,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..517216b5 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 }, diff --git a/apps/web-ui/middleware.ts b/apps/web-ui/middleware.ts index f1be636f..ac9d96d6 100644 --- a/apps/web-ui/middleware.ts +++ b/apps/web-ui/middleware.ts @@ -74,6 +74,6 @@ export default withAuth( 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|_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..1d576c57 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,7 @@ 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 type { ScheduledTask, AgentOpsRun } from '@/lib/agent-ops/types'; vi.mock('@/lib/tenant-config-service', () => ({ @@ -13,12 +14,30 @@ vi.mock('@/lib/tenant-config-service', () => ({ }, })); +// 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); }); it('has correct channel metadata', () => { @@ -72,6 +91,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: { diff --git a/apps/web-ui/tests/gateway/gateway-service.test.ts b/apps/web-ui/tests/gateway/gateway-service.test.ts index b8355b6c..0ac9ba5b 100644 --- a/apps/web-ui/tests/gateway/gateway-service.test.ts +++ b/apps/web-ui/tests/gateway/gateway-service.test.ts @@ -98,6 +98,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: '', 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/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 From 2586975154abfa0a4a343be47663a33e6eb396da Mon Sep 17 00:00:00 2001 From: Omar Hussain Date: Tue, 21 Jul 2026 11:58:46 +0530 Subject: [PATCH 5/6] feat(agent-ops): Telegram conversational continuity + /new reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Telegram chat now behaves as an ongoing conversation. When a run is awaiting the user's input (agent asked a clarifying question), the next message is fed into that same run/thread instead of spawning a new run — so answers keep context. Once a run has completed and returned a result, the next message starts a fresh run. - findResumableTelegramRun(chatId, idleCutoff): resolves the chat's run currently awaiting input, within a 30-min idle window (stale questions are abandoned). Keyed on trigger.chatId, source=telegram. - /new (and /n): cancels a pending awaiting-input run and confirms, so the next message starts fresh rather than being read as the answer. - gateway: 'reset' action wired; adapter gains sendSessionReset. Fixes the prior behavior where every Telegram message — including a reply to the agent's own clarifying question — created a separate run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web-ui/lib/agent-ops/agent-ops-service.ts | 21 ++++ .../repositories/agent-ops-run/interface.ts | 1 + .../db/repositories/agent-ops-run/postgres.ts | 18 +++ .../lib/gateway/adapters/telegram-adapter.ts | 108 ++++++++++++++---- apps/web-ui/lib/gateway/gateway-service.ts | 12 ++ apps/web-ui/lib/gateway/types.ts | 8 +- .../gateway/adapters/telegram-adapter.test.ts | 81 +++++++++++++ .../tests/gateway/gateway-service.test.ts | 15 +++ 8 files changed, 238 insertions(+), 26 deletions(-) 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/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/gateway/adapters/telegram-adapter.ts b/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts index 3953ffef..90059d1b 100644 --- a/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts +++ b/apps/web-ui/lib/gateway/adapters/telegram-adapter.ts @@ -9,6 +9,7 @@ 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'; @@ -32,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 { @@ -379,10 +386,10 @@ export class TelegramAdapter implements ChannelAdapter { ).catch(() => null); } - private parseMessage( + private async parseMessage( message: Record, resolvedTenantId: string | null, - ): GatewayMessage { + ): Promise { const from = message.from as Record | undefined; const chat = message.chat as Record | undefined; const userId = (from?.id as number) ?? 0; @@ -398,46 +405,68 @@ export class TelegramAdapter implements ChannelAdapter { } 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, @@ -447,6 +476,35 @@ 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, resolvedTenantId: string | null, diff --git a/apps/web-ui/lib/gateway/gateway-service.ts b/apps/web-ui/lib/gateway/gateway-service.ts index 517216b5..76587cee 100644 --- a/apps/web-ui/lib/gateway/gateway-service.ts +++ b/apps/web-ui/lib/gateway/gateway-service.ts @@ -186,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/tests/gateway/adapters/telegram-adapter.test.ts b/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts index 1d576c57..79b8c464 100644 --- a/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts +++ b/apps/web-ui/tests/gateway/adapters/telegram-adapter.test.ts @@ -2,6 +2,7 @@ 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', () => ({ @@ -14,6 +15,13 @@ 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. @@ -38,6 +46,8 @@ describe('TelegramAdapter', () => { 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', () => { @@ -149,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 0ac9ba5b..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; } @@ -149,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); + }); }); From 825d81ffa778534b0c53caabb83be50b25f75157 Mon Sep 17 00:00:00 2001 From: Omar Hussain Date: Fri, 24 Jul 2026 17:52:59 +0530 Subject: [PATCH 6/6] feat(aiops): live + persisted token counter in chat header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a cumulative incoming/outgoing token total in the AI Ops chat header, next to the elapsed timer. The counter sums usage_metadata (already produced by every Bedrock/Claude call but previously discarded) across all model calls. - Server: accumulate a request-scoped run total across every non-guard on_chat_model_end, stream a data-usage part per call (live), and persist the run total onto the turn's final AI message (metadata.usage_metadata). - History: reconstruct one data-usage part per turn from the stored total so the count survives reload and shows when reopening a session. - Client: deriveRunState sums data-usage parts into RunState.tokenUsage; the header renders "↓ in · ↑ out" (hidden at zero, exact counts on hover). Read-path/stream/persist only; no schema change. Pure helpers (parseUsageMetadata/formatTokens) and the reducer accumulation are unit-tested; live==reload parity verified by review. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/chat/__tests__/stream-parts.test.ts | 7 + apps/web-ui/app/api/chat/route.ts | 26 +- apps/web-ui/app/api/chat/stream-parts.ts | 4 + .../api/threads/[threadId]/history/route.ts | 5 + .../agent/chat/__tests__/run-rail.test.tsx | 1 + .../agent/chat/__tests__/run-state.test.ts | 20 + .../web-ui/components/agent/chat/run-state.ts | 10 + .../workspace/__tests__/agent-turn.test.tsx | 1 + .../__tests__/transcript-header.test.tsx | 38 ++ .../agent/workspace/transcript-header.tsx | 10 + .../lib/agent-chat/__tests__/events.test.ts | 7 + .../agent-chat/__tests__/token-usage.test.ts | 30 ++ apps/web-ui/lib/agent-chat/token-usage.ts | 19 + .../plans/2026-07-24-aiops-token-counter.md | 400 ++++++++++++++++++ .../2026-07-24-aiops-token-counter-design.md | 119 ++++++ 15 files changed, 696 insertions(+), 1 deletion(-) create mode 100644 apps/web-ui/lib/agent-chat/__tests__/token-usage.test.ts create mode 100644 apps/web-ui/lib/agent-chat/token-usage.ts create mode 100644 docs/superpowers/plans/2026-07-24-aiops-token-counter.md create mode 100644 docs/superpowers/specs/2026-07-24-aiops-token-counter-design.md 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 54ca5aec..177dda96 100644 --- a/apps/web-ui/app/api/threads/[threadId]/history/route.ts +++ b/apps/web-ui/app/api/threads/[threadId]/history/route.ts @@ -5,6 +5,7 @@ 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 { @@ -126,6 +127,10 @@ function convertPlainMessage(msg: { role: string; content: string; metadata?: Re 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: displayContent, parts }; } 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__/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/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__/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/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/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-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).