Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/web-ui/app/api/agent-ops/settings/telegram/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 7 additions & 0 deletions apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
Expand Down Expand Up @@ -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 } });
});
});
26 changes: 25 additions & 1 deletion apps/web-ui/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/web-ui/app/api/chat/stream-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions apps/web-ui/app/api/threads/[threadId]/history/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { getChatHistory } from '@/lib/agent/persistence';
import { getSessionTenantId, getSessionUserId } from '@/lib/auth-session';
import { AIMessage, HumanMessage, ToolMessage, BaseMessage } from '@langchain/core/messages';
import { normalizeLegacyContent } from '@/lib/agent-chat/legacy-normalizer';
import { reconstructAiContentParts } from '@/lib/agent-chat/ai-content-parts';
import { parseUsageMetadata } from '@/lib/agent-chat/token-usage';
import { humanizePlanning, stripWorkingMemoryPrelude } from '@/app/api/chat/stream-parts';

interface HistoryMessage {
Expand Down Expand Up @@ -114,12 +116,23 @@ function convertPlainMessage(msg: { role: string; content: string; metadata?: Re
}
if (role === 'ai') {
const toolCalls = metadata?.tool_calls as Array<{ id?: string; name: string; args: Record<string, unknown> }> | undefined;
const parts: HistoryMessage['parts'] = content ? [...(buildPhaseParts(content) ?? [])] : [];
// Content-block arrays (extended-thinking) are reconstructed block-by-block; everything else keeps the marker path.
const reconstructed = reconstructAiContentParts(msg.content);
const parts: HistoryMessage['parts'] = reconstructed !== null
? [...reconstructed]
: (content ? [...(buildPhaseParts(content) ?? [])] : []);
const displayContent = reconstructed !== null
? reconstructed.filter((p) => p.type === 'text').map((p) => p.text).join('')
: content;
for (const tc of toolCalls ?? []) {
parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args, state: 'call' });
}
const usage = parseUsageMetadata(metadata?.usage_metadata);
if (usage && parts.length > 0) {
parts.push({ type: 'data-usage', data: { input: usage.input, output: usage.output } });
}
if (parts.length === 0) return null;
return { id: `history-${index}`, role: 'assistant', content, parts };
return { id: `history-${index}`, role: 'assistant', content: displayContent, parts };
}
if (role === 'tool') {
const toolCallId = metadata?.tool_call_id as string | undefined;
Expand All @@ -138,12 +151,18 @@ function convertMessage(msg: BaseMessage, index: number): HistoryMessage | null
}
if (msgType === 'ai') {
const aiMsg = msg as AIMessage;
const parts: HistoryMessage['parts'] = content ? [...(buildPhaseParts(content) ?? [])] : [];
const reconstructed = reconstructAiContentParts(aiMsg.content);
const parts: HistoryMessage['parts'] = reconstructed !== null
? [...reconstructed]
: (content ? [...(buildPhaseParts(content) ?? [])] : []);
const displayContent = reconstructed !== null
? reconstructed.filter((p) => p.type === 'text').map((p) => p.text).join('')
: (content || '');
for (const tc of aiMsg.tool_calls ?? []) {
parts.push({ type: 'tool-invocation', toolCallId: tc.id ?? `tool-${index}-${tc.name}`, toolName: tc.name, args: tc.args as Record<string, unknown>, 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
20 changes: 20 additions & 0 deletions apps/web-ui/components/agent/chat/__tests__/run-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
10 changes: 10 additions & 0 deletions apps/web-ui/components/agent/chat/run-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -92,6 +96,11 @@ export function deriveRunState(
}
break;
}
case 'data-usage': {
tokenIn += Number(part.data?.input) || 0;
tokenOut += Number(part.data?.output) || 0;
break;
}
}
}
}
Expand All @@ -112,6 +121,7 @@ export function deriveRunState(
pendingClarifications,
hasStructuredData,
hasApprovalData,
tokenUsage: { input: tokenIn, output: tokenOut },
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const EMPTY_RUN_STATE: RunState = {
pendingClarifications: [],
hasStructuredData: false,
hasApprovalData: false,
tokenUsage: { input: 0, output: 0 },
}

function baseProps(events: TranscriptEvent[]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ describe('Composer', () => {
expect(screen.queryByTestId('char-counter')).toBeNull()
})

// Thresholds live in composer.tsx: MAX_CHARS 6000, CHAR_WARNING_THRESHOLD 5400.
it('char counter is visible near the limit', () => {
render(<Composer {...baseProps({ value: 'a'.repeat(1900) })} />)
expect(screen.getByTestId('char-counter').textContent).toBe('1900/2000')
render(<Composer {...baseProps({ value: 'a'.repeat(5500) })} />)
expect(screen.getByTestId('char-counter').textContent).toBe('5500/6000')
})

it('shows Send while idle and calls onSubmit when clicked', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] {
Expand All @@ -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(
<TranscriptHeader
title="Untitled conversation"
runState={runState}
isStreaming={false}
elapsedMs={null}
onMenuAction={vi.fn()}
/>
)

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(
<TranscriptHeader
title="Untitled conversation"
runState={EMPTY_RUN_STATE}
isStreaming={false}
elapsedMs={null}
onMenuAction={vi.fn()}
/>
)

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,
Expand Down
4 changes: 2 additions & 2 deletions apps/web-ui/components/agent/workspace/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { AccountChip, KbSection, ModelChip, SkillChip, ToolsSection, type Compos

export type { ComposerContext } from "./composer-pickers";

const MAX_CHARS = 2000;
const CHAR_WARNING_THRESHOLD = 1800;
const MAX_CHARS = 6000;
const CHAR_WARNING_THRESHOLD = 5400;
const TEXTAREA_MAX_HEIGHT_PX = 192; // matches max-h-48

// Must stay in sync with the modes /api/chat accepts (fast | plan | deep) —
Expand Down
10 changes: 10 additions & 0 deletions apps/web-ui/components/agent/workspace/transcript-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -137,6 +138,15 @@ export function TranscriptHeader({ title, runState, isStreaming, elapsedMs, onMe
)}

<div className="ml-auto flex items-center gap-2">
{(runState.tokenUsage.input > 0 || runState.tokenUsage.output > 0) && (
<span
data-testid="token-usage"
className="font-mono text-xs text-muted-foreground"
title={`Incoming ${runState.tokenUsage.input.toLocaleString()} tokens · Outgoing ${runState.tokenUsage.output.toLocaleString()} tokens`}
>
↓ {formatTokens(runState.tokenUsage.input)} · ↑ {formatTokens(runState.tokenUsage.output)}
</span>
)}
{elapsedMs != null && (
<span data-testid="elapsed-timer" className="font-mono text-xs text-muted-foreground">
{formatElapsed(elapsedMs)}
Expand Down
Loading
Loading