fix(agent-debug): stream execution traces with platform mocks and coverage

This commit is contained in:
Hyu
2026-09-05 15:34:29 +08:00
parent a4d36aa2db
commit 8f0a55a1f4
47 changed files with 1815 additions and 562 deletions
@@ -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;
}
+49
View File
@@ -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');
+23
View File
@@ -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',
+24
View File
@@ -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:
'再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します',
+20
View File
@@ -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: '请输入事件类型',
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(new URL('../../src/app/home/agents/components/debug-execution.ts', import.meta.url), 'utf8');
const module = { exports: {} };
new Function('exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText)(module.exports);
const { executionSteps } = module.exports;
const event = (type, data) => ({type, data});
test('separates streamed thinking and text, replaces final snapshot without duplication', () => {
assert.deepEqual(executionSteps([
event('message.delta', {chunk: {content:'<think>plan'}}),
event('message.delta', {chunk: {content:'</think>hello'}}),
event('message.completed', {message: {content:'<think>plan</think>hello'}}),
event('run.completed', {message: {content:'hello'}}),
]), [{kind:'message', text:'hello', reasoning:'plan'}]);
});
test('retains structured reasoning and tool parameters/results in order', () => {
const steps = executionSteps([
event('message.delta', {chunk: {provider_specific_fields:{reasoning_content:'plan'}}}),
event('message.completed', {message: {content:''}}),
event('tool.call.started', {tool_call_id:'1',tool_name:'exec', parameters:{command:'echo hi'}}),
event('tool.call.started', {tool_call_id:'2',tool_name:'exec', parameters:{command:'bad'}}),
event('tool.call.completed', {tool_call_id:'2',tool_name:'exec', error:'failed'}),
event('tool.call.completed', {tool_call_id:'1',tool_name:'exec', result:{stdout:'hi'}}),
event('run.failed', {}),
]);
assert.equal(steps[0].reasoning, 'plan');
assert.equal(steps[1].parameters.command, 'echo hi');
assert.deepEqual(steps[1].result, {stdout:'hi'});
assert.equal(steps[2].status, 'failed');
assert.equal(steps[2].error, 'failed');
});
test('replaces LocalAgent cumulative snapshots instead of repeating text', () => {
assert.deepEqual(executionSteps([
event('message.delta', {chunk: {content:'hello', msg_sequence:1}}),
event('message.delta', {chunk: {content:'hello world', msg_sequence:2}}),
event('message.delta', {chunk: {content:'hello world', msg_sequence:3, is_final:true}}),
]), [{kind:'message', text:'hello world', reasoning:''}]);
});
test('shows failed tool results even when the call transport completed', () => {
const steps = executionSteps([
event('tool.call.started', {tool_call_id:'exit7', tool_name:'exec', parameters:{command:'exit 7'}}),
event('tool.call.completed', {tool_call_id:'exit7', tool_name:'exec', result:{ok:false, exit_code:7, stderr:'expected'}}),
]);
assert.equal(steps[0].status,'failed');
assert.equal(steps[0].result.exit_code,7);
});
test('does not repeat prior thinking across LocalAgent tool turns', () => {
const prefix = '<think>first thought</think>';
const steps = executionSteps([
event('message.delta', {chunk:{content:prefix, msg_sequence:1}}),
event('tool.call.started', {tool_call_id:'w',tool_name:'write',parameters:{path:'/workspace/a'}}),
event('tool.call.completed', {tool_call_id:'w',tool_name:'write',result:{ok:true}}),
event('message.delta', {chunk:{content:prefix+'now read',msg_sequence:1}}),
event('tool.call.started', {tool_call_id:'r',tool_name:'read'}),
event('tool.call.completed', {tool_call_id:'r',tool_name:'read',result:{ok:true}}),
event('message.delta', {chunk:{content:prefix+'now read'+'done',msg_sequence:1}}),
event('message.completed', {message:{content:'done'}}),
]);
const messages = steps.filter(s=>s.kind==='message');
assert.deepEqual(messages, [
{kind:'message',text:'',reasoning:'first thought'},
{kind:'message',text:'now read',reasoning:''},
{kind:'message',text:'done',reasoning:''},
]);
});
+1 -1
View File
@@ -43,7 +43,7 @@ test('hides the entire workspace switcher slot for a singleton local workspace',
test('keeps bot cards at the same vertical spacing as knowledge-base cards', () => {
assert.match(
botFormSource,
/<fieldset className="space-y-6" disabled=\{isLoading\}>/,
/<fieldset\s+className="[^"]*\bspace-y-6\b[^"]*"\s+disabled=\{isLoading\}/,
);
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
});
@@ -73,13 +73,13 @@ test('processor forms expose their primary orchestration flow horizontally', ()
assert.match(
agentForm,
/name: 'basic'[\s\S]*name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
/name: 'runner'[\s\S]*name: 'runner_config'[\s\S]*name: 'events_and_tools'/,
);
assert.match(
pipelineForm,
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
);
assert.match(agentForm, /<TabsList[^>]*grid-cols-4/);
assert.match(agentForm, /<TabsList[^>]*grid-cols-3/);
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
assert.doesNotMatch(agentForm, /<ol className=/);
assert.doesNotMatch(pipelineForm, /<ol className=/);