Skip to content

Commit 76c0bad

Browse files
authored
botonic-react: infer a function to get ai agent event using authenticated api endpoint to webchat preview #BLT-2535 (#3261)
## Description Adds automatic rehydration of truncated AI Agent debug events in the webchat preview by fetching the full event payload through the authenticated `getMessageById` API. ## Context AI Agent debug events shown in the webchat preview can be truncated when the payload is too large to send over Pusher. In that case, the event is delivered with `truncated: true` and a `hubtype_message_id`, but the UI cannot display tools, guardrails, or other details without the full `event_data`. The webchat preview already exposes `previewUtils.getMessageById` as an authenticated endpoint to retrieve message details. This PR wires that endpoint into the AI Agent debug trace component so truncated events are automatically enriched when preview utilities are available. ## Approach taken / Explain the design 1. **`rehydrate-ai-agent-event.ts`** — Pure functions that: - Call `previewUtils.getMessageById(hubtypeMessageId, { includeDebugEvents: true })`. - Merge the returned `event_data` into the truncated props while preserving `action` and `messageId`. - Cache successful results and deduplicate in-flight requests per `hubtype_message_id`. 2. **`use-rehydrated-ai-agent-event.ts`** — React hook that: - Detects when rehydration is needed (`truncated`, `hubtype_message_id`, and `previewUtils` are all present). - Returns the cached/resolved event and an `isRehydrating` flag. - Triggers the fetch once per mount and handles errors gracefully. 3. **`ai-agent.tsx`** — Uses the hook to render the resolved event. While rehydrating, or when rehydration returns no `event_data`, the "No tools executed" label is suppressed to avoid misleading empty states. 4. **`PreviewUtils` typing** — `getMessageById` now accepts an optional `includeDebugEvents` flag via `GetMessageByIdOptions`. ## To document / Usage example Rehydration is automatic when the host application provides `previewUtils` with a `getMessageById` implementation that supports `includeDebugEvents`: ```typescript const previewUtils: PreviewUtils = { // ...other preview utils getMessageById: async (messageId, options) => { const response = await fetch( `/api/messages/${messageId}?include_debug_events=${options?.includeDebugEvents ?? false}` ) return response.json() }, } // Webchat receives truncated AI agent events from Pusher: // { truncated: true, hubtype_message_id: 'msg-123', tools_executed: [], ... } // The AiAgent debug component will fetch the full event_data and render tools/guardrails. ``` No changes are required in bot code — the `AiAgent` component reads `previewUtils` from `WebchatContext`. ## Testing The pull request... - [x] has unit tests - [ ] has integration tests - [ ] doesn't need tests because... Unit tests cover rehydration via `getMessageById`, cache/deduplication, and the UI behavior while rehydrating. System debug trace tests were migrated from `.jsx` to `.tsx` to type the new rehydration scenarios.
1 parent 1790c5c commit 76c0bad

15 files changed

Lines changed: 982 additions & 899 deletions

packages/botonic-plugin-flow-builder/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ All notable changes to Botonic will be documented in this file.
1010
Click to see more.
1111
</summary>
1212

13-
## [0.54.0] - 2026-mm-dd
13+
## [0.55.0] - 2026-mm-dd
1414

1515
### Added
1616

packages/botonic-react/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ All notable changes to Botonic will be documented in this file.
1414

1515
### Added
1616

17+
- Rehydrate truncated AI agent debug events via `getMessageById`.
18+
1719
### Changed
1820

