mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +00:00
fix(agent-debug): stream execution traces with platform mocks and coverage
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
groupEventPatterns,
|
||||
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||
import AgentExecutionTrace from './AgentExecutionTrace';
|
||||
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
|
||||
|
||||
interface AgentDebugPanelProps {
|
||||
agentId: string;
|
||||
@@ -54,6 +56,8 @@ interface DebugEntry {
|
||||
text: string;
|
||||
errorCode?: string;
|
||||
detail?: string;
|
||||
events?: DebugExecutionEvent[];
|
||||
finished?: boolean;
|
||||
}
|
||||
|
||||
const EVENT_PRESET_DATA: Record<
|
||||
@@ -85,6 +89,7 @@ const EVENT_PRESET_DATA: Record<
|
||||
data: {
|
||||
requester_id: 'debug-user',
|
||||
requester_name: 'Debug User',
|
||||
request_id: 'debug-friend-request',
|
||||
message: 'Hello',
|
||||
},
|
||||
},
|
||||
@@ -95,6 +100,62 @@ const EVENT_PRESET_DATA: Record<
|
||||
content: 'Debug feedback',
|
||||
},
|
||||
},
|
||||
'friend.added': {
|
||||
text: 'A friend was added.',
|
||||
data: { user_id: 'debug-user', user_name: 'Debug User' },
|
||||
},
|
||||
'group.member_banned': {
|
||||
text: 'A member was banned.',
|
||||
data: {
|
||||
group_id: 'debug-group',
|
||||
member_id: 'debug-user',
|
||||
member_name: 'Debug User',
|
||||
},
|
||||
},
|
||||
'bot.invited_to_group': {
|
||||
text: 'The bot was invited to a group.',
|
||||
data: {
|
||||
group_id: 'debug-group',
|
||||
request_id: 'debug-group-request',
|
||||
requester_id: 'debug-user',
|
||||
},
|
||||
},
|
||||
'bot.muted': {
|
||||
text: 'The bot was muted.',
|
||||
data: { group_id: 'debug-group', duration: 60 },
|
||||
},
|
||||
'bot.unmuted': {
|
||||
text: 'The bot was unmuted.',
|
||||
data: { group_id: 'debug-group' },
|
||||
},
|
||||
'bot.removed_from_group': {
|
||||
text: 'The bot was removed from the group.',
|
||||
data: { group_id: 'debug-group' },
|
||||
},
|
||||
'message.edited': {
|
||||
text: 'A message was edited.',
|
||||
data: {
|
||||
group_id: 'debug-group',
|
||||
message_id: 'debug-message',
|
||||
text: 'Edited message',
|
||||
},
|
||||
},
|
||||
'message.deleted': {
|
||||
text: 'A message was deleted.',
|
||||
data: { group_id: 'debug-group', message_id: 'debug-message' },
|
||||
},
|
||||
'message.reaction': {
|
||||
text: 'A reaction was added.',
|
||||
data: {
|
||||
group_id: 'debug-group',
|
||||
message_id: 'debug-message',
|
||||
reaction: '👍',
|
||||
},
|
||||
},
|
||||
'platform.specific': {
|
||||
text: 'A platform-specific event occurred.',
|
||||
data: { event_name: 'debug-platform-event' },
|
||||
},
|
||||
};
|
||||
|
||||
function createDebugSessionId(agentId: string) {
|
||||
@@ -120,9 +181,21 @@ export default function AgentDebugPanel({
|
||||
const [customEventType, setCustomEventType] = useState('custom.event');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [eventDataText, setEventDataText] = useState('{}');
|
||||
const [mockOptionsText, setMockOptionsText] = useState('{}');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [entries, setEntries] = useState<DebugEntry[]>([]);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const sessionIdRef = useRef(createDebugSessionId(agentId));
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => requestRef.current?.abort(), [agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const transcript = transcriptRef.current;
|
||||
if (transcript) {
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
}
|
||||
}, [entries]);
|
||||
|
||||
const eventType = preset === 'custom' ? customEventType.trim() : preset;
|
||||
const isMessageEvent = eventType.startsWith('message.');
|
||||
@@ -186,6 +259,7 @@ export default function AgentDebugPanel({
|
||||
}
|
||||
|
||||
let eventData: Record<string, unknown>;
|
||||
let mockOptions: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(eventDataText || '{}');
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
@@ -196,6 +270,15 @@ export default function AgentDebugPanel({
|
||||
toast.error(t('agents.debugInvalidPayload'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(mockOptionsText || '{}');
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
||||
throw new Error('Mock options must be an object');
|
||||
mockOptions = parsed;
|
||||
} catch {
|
||||
toast.error(t('agents.debugInvalidMock'));
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
|
||||
@@ -204,6 +287,9 @@ export default function AgentDebugPanel({
|
||||
}
|
||||
|
||||
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
const outputId = `execution:${requestId}`;
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
{
|
||||
@@ -214,23 +300,85 @@ export default function AgentDebugPanel({
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const result = await httpClient.debugAgent(agentId, {
|
||||
event_type: eventType,
|
||||
text: inputText.trim(),
|
||||
data: eventData,
|
||||
conversation_id: sessionIdRef.current,
|
||||
});
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
const result = await httpClient.streamDebugAgent(
|
||||
agentId,
|
||||
{
|
||||
id: `output:${result.event_id}`,
|
||||
direction: 'output',
|
||||
eventType,
|
||||
text: result.final_text || t('agents.debugNoTextOutput'),
|
||||
event_type: eventType,
|
||||
text: inputText.trim(),
|
||||
data: eventData,
|
||||
mock: mockOptions,
|
||||
conversation_id: sessionIdRef.current,
|
||||
},
|
||||
]);
|
||||
if (isMessageEvent) setInputText('');
|
||||
(event) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setEntries((current) => {
|
||||
const existing = current.find((entry) => entry.id === outputId);
|
||||
if (existing)
|
||||
return current.map((entry) =>
|
||||
entry.id === outputId
|
||||
? { ...entry, events: [...(entry.events ?? []), event] }
|
||||
: entry,
|
||||
);
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: outputId,
|
||||
direction: 'output',
|
||||
eventType,
|
||||
text: '',
|
||||
events: [event],
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setEntries((current) =>
|
||||
current.some((entry) => entry.id === outputId)
|
||||
? current.map((entry) =>
|
||||
entry.id === outputId
|
||||
? {
|
||||
...entry,
|
||||
finished: true,
|
||||
text: executionSteps(entry.events ?? []).some(
|
||||
(step) =>
|
||||
step.kind === 'tool' || step.text || step.reasoning,
|
||||
)
|
||||
? ''
|
||||
: result.final_text || t('agents.debugNoTextOutput'),
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: [
|
||||
...current,
|
||||
{
|
||||
id: outputId,
|
||||
direction: 'output',
|
||||
eventType,
|
||||
text: result.final_text || t('agents.debugNoTextOutput'),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (isMessageEvent)
|
||||
setInputText((current) => (current === inputText ? '' : current));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
if (controller.signal.reason === 'user') {
|
||||
setEntries((current) => [
|
||||
...current.map((entry) =>
|
||||
entry.id === outputId ? { ...entry, finished: true } : entry,
|
||||
),
|
||||
{
|
||||
id: `cancel:${requestId}`,
|
||||
direction: 'error',
|
||||
eventType,
|
||||
text: t('agents.debugCancelled'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const errorCode =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? String((error as { code?: string }).code || '')
|
||||
@@ -255,7 +403,9 @@ export default function AgentDebugPanel({
|
||||
? t('agents.debugRunnerTimeoutDescription')
|
||||
: message || t('agents.debugRunFailed');
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
...current.map((entry) =>
|
||||
entry.id === outputId ? { ...entry, finished: true } : entry,
|
||||
),
|
||||
{
|
||||
id: `error:${requestId}`,
|
||||
direction: 'error',
|
||||
@@ -269,13 +419,19 @@ export default function AgentDebugPanel({
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null;
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<p className="shrink-0 border-b bg-muted/20 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('agents.debugPlatformNotice')}
|
||||
</p>
|
||||
<div ref={transcriptRef} className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -292,72 +448,90 @@ export default function AgentDebugPanel({
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry) => (
|
||||
<Alert
|
||||
key={entry.id}
|
||||
variant={
|
||||
entry.direction === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
className={
|
||||
entry.direction === 'output'
|
||||
? 'border-primary/20 bg-primary/5'
|
||||
: entry.direction === 'input'
|
||||
? 'bg-muted/40'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.direction === 'error' && <AlertCircle />}
|
||||
<div className="col-start-2 min-w-0">
|
||||
<div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="max-w-full overflow-hidden text-ellipsis"
|
||||
>
|
||||
{entry.eventType}
|
||||
</Badge>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{entry.direction === 'output'
|
||||
? t('agents.debugAgentOutput')
|
||||
: entry.direction === 'error'
|
||||
? t('common.error')
|
||||
: t('agents.debugTestInput')}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
|
||||
{entry.text}
|
||||
</pre>
|
||||
{entry.detail && (
|
||||
<Collapsible className="mt-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
{t('agents.debugErrorDetails')}
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
|
||||
{entry.detail}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
{(entry.errorCode?.endsWith('.config_invalid') ||
|
||||
entry.errorCode === 'runner_execution_failed' ||
|
||||
entry.errorCode === 'runner.timeout') &&
|
||||
onOpenRunnerConfig && (
|
||||
<Button
|
||||
type="button"
|
||||
{entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.direction !== 'output' ||
|
||||
entry.text ||
|
||||
executionSteps(entry.events ?? []).some(
|
||||
(step) =>
|
||||
step.kind === 'tool' || step.text || step.reasoning,
|
||||
),
|
||||
)
|
||||
.map((entry) => (
|
||||
<Alert
|
||||
key={entry.id}
|
||||
variant={
|
||||
entry.direction === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
className={
|
||||
entry.direction === 'output'
|
||||
? 'border-primary/20 bg-primary/5'
|
||||
: entry.direction === 'input'
|
||||
? 'bg-muted/40'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.direction === 'error' && <AlertCircle />}
|
||||
<div className="col-start-2 min-w-0">
|
||||
<div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={onOpenRunnerConfig}
|
||||
className="max-w-full overflow-hidden text-ellipsis"
|
||||
>
|
||||
{t('agents.debugReviewRunnerConfig')}
|
||||
</Button>
|
||||
{entry.eventType}
|
||||
</Badge>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{entry.direction === 'output'
|
||||
? t('agents.debugAgentOutput')
|
||||
: entry.direction === 'error'
|
||||
? t('common.error')
|
||||
: t('agents.debugTestInput')}
|
||||
</span>
|
||||
</div>
|
||||
{entry.events && (
|
||||
<AgentExecutionTrace
|
||||
events={entry.events}
|
||||
finished={entry.finished}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
{entry.text && (
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
|
||||
{entry.text}
|
||||
</pre>
|
||||
)}
|
||||
{entry.detail && (
|
||||
<Collapsible className="mt-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
{t('agents.debugErrorDetails')}
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
|
||||
{entry.detail}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
{(entry.errorCode?.endsWith('.config_invalid') ||
|
||||
entry.errorCode === 'runner_execution_failed' ||
|
||||
entry.errorCode === 'runner.timeout') &&
|
||||
onOpenRunnerConfig && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={onOpenRunnerConfig}
|
||||
>
|
||||
{t('agents.debugReviewRunnerConfig')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -469,11 +643,27 @@ export default function AgentDebugPanel({
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<summary className="cursor-pointer text-xs font-medium">
|
||||
{t('agents.debugMockOptions')}
|
||||
</summary>
|
||||
<p className="my-2 text-xs text-muted-foreground">
|
||||
{t('agents.debugMockOptionsHelp')}
|
||||
</p>
|
||||
<Textarea
|
||||
aria-label={t('agents.debugMockOptions')}
|
||||
value={mockOptionsText}
|
||||
onChange={(event) => setMockOptionsText(event.target.value)}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</details>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={running}
|
||||
onClick={runDebugEvent}
|
||||
onClick={() =>
|
||||
running ? requestRef.current?.abort('user') : runDebugEvent()
|
||||
}
|
||||
>
|
||||
{running ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
@@ -481,7 +671,7 @@ export default function AgentDebugPanel({
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
{running
|
||||
? t('agents.debugRunning')
|
||||
? t('agents.debugStop')
|
||||
: hasUnsavedChanges
|
||||
? t('agents.debugSaveAndRun')
|
||||
: t('agents.debugRun')}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, MessageSquare, Wrench } from 'lucide-react';
|
||||
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function isMockResult(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'mock' in value &&
|
||||
value.mock === true
|
||||
);
|
||||
}
|
||||
|
||||
const textClass =
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed';
|
||||
|
||||
export default function AgentExecutionTrace({
|
||||
events,
|
||||
finished = false,
|
||||
}: {
|
||||
events: DebugExecutionEvent[];
|
||||
finished?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const steps = useMemo(() => executionSteps(events), [events]);
|
||||
const toolCount = steps.filter((step) => step.kind === 'tool').length;
|
||||
const ended =
|
||||
finished ||
|
||||
events.some((event) =>
|
||||
['run.completed', 'run.failed'].includes(event.type),
|
||||
);
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{ended && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(toolCount ? 'agents.debugToolCount' : 'agents.debugNoToolCalls', {
|
||||
count: toolCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{steps.map((step, index) =>
|
||||
step.kind === 'message' ? (
|
||||
<div key={index} className="space-y-3">
|
||||
{step.reasoning && (
|
||||
<details open className="rounded-md border bg-muted/30 p-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
||||
<Brain className="mr-1.5 inline size-3.5" />
|
||||
{t('agents.debugReasoning')}
|
||||
</summary>
|
||||
<pre className={`${textClass} mt-2 text-muted-foreground`}>
|
||||
{step.reasoning}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{step.text && (
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-medium">
|
||||
<MessageSquare className="mr-1.5 inline size-3.5" />
|
||||
{t('agents.debugTextOutput')}
|
||||
</p>
|
||||
<pre className={textClass}>{step.text}</pre>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<section
|
||||
key={index}
|
||||
className="min-w-0 space-y-2 rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 break-all font-medium">
|
||||
<Wrench className="mr-1.5 inline size-3.5" />
|
||||
{step.name}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
step.status === 'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{isMockResult(step.result)
|
||||
? t(
|
||||
step.status === 'failed'
|
||||
? 'agents.debugToolMockFailed'
|
||||
: 'agents.debugToolSimulated',
|
||||
)
|
||||
: t(
|
||||
step.status === 'running'
|
||||
? ended
|
||||
? 'agents.debugToolInterrupted'
|
||||
: 'agents.debugToolRunning'
|
||||
: step.status === 'failed'
|
||||
? 'agents.debugToolFailed'
|
||||
: 'agents.debugToolCompleted',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{step.parameters !== undefined && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.debugToolArguments')}
|
||||
</p>
|
||||
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/40 p-2 font-mono text-xs">
|
||||
{formatValue(step.parameters)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{step.result !== undefined && step.result !== null && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.debugToolResult')}
|
||||
</p>
|
||||
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/40 p-2 font-mono text-xs">
|
||||
{formatValue(step.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{step.error && (
|
||||
<pre className={`${textClass} text-destructive`}>
|
||||
{step.error}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
export type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
|
||||
export type ExecutionStep =
|
||||
| { kind: 'message'; text: string; reasoning: string }
|
||||
| {
|
||||
kind: 'tool';
|
||||
id: string;
|
||||
name: string;
|
||||
parameters?: unknown;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
};
|
||||
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function splitMessage(
|
||||
content: string,
|
||||
reasoning: string,
|
||||
): ExecutionStep & { kind: 'message' } {
|
||||
const thoughts: string[] = [];
|
||||
const text = content.replace(
|
||||
/<think>([\s\S]*?)(?:<\/think>|$)/gi,
|
||||
(_, thought) => {
|
||||
thoughts.push(thought);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
return {
|
||||
kind: 'message',
|
||||
text: text.trim(),
|
||||
reasoning: (reasoning || thoughts.join('\n')).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve execution order while replacing streamed messages with their final snapshot. */
|
||||
export function executionSteps(events: DebugExecutionEvent[]): ExecutionStep[] {
|
||||
const steps: ExecutionStep[] = [];
|
||||
const tools = new Map<string, number>();
|
||||
let active: number | null = null;
|
||||
let content = '';
|
||||
let reasoning = '';
|
||||
let visiblePrefix = '';
|
||||
for (const event of events) {
|
||||
const data = event.data ?? {};
|
||||
if (
|
||||
event.type === 'tool.call.started' ||
|
||||
event.type === 'tool.call.completed'
|
||||
) {
|
||||
if (active !== null && content) visiblePrefix = content;
|
||||
active = null;
|
||||
content = reasoning = '';
|
||||
const id = String(
|
||||
data.tool_call_id ?? `${event.sequence}:${steps.length}`,
|
||||
);
|
||||
const index = tools.get(id);
|
||||
const old = index === undefined ? undefined : steps[index];
|
||||
const step: ExecutionStep = {
|
||||
kind: 'tool',
|
||||
id,
|
||||
name: String(data.tool_name ?? ''),
|
||||
...(event.type === 'tool.call.started'
|
||||
? { parameters: data.parameters }
|
||||
: {
|
||||
parameters: old?.kind === 'tool' ? old.parameters : undefined,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
}),
|
||||
status:
|
||||
event.type === 'tool.call.started'
|
||||
? 'running'
|
||||
: data.error ||
|
||||
data.result?.ok === false ||
|
||||
data.result?.isError === true
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
};
|
||||
if (index === undefined) {
|
||||
tools.set(id, steps.length);
|
||||
steps.push(step);
|
||||
} else steps[index] = step;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!['message.delta', 'message.completed', 'run.completed'].includes(
|
||||
event.type,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const message = data.chunk ?? data.message;
|
||||
if (!message || (message.role && message.role !== 'assistant')) continue;
|
||||
const nextContent = contentText(message.content);
|
||||
const fields = message.provider_specific_fields ?? {};
|
||||
const nextReasoning =
|
||||
contentText(fields.reasoning_content ?? message.reasoning_content) ||
|
||||
(Array.isArray(fields.thinking_blocks)
|
||||
? fields.thinking_blocks
|
||||
.map((block: { thinking?: string }) => block.thinking ?? '')
|
||||
.join('\n')
|
||||
: '');
|
||||
if (event.type === 'message.delta') {
|
||||
// LocalAgent batches cumulative snapshots with msg_sequence; raw deltas use zero.
|
||||
if (typeof message.all_content === 'string')
|
||||
content = message.all_content;
|
||||
else if (message.msg_sequence > 0) content = nextContent;
|
||||
else content += nextContent;
|
||||
if (Array.isArray(fields.thinking_blocks) || message.msg_sequence > 0) {
|
||||
reasoning = nextReasoning || reasoning;
|
||||
} else reasoning += nextReasoning;
|
||||
} else {
|
||||
content = nextContent;
|
||||
reasoning = nextReasoning || reasoning;
|
||||
}
|
||||
// LocalAgent includes previous model turns in cumulative chunks after tool calls.
|
||||
const visibleContent =
|
||||
event.type === 'message.delta' &&
|
||||
message.msg_sequence > 0 &&
|
||||
visiblePrefix &&
|
||||
content.startsWith(visiblePrefix)
|
||||
? content.slice(visiblePrefix.length)
|
||||
: content;
|
||||
const step = splitMessage(visibleContent, reasoning);
|
||||
const previous = steps.at(-1);
|
||||
if (
|
||||
event.type === 'run.completed' &&
|
||||
active === null &&
|
||||
previous?.kind === 'message' &&
|
||||
previous.text === step.text &&
|
||||
(!step.reasoning || previous.reasoning === step.reasoning)
|
||||
)
|
||||
continue;
|
||||
if (active === null) {
|
||||
active = steps.length;
|
||||
steps.push(step);
|
||||
} else steps[active] = step;
|
||||
if (event.type !== 'message.delta') {
|
||||
active = null;
|
||||
content = reasoning = '';
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<fieldset className="min-w-0" disabled={!canManage}>
|
||||
<KBForm
|
||||
key={`${id}-${formVersion}`}
|
||||
initKbId={id}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface DebugExecutionEvent {
|
||||
type: string;
|
||||
data: Record<string, any>;
|
||||
sequence?: number;
|
||||
timestamp?: number;
|
||||
run_id?: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
import {
|
||||
ApiRespProviderRequesters,
|
||||
ApiRespProviderRequester,
|
||||
@@ -288,6 +289,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
conversation_id?: string;
|
||||
actor?: Record<string, unknown>;
|
||||
subject?: Record<string, unknown>;
|
||||
mock?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{
|
||||
event_id: string;
|
||||
@@ -303,6 +305,53 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post(`/api/v1/agents/${uuid}/debug`, payload);
|
||||
}
|
||||
|
||||
public async streamDebugAgent(
|
||||
uuid: string,
|
||||
payload: Parameters<BackendClient['debugAgent']>[1],
|
||||
onResult: (event: DebugExecutionEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): ReturnType<BackendClient['debugAgent']> {
|
||||
let offset = 0;
|
||||
let result: Awaited<ReturnType<BackendClient['debugAgent']>> | undefined;
|
||||
let failure: { code: string; msg: string } | undefined;
|
||||
const consume = (text: string) => {
|
||||
let end: number;
|
||||
while ((end = text.indexOf('\n', offset)) !== -1) {
|
||||
const line = text.slice(offset, end).trim();
|
||||
offset = end + 1;
|
||||
if (!line) continue;
|
||||
const frame = JSON.parse(line);
|
||||
if (frame.kind === 'result') onResult(frame.data);
|
||||
else if (frame.kind === 'completed') result = frame.data;
|
||||
else if (frame.kind === 'error') failure = frame;
|
||||
}
|
||||
};
|
||||
const response = await this.instance.post<string>(
|
||||
`/api/v1/agents/${uuid}/debug/stream`,
|
||||
payload,
|
||||
{
|
||||
adapter: 'xhr',
|
||||
responseType: 'text',
|
||||
timeout: 0,
|
||||
signal,
|
||||
headers: { Accept: 'application/x-ndjson' },
|
||||
transformResponse: [(data) => data],
|
||||
onDownloadProgress: (progress) => {
|
||||
const xhr = progress.event?.target as XMLHttpRequest | undefined;
|
||||
if (xhr?.status === 200) consume(xhr.responseText);
|
||||
},
|
||||
},
|
||||
);
|
||||
consume(response.data);
|
||||
if (failure) throw failure;
|
||||
if (!result)
|
||||
throw {
|
||||
code: 'runner_protocol_error',
|
||||
msg: 'Debug stream ended before completion',
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
||||
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
||||
return this.get('/api/v1/pipelines/_/metadata');
|
||||
|
||||
@@ -875,6 +875,29 @@ const enUS = {
|
||||
debugEmptyTranscript:
|
||||
'Choose an event, enter test content, then select “Run test”. Results stay on this page.',
|
||||
debugAgentOutput: 'Agent output',
|
||||
debugReasoning: 'Thinking',
|
||||
debugTextOutput: 'Text output',
|
||||
debugPlatformNotice:
|
||||
'Platform tools use Mock: the Agent makes real tool calls, while platform actions are simulated without sending real messages. Other tools execute as configured.',
|
||||
debugToolSimulated: 'Simulated successfully · Mock',
|
||||
debugStop: 'Stop debugging',
|
||||
debugMockOptions: 'Mock scenario (JSON)',
|
||||
debugInvalidMock: 'Mock scenario must be a valid JSON object.',
|
||||
debugToolMockFailed: 'Simulated failure · Mock',
|
||||
debugMockOptionsHelp:
|
||||
'Defaults to success. Map tool names to failures in errors or query fixtures in results; list unsupported APIs in unsupported_apis. Example: {"errors":{"event_reply":"Simulated send failure"}}',
|
||||
debugCancelled:
|
||||
'Debugging stopped. Earlier execution records are retained.',
|
||||
debugNoToolCalls:
|
||||
'No tool calls recorded. Generated text does not mean a message was sent.',
|
||||
debugToolCount:
|
||||
'{{count}} tool calls recorded. See their execution status and results below.',
|
||||
debugToolRunning: 'Running',
|
||||
debugToolCompleted: 'Completed',
|
||||
debugToolFailed: 'Failed',
|
||||
debugToolInterrupted: 'No result returned',
|
||||
debugToolArguments: 'Arguments',
|
||||
debugToolResult: 'Result',
|
||||
debugTestInput: 'Test input',
|
||||
debugNoTextOutput: 'The run completed without textual output.',
|
||||
debugEventTypeRequired: 'Enter an event type',
|
||||
|
||||
@@ -716,6 +716,30 @@ const jaJP = {
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
debugReasoning: '思考内容',
|
||||
debugTextOutput: 'テキスト出力',
|
||||
debugPlatformNotice:
|
||||
'プラットフォームツールは Mock を使用します。Agent は実際にツールを呼び出し、返信・送信などは模擬実行されます。他のツールは設定どおりに実行されます。',
|
||||
debugToolSimulated: '模擬実行成功 · Mock',
|
||||
debugStop: 'デバッグを停止',
|
||||
debugMockOptions: 'Mock シナリオ(JSON)',
|
||||
debugInvalidMock:
|
||||
'Mock シナリオは有効な JSON オブジェクトで指定してください。',
|
||||
debugToolMockFailed: '模擬実行失敗 · Mock',
|
||||
debugMockOptionsHelp:
|
||||
'既定は成功です。errors にツール別エラー、results に結果、unsupported_apis に未対応 API を指定します。例:{"errors":{"event_reply":"送信失敗"}}',
|
||||
debugCancelled:
|
||||
'デバッグを停止しました。それまでの実行記録は保持されます。',
|
||||
debugNoToolCalls:
|
||||
'ツール呼び出しの記録はありません。テキストの生成は送信完了を意味しません。',
|
||||
debugToolCount:
|
||||
'{{count}} 件のツール呼び出しを記録しました。実行状態と結果は以下をご確認ください。',
|
||||
debugToolRunning: '実行中',
|
||||
debugToolCompleted: '完了',
|
||||
debugToolFailed: '失敗',
|
||||
debugToolInterrupted: '結果なし',
|
||||
debugToolArguments: '引数',
|
||||
debugToolResult: '実行結果',
|
||||
title: 'プロセッサー',
|
||||
description:
|
||||
'再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します',
|
||||
|
||||
@@ -833,6 +833,26 @@ const zhHans = {
|
||||
debugEmptyTranscript:
|
||||
'选择事件类型,填写测试内容,然后点击“运行测试”。结果只会显示在这里。',
|
||||
debugAgentOutput: 'Agent 输出',
|
||||
debugReasoning: '思考内容',
|
||||
debugTextOutput: '文本输出',
|
||||
debugPlatformNotice:
|
||||
'平台工具使用 Mock:Agent 真实调用工具,回复、发送等平台动作模拟执行,不发送真实消息。其他工具仍按实际配置执行。',
|
||||
debugToolSimulated: '模拟执行成功 · Mock',
|
||||
debugStop: '停止调试',
|
||||
debugMockOptions: 'Mock 场景(JSON)',
|
||||
debugInvalidMock: 'Mock 场景必须是有效的 JSON 对象。',
|
||||
debugToolMockFailed: '模拟执行失败 · Mock',
|
||||
debugMockOptionsHelp:
|
||||
'默认模拟成功。errors 按工具名设置失败原因,results 设置查询结果,unsupported_apis 设置不支持的接口。例如:{"errors":{"event_reply":"模拟发送失败"}}',
|
||||
debugCancelled: '已停止调试,保留停止前的执行记录。',
|
||||
debugNoToolCalls: '未记录到工具调用;生成文本不代表消息已发送。',
|
||||
debugToolCount: '已记录 {{count}} 次工具调用,执行状态和结果见下方。',
|
||||
debugToolRunning: '执行中',
|
||||
debugToolCompleted: '已完成',
|
||||
debugToolFailed: '执行失败',
|
||||
debugToolInterrupted: '未返回结果',
|
||||
debugToolArguments: '调用参数',
|
||||
debugToolResult: '执行结果',
|
||||
debugTestInput: '测试输入',
|
||||
debugNoTextOutput: '运行完成,但没有产生文本输出。',
|
||||
debugEventTypeRequired: '请输入事件类型',
|
||||
|
||||
Reference in New Issue
Block a user