1921
- [PR-3258](https://github.com/hubtype/botonic/pull/3258): Remove system debug trace for knowledge base event.

packages/botonic-react/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module.exports = {
44
roots: ['<rootDir>', 'src/', 'tests/'],
55
preset: '../../node_modules/@babel/preset-typescript',
6-
testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.(js|jsx)$',
6+
testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.(js|jsx|ts|tsx)$',
77
testPathIgnorePatterns: [
88
'lib',
99
'.*.d.ts',

packages/botonic-react/src/components/system-debug-trace/events/ai-agent/ai-agent.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventAction, type ToolExecution } from '@botonic/core'
22
import { useContext, useMemo } from 'react'
3+
34
import { WebchatContext } from '../../../../webchat/context'
45
import { useKnowledgeBaseInfo } from '../../hooks/use-knowledge-base-info'
56
import { AiSpecialistSvg } from '../../icons'
@@ -14,17 +15,25 @@ import { LABELS } from '../constants'
1415
import { ExecutedTools } from './executed-tools'
1516
import { parseTools } from './parse-tools'
1617
import type { AiAgentDebugEvent, ToolExecuted } from './types'
18+
import { useRehydratedAiAgentEvent } from './use-rehydrated-ai-agent-event'
1719

18-
export const AiAgent = ({
19-
tools_executed,
20-
input_guardrails_triggered,
21-
output_guardrails_triggered,
22-
exit,
23-
error,
24-
messageId,
25-
knowledge_base_chunks_with_sources,
26-
}: AiAgentDebugEvent) => {
20+
export const AiAgent = (props: AiAgentDebugEvent) => {
2721
const { previewUtils } = useContext(WebchatContext)
22+
const { resolvedEvent, isRehydrating } = useRehydratedAiAgentEvent(
23+
props,
24+
previewUtils
25+
)
26+
27+
const {
28+
tools_executed,
29+
input_guardrails_triggered,
30+
output_guardrails_triggered,
31+
exit,
32+
error,
33+
messageId,
34+
knowledge_base_chunks_with_sources,
35+
truncated,
36+
} = resolvedEvent
2837

2938
const { otherTools, allSourcesIds, allChunksIds, query } = useMemo(
3039
() => parseTools(tools_executed),
@@ -57,6 +66,9 @@ export const AiAgent = ({
5766
previewUtils?.onClickOpenToolResults?.(toolExecution)
5867
}
5968

69+
const showNoToolsExecuted =
70+
!isRehydrating && !tools_executed.length && !truncated
71+
6072
return (
6173
<>
6274
{query && (
@@ -79,7 +91,7 @@ export const AiAgent = ({
7991
onSeeToolDetails={handleSeeToolDetails}
8092
/>
8193

82-
{!tools_executed.length && (
94+
{showNoToolsExecuted && (
8395
<StyledDebugDetail>
8496
<StyledDebugLabel>{LABELS.NO_TOOLS_EXECUTED}</StyledDebugLabel>
8597
</StyledDebugDetail>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { PreviewUtils } from '../../../../index-types'
2+
import type { AiAgentDebugEvent } from './types'
3+
4+
const rehydratedEventsCache = new Map<string, AiAgentDebugEvent>()
5+
const rehydrationInFlight = new Map<string, Promise<AiAgentDebugEvent | null>>()
6+
7+
export const clearRehydrationCacheForTests = () => {
8+
rehydratedEventsCache.clear()
9+
rehydrationInFlight.clear()
10+
}
11+
12+
export const getCachedRehydratedEvent = (
13+
hubtypeMessageId: string
14+
): AiAgentDebugEvent | undefined => rehydratedEventsCache.get(hubtypeMessageId)
15+
16+
export const rehydrateTruncatedEvent = async (
17+
props: AiAgentDebugEvent,
18+
previewUtils: PreviewUtils,
19+
hubtypeMessageId: string
20+
): Promise<AiAgentDebugEvent | null> => {
21+
const message = await previewUtils.getMessageById(hubtypeMessageId, {
22+
includeDebugEvents: true,
23+
})
24+
25+
if (!message?.event_data) {
26+
return null
27+
}
28+
29+
const eventData = message.event_data as Partial<AiAgentDebugEvent>
30+
31+
return {
32+
...props,
33+
...eventData,
34+
action: props.action,
35+
messageId: props.messageId,
36+
truncated: false,
37+
}
38+
}
39+
40+
export const getOrStartRehydration = (
41+
props: AiAgentDebugEvent,
42+
previewUtils: PreviewUtils,
43+
hubtypeMessageId: string
44+
): Promise<AiAgentDebugEvent | null> => {
45+
const cached = rehydratedEventsCache.get(hubtypeMessageId)
46+
if (cached) {
47+
return Promise.resolve(cached)
48+
}
49+
50+
const inFlight = rehydrationInFlight.get(hubtypeMessageId)
51+
if (inFlight) {
52+
return inFlight
53+
}
54+
55+
const promise = rehydrateTruncatedEvent(props, previewUtils, hubtypeMessageId)
56+
.then(mergedEvent => {
57+
if (mergedEvent) {
58+
rehydratedEventsCache.set(hubtypeMessageId, mergedEvent)
59+
}
60+
rehydrationInFlight.delete(hubtypeMessageId)
61+
return mergedEvent
62+
})
63+
.catch(error => {
64+
rehydrationInFlight.delete(hubtypeMessageId)
65+
throw error
66+
})
67+
68+
rehydrationInFlight.set(hubtypeMessageId, promise)
69+
return promise
70+
}

packages/botonic-react/src/components/system-debug-trace/events/ai-agent/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ export interface AiAgentDebugEvent {
2020
error: boolean
2121
knowledge_base_chunks_with_sources?: ChunkIdsGroupedBySourceData[]
2222
messageId?: string
23+
truncated?: boolean
24+
hubtype_message_id?: string
2325
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useRef, useState } from 'react'
2+
3+
import type { PreviewUtils } from '../../../../index-types'
4+
import {
5+
getCachedRehydratedEvent,
6+
getOrStartRehydration,
7+
} from './rehydrate-ai-agent-event'
8+
import type { AiAgentDebugEvent } from './types'
9+
10+
interface UseRehydratedAiAgentEventResult {
11+
resolvedEvent: AiAgentDebugEvent
12+
isRehydrating: boolean
13+
}
14+
15+
export const useRehydratedAiAgentEvent = (
16+
props: AiAgentDebugEvent,
17+
previewUtils?: PreviewUtils
18+
): UseRehydratedAiAgentEventResult => {
19+
const hubtypeMessageId = props.hubtype_message_id
20+
const needsRehydration = Boolean(
21+
props.truncated && hubtypeMessageId && previewUtils
22+
)
23+
24+
const cachedEvent =
25+
hubtypeMessageId && needsRehydration
26+
? getCachedRehydratedEvent(hubtypeMessageId)
27+
: undefined
28+
29+
const [resolvedEvent, setResolvedEvent] = useState<AiAgentDebugEvent>(
30+
() => cachedEvent ?? props
31+
)
32+
const [isRehydrating, setIsRehydrating] = useState(
33+
() => needsRehydration && !cachedEvent
34+
)
35+
36+
const fetchStartedRef = useRef(false)
37+
38+
if (!needsRehydration) {
39+
return { resolvedEvent: props, isRehydrating: false }
40+
}
41+
42+
if (
43+
!cachedEvent &&
44+
!fetchStartedRef.current &&
45+
hubtypeMessageId &&
46+
previewUtils
47+
) {
48+
fetchStartedRef.current = true
49+
50+
getOrStartRehydration(props, previewUtils, hubtypeMessageId)
51+
.then(mergedEvent => {
52+
if (mergedEvent) {
53+
setResolvedEvent(mergedEvent)
54+
}
55+
setIsRehydrating(false)
56+
})
57+
.catch(error => {
58+
console.error('Error rehydrating truncated AI agent event:', error)
59+
setIsRehydrating(false)
60+
})
61+
}
62+
63+
return {
64+
resolvedEvent: cachedEvent ?? resolvedEvent,
65+
isRehydrating: cachedEvent ? false : isRehydrating,
66+
}
67+
}

packages/botonic-react/src/index-types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,17 @@ export interface MinimalHubtypeMessage {
8787
type: string
8888
action: string
8989
text: string
90+
event_data?: Record<string, unknown> | null
9091
}
9192

9293
export interface WebchatLocaleContents {
9394
inputPlaceholder: string
9495
}
9596

97+
export interface GetMessageByIdOptions {
98+
includeDebugEvents?: boolean
99+
}
100+
96101
export interface PreviewUtils {
97102
getChunkIdsGroupedBySource: (
98103
chunkIds: string[]
@@ -101,7 +106,10 @@ export interface PreviewUtils {
101106
chunkIdsGroupedBySource: ChunkIdsGroupedBySourceData[]
102107
) => void
103108
onClickOpenToolResults: (toolExecution: ToolExecution) => void
104-
getMessageById: (messageId: string) => Promise<MinimalHubtypeMessage>
109+
getMessageById: (
110+
messageId: string,
111+
options?: GetMessageByIdOptions
112+
) => Promise<MinimalHubtypeMessage>
105113
trackPreviewEventOpened: (eventProperties: Record<string, unknown>) => void
106114
}
107115

packages/botonic-react/tests/components/__snapshots__/system-debug-trace-events.test.jsx.snap renamed to packages/botonic-react/tests/components/__snapshots__/system-debug-trace-events.test.tsx.snap

File renamed without changes.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`WhatsappTemplate Component renders WhatsappTemplate with FLOW button 1`] = `
4+
<message
5+
buttons="{"type":"BUTTONS","buttons":[{"type":"BUTTON","sub_type":"FLOW","index":"0","parameters":[{"type":"ACTION","action":{"flow_token":"static-booking-token","flow_action_data":{"ticket_id":"TKT-001"}}}]}]}"
6+
language="en"
7+
name="booking_flow"
8+
type="whatsapptemplate"
9+
/>
10+
`;
11+
12+
exports[`WhatsappTemplate Component renders WhatsappTemplate with FLOW button without flow_action_data 1`] = `
13+
<message
14+
buttons="{"type":"BUTTONS","buttons":[{"type":"BUTTON","sub_type":"FLOW","index":"0","parameters":[{"type":"ACTION","action":{"flow_token":"static-data-token"}}]}]}"
15+
language="en"
16+
name="data_exchange_flow"
17+
type="whatsapptemplate"
18+
/>
19+
`;
20+
21+
exports[`WhatsappTemplate Component renders WhatsappTemplate with URL button 1`] = `
22+
<message
23+
buttons="{"type":"BUTTONS","buttons":[{"type":"BUTTON","sub_type":"URL","index":0,"parameters":[{"type":"TEXT","text":"order-123"}]}]}"
24+
language="en"
25+
name="order_details"
26+
type="whatsapptemplate"
27+
/>
28+
`;
29+
30+
exports[`WhatsappTemplate Component renders WhatsappTemplate with all components (header, body, buttons) 1`] = `
31+
<message
32+
body="{"type":"BODY","parameters":[{"type":"TEXT","parameter_name":"issue_description","text":"Unable to login to account"},{"type":"TEXT","parameter_name":"priority","text":"High"}]}"
33+
buttons="{"type":"BUTTONS","buttons":[{"type":"BUTTON","sub_type":"URL","index":0,"parameters":[{"type":"TEXT","text":"TKT-001"}]},{"type":"BUTTON","sub_type":"QUICK_REPLY","index":1,"parameters":[{"type":"PAYLOAD","payload":"talk_to_agent"}]}]}"
34+
header="{"type":"HEADER","parameters":[{"type":"TEXT","text":"Ticket #TKT-001"}]}"
35+
language="en"
36+
name="support_ticket"
37+
namespace="business_namespace"
38+
type="whatsapptemplate"
39+
/>
40+
`;
41+
42+
exports[`WhatsappTemplate Component renders WhatsappTemplate with body parameters 1`] = `
43+
<message
44+
body="{"type":"BODY","parameters":[{"type":"TEXT","parameter_name":"customer_name","text":"John Doe"},{"type":"TEXT","parameter_name":"tracking_number","text":"TRK-123456789"}]}"
45+
language="en"
46+
name="shipping_update"
47+
type="whatsapptemplate"
48+
/>
49+
`;
50+
51+
exports[`WhatsappTemplate Component renders WhatsappTemplate with image header 1`] = `
52+
<message
53+
header="{"type":"HEADER","parameters":[{"type":"IMAGE","image":{"link":"https://example.com/promo-image.jpg"}}]}"
54+
language="es"
55+
name="promotional_offer"
56+
type="whatsapptemplate"
57+
/>
58+
`;
59+
60+
exports[`WhatsappTemplate Component renders WhatsappTemplate with minimal props 1`] = `
61+
<message
62+
language="en"
63+
name="order_confirmation"
64+
type="whatsapptemplate"
65+
/>
66+
`;
67+
68+
exports[`WhatsappTemplate Component renders WhatsappTemplate with namespace 1`] = `
69+
<message
70+
language="en"
71+
name="order_confirmation"
72+
namespace="my_business_namespace"
73+
type="whatsapptemplate"
74+
/>
75+
`;
76+
77+
exports[`WhatsappTemplate Component renders WhatsappTemplate with quick reply buttons 1`] = `
78+
<message
79+
buttons="{"type":"BUTTONS","buttons":[{"type":"BUTTON","sub_type":"QUICK_REPLY","index":0,"parameters":[{"type":"PAYLOAD","payload":"feedback_positive"}]},{"type":"BUTTON","sub_type":"QUICK_REPLY","index":1,"parameters":[{"type":"PAYLOAD","payload":"feedback_negative"}]}]}"
80+
language="en"
81+
name="customer_feedback"
82+
type="whatsapptemplate"
83+
/>
84+
`;
85+
86+
exports[`WhatsappTemplate Component renders WhatsappTemplate with text header 1`] = `
87+
<message
88+
header="{"type":"HEADER","parameters":[{"type":"TEXT","text":"Order #12345"}]}"
89+
language="en"
90+
name="order_confirmation"
91+
type="whatsapptemplate"
92+
/>
93+
`;

0 commit comments

Comments
 (0)