Add execution graph to chat history (#776)
This commit is contained in:
committed by
GitHub
Unverified
parent
91c735c9f4
commit
c866205eac
@@ -4,12 +4,51 @@ import { getOpenClawConfigDir } from '../../utils/paths';
|
|||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody, sendJson } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
|
|
||||||
|
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||||
|
|
||||||
export async function handleSessionRoutes(
|
export async function handleSessionRoutes(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
url: URL,
|
url: URL,
|
||||||
_ctx: HostApiContext,
|
_ctx: HostApiContext,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (url.pathname === '/api/sessions/transcript' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const agentId = url.searchParams.get('agentId')?.trim() || '';
|
||||||
|
const sessionId = url.searchParams.get('sessionId')?.trim() || '';
|
||||||
|
if (!agentId || !sessionId) {
|
||||||
|
sendJson(res, 400, { success: false, error: 'agentId and sessionId are required' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) {
|
||||||
|
sendJson(res, 400, { success: false, error: 'Invalid transcript identifier' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
|
||||||
|
const fsP = await import('node:fs/promises');
|
||||||
|
const raw = await fsP.readFile(transcriptPath, 'utf8');
|
||||||
|
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||||
|
const messages = lines.flatMap((line) => {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as { type?: string; message?: unknown };
|
||||||
|
return entry.type === 'message' && entry.message ? [entry.message] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sendJson(res, 200, { success: true, messages });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
sendJson(res, 404, { success: false, error: 'Transcript not found' });
|
||||||
|
} else {
|
||||||
|
sendJson(res, 500, { success: false, error: 'Failed to load transcript' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
|
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ sessionKey: string }>(req);
|
const body = await parseJsonBody<{ sessionKey: string }>(req);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export function MainLayout() {
|
|||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
|
||||||
{/* Below the title bar: sidebar + content */}
|
{/* Below the title bar: sidebar + content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main data-testid="main-content" className="flex-1 overflow-auto p-6">
|
<main data-testid="main-content" className="min-h-0 flex-1 overflow-auto p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function Sidebar() {
|
|||||||
<aside
|
<aside
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex shrink-0 flex-col border-r bg-[#eae8e1]/60 dark:bg-background transition-all duration-300',
|
'flex min-h-0 shrink-0 flex-col overflow-hidden border-r bg-[#eae8e1]/60 dark:bg-background transition-all duration-300',
|
||||||
sidebarCollapsed ? 'w-16' : 'w-64'
|
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -279,7 +279,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* Session list — below Settings, only when expanded */}
|
{/* Session list — below Settings, only when expanded */}
|
||||||
{!sidebarCollapsed && sessions.length > 0 && (
|
{!sidebarCollapsed && sessions.length > 0 && (
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-2 mt-4 space-y-0.5 pb-2">
|
<div className="mt-4 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2 space-y-0.5">
|
||||||
{sessionBuckets.map((bucket) => (
|
{sessionBuckets.map((bucket) => (
|
||||||
bucket.sessions.length > 0 ? (
|
bucket.sessions.length > 0 ? (
|
||||||
<div key={bucket.key} className="pt-2">
|
<div key={bucket.key} className="pt-2">
|
||||||
|
|||||||
@@ -17,6 +17,37 @@
|
|||||||
"hideThinking": "Hide thinking",
|
"hideThinking": "Hide thinking",
|
||||||
"currentAgent": "Talking to {{agent}}"
|
"currentAgent": "Talking to {{agent}}"
|
||||||
},
|
},
|
||||||
|
"taskPanel": {
|
||||||
|
"eyebrow": "Run View",
|
||||||
|
"title": "Task Outline",
|
||||||
|
"emptyTitle": "No structured steps yet",
|
||||||
|
"emptyBody": "Once a run starts, ClawX will surface thinking, tool calls, and handoff states here.",
|
||||||
|
"status": {
|
||||||
|
"idle": "Idle",
|
||||||
|
"running_one": "1 active step",
|
||||||
|
"running_other": "{{count}} active steps"
|
||||||
|
},
|
||||||
|
"stepStatus": {
|
||||||
|
"running": "Running",
|
||||||
|
"completed": "Done",
|
||||||
|
"error": "Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executionGraph": {
|
||||||
|
"eyebrow": "Conversation Run",
|
||||||
|
"title": "Execution Graph",
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"latest": "Latest",
|
||||||
|
"previous": "Previous"
|
||||||
|
},
|
||||||
|
"branchLabel": "branch",
|
||||||
|
"userTrigger": "User Trigger",
|
||||||
|
"userTriggerHint": "Triggered by the user message above",
|
||||||
|
"agentRun": "{{agent}} execution",
|
||||||
|
"agentReply": "Assistant Reply",
|
||||||
|
"agentReplyHint": "Resolved in the assistant reply below"
|
||||||
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"attachFiles": "Attach files",
|
"attachFiles": "Attach files",
|
||||||
"pickAgent": "Choose agent",
|
"pickAgent": "Choose agent",
|
||||||
|
|||||||
@@ -17,6 +17,37 @@
|
|||||||
"hideThinking": "思考を非表示",
|
"hideThinking": "思考を非表示",
|
||||||
"currentAgent": "現在の会話相手: {{agent}}"
|
"currentAgent": "現在の会話相手: {{agent}}"
|
||||||
},
|
},
|
||||||
|
"taskPanel": {
|
||||||
|
"eyebrow": "実行ビュー",
|
||||||
|
"title": "タスクの流れ",
|
||||||
|
"emptyTitle": "まだ構造化されたステップはありません",
|
||||||
|
"emptyBody": "実行が始まると、ClawX は思考・ツール呼び出し・最終化の状態をここに表示します。",
|
||||||
|
"status": {
|
||||||
|
"idle": "待機中",
|
||||||
|
"running_one": "進行中 1 件",
|
||||||
|
"running_other": "進行中 {{count}} 件"
|
||||||
|
},
|
||||||
|
"stepStatus": {
|
||||||
|
"running": "実行中",
|
||||||
|
"completed": "完了",
|
||||||
|
"error": "エラー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executionGraph": {
|
||||||
|
"eyebrow": "会話実行",
|
||||||
|
"title": "実行グラフ",
|
||||||
|
"status": {
|
||||||
|
"active": "進行中",
|
||||||
|
"latest": "直近",
|
||||||
|
"previous": "履歴"
|
||||||
|
},
|
||||||
|
"branchLabel": "branch",
|
||||||
|
"userTrigger": "ユーザー入力",
|
||||||
|
"userTriggerHint": "上のユーザーメッセージがトリガーです",
|
||||||
|
"agentRun": "{{agent}} の実行",
|
||||||
|
"agentReply": "アシスタント返信",
|
||||||
|
"agentReplyHint": "結果は下のアシスタント返信に反映されます"
|
||||||
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"attachFiles": "ファイルを添付",
|
"attachFiles": "ファイルを添付",
|
||||||
"pickAgent": "Agent を選択",
|
"pickAgent": "Agent を選択",
|
||||||
|
|||||||
@@ -17,6 +17,37 @@
|
|||||||
"hideThinking": "隐藏思考过程",
|
"hideThinking": "隐藏思考过程",
|
||||||
"currentAgent": "当前对话对象:{{agent}}"
|
"currentAgent": "当前对话对象:{{agent}}"
|
||||||
},
|
},
|
||||||
|
"taskPanel": {
|
||||||
|
"eyebrow": "运行视图",
|
||||||
|
"title": "任务脉络",
|
||||||
|
"emptyTitle": "还没有结构化步骤",
|
||||||
|
"emptyBody": "当一次运行开始后,ClawX 会在这里展示思考、工具调用和收尾状态。",
|
||||||
|
"status": {
|
||||||
|
"idle": "空闲",
|
||||||
|
"running_one": "1 个活动步骤",
|
||||||
|
"running_other": "{{count}} 个活动步骤"
|
||||||
|
},
|
||||||
|
"stepStatus": {
|
||||||
|
"running": "进行中",
|
||||||
|
"completed": "完成",
|
||||||
|
"error": "错误"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executionGraph": {
|
||||||
|
"eyebrow": "对话执行",
|
||||||
|
"title": "执行关系图",
|
||||||
|
"status": {
|
||||||
|
"active": "执行中",
|
||||||
|
"latest": "最近一次",
|
||||||
|
"previous": "历史"
|
||||||
|
},
|
||||||
|
"branchLabel": "分支",
|
||||||
|
"userTrigger": "用户触发",
|
||||||
|
"userTriggerHint": "对应上方这条用户消息",
|
||||||
|
"agentRun": "{{agent}} 执行",
|
||||||
|
"agentReply": "助手回复",
|
||||||
|
"agentReplyHint": "结果体现在下方这条助手回复里"
|
||||||
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"attachFiles": "添加文件",
|
"attachFiles": "添加文件",
|
||||||
"pickAgent": "选择 Agent",
|
"pickAgent": "选择 Agent",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { extractText, extractThinking, extractImages, extractToolUse, formatTime
|
|||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: RawMessage;
|
message: RawMessage;
|
||||||
showThinking: boolean;
|
showThinking: boolean;
|
||||||
|
suppressToolCards?: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
streamingTools?: Array<{
|
streamingTools?: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -40,6 +41,7 @@ function imageSrc(img: ExtractedImage): string | null {
|
|||||||
export const ChatMessage = memo(function ChatMessage({
|
export const ChatMessage = memo(function ChatMessage({
|
||||||
message,
|
message,
|
||||||
showThinking,
|
showThinking,
|
||||||
|
suppressToolCards = false,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
streamingTools = [],
|
streamingTools = [],
|
||||||
}: ChatMessageProps) {
|
}: ChatMessageProps) {
|
||||||
@@ -52,7 +54,7 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
const images = extractImages(message);
|
const images = extractImages(message);
|
||||||
const tools = extractToolUse(message);
|
const tools = extractToolUse(message);
|
||||||
const visibleThinking = showThinking ? thinking : null;
|
const visibleThinking = showThinking ? thinking : null;
|
||||||
const visibleTools = tools;
|
const visibleTools = suppressToolCards ? [] : tools;
|
||||||
|
|
||||||
const attachedFiles = message._attachedFiles || [];
|
const attachedFiles = message._attachedFiles || [];
|
||||||
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
|
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
|
||||||
|
|||||||
187
src/pages/Chat/ExecutionGraphCard.tsx
Normal file
187
src/pages/Chat/ExecutionGraphCard.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ArrowDown, ArrowUp, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, GitBranch, Sparkles, Wrench, XCircle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TaskStep } from './task-visualization';
|
||||||
|
|
||||||
|
interface ExecutionGraphCardProps {
|
||||||
|
agentLabel: string;
|
||||||
|
sessionLabel: string;
|
||||||
|
steps: TaskStep[];
|
||||||
|
active: boolean;
|
||||||
|
onJumpToTrigger?: () => void;
|
||||||
|
onJumpToReply?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GraphStatusIcon({ status }: { status: TaskStep['status'] }) {
|
||||||
|
if (status === 'completed') return <CheckCircle2 className="h-4 w-4" />;
|
||||||
|
if (status === 'error') return <XCircle className="h-4 w-4" />;
|
||||||
|
return <CircleDashed className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepDetailCard({ step }: { step: TaskStep }) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hasDetail = !!step.detail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 flex-1 rounded-xl border border-black/10 bg-white/40 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('flex w-full items-start gap-2 text-left', hasDetail ? 'cursor-pointer' : 'cursor-default')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasDetail) return;
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{step.label}</p>
|
||||||
|
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
|
||||||
|
{t(`taskPanel.stepStatus.${step.status}`)}
|
||||||
|
</span>
|
||||||
|
{step.depth > 1 && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||||
|
{t('executionGraph.branchLabel')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.detail && !expanded && (
|
||||||
|
<p className="mt-1 text-[12px] leading-5 text-muted-foreground line-clamp-2">{step.detail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasDetail && (
|
||||||
|
<span className="mt-0.5 shrink-0 text-muted-foreground">
|
||||||
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{step.detail && expanded && (
|
||||||
|
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<pre className="whitespace-pre-wrap break-all text-[12px] leading-5 text-muted-foreground">
|
||||||
|
{step.detail}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionGraphCard({
|
||||||
|
agentLabel,
|
||||||
|
sessionLabel,
|
||||||
|
steps,
|
||||||
|
active,
|
||||||
|
onJumpToTrigger,
|
||||||
|
onJumpToReply,
|
||||||
|
}: ExecutionGraphCardProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="chat-execution-graph"
|
||||||
|
className="w-full rounded-2xl border border-black/10 bg-[#f5f1e8]/70 px-4 py-4 shadow-sm dark:border-white/10 dark:bg-white/[0.04]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground/70">
|
||||||
|
{t('executionGraph.eyebrow')}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-1 text-base font-semibold text-foreground">{t('executionGraph.title')}</h3>
|
||||||
|
<p className="mt-1 text-[12px] text-muted-foreground">
|
||||||
|
{agentLabel} · {sessionLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||||
|
active ? 'bg-primary/10 text-primary' : 'bg-black/5 text-foreground/70 dark:bg-white/10 dark:text-foreground/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? t('executionGraph.status.active') : t('executionGraph.status.previous')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chat-execution-jump-trigger"
|
||||||
|
onClick={onJumpToTrigger}
|
||||||
|
className="flex items-center gap-2 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
<span>{t('executionGraph.userTriggerHint')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pl-4">
|
||||||
|
<div className="ml-4 h-4 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex w-8 shrink-0 justify-center">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 rounded-xl border border-primary/15 bg-primary/5 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<GitBranch className="h-4 w-4 text-primary" />
|
||||||
|
<span>{t('executionGraph.agentRun', { agent: agentLabel })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.id}>
|
||||||
|
<div
|
||||||
|
className="pl-4"
|
||||||
|
style={{ marginLeft: `${Math.max(step.depth - 1, 0) * 24}px` }}
|
||||||
|
>
|
||||||
|
<div className="ml-4 h-4 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex gap-3"
|
||||||
|
data-testid="chat-execution-step"
|
||||||
|
style={{ marginLeft: `${Math.max(step.depth - 1, 0) * 24}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex w-8 shrink-0 justify-center">
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
{step.depth > 1 && (
|
||||||
|
<div className="absolute -left-4 top-1/2 h-px w-4 -translate-y-1/2 bg-border" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full',
|
||||||
|
step.status === 'running' && 'bg-primary/10 text-primary',
|
||||||
|
step.status === 'completed' && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
step.status === 'error' && 'bg-destructive/10 text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.kind === 'thinking' ? <Sparkles className="h-4 w-4" /> : step.kind === 'tool' ? <Wrench className="h-4 w-4" /> : <GraphStatusIcon status={step.status} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StepDetailCard step={step} />
|
||||||
|
</div>
|
||||||
|
{index === steps.length - 1 && (
|
||||||
|
<>
|
||||||
|
<div className="pl-4">
|
||||||
|
<div className="ml-4 h-4 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chat-execution-jump-reply"
|
||||||
|
onClick={onJumpToReply}
|
||||||
|
className="flex items-center gap-2 pl-11 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
|
<span>{t('executionGraph.agentReplyHint')}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,11 +9,14 @@ import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
|||||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import { useAgentsStore } from '@/stores/agents';
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { ChatMessage } from './ChatMessage';
|
import { ChatMessage } from './ChatMessage';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
|
import { ExecutionGraphCard } from './ExecutionGraphCard';
|
||||||
import { ChatToolbar } from './ChatToolbar';
|
import { ChatToolbar } from './ChatToolbar';
|
||||||
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
|
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
|
||||||
|
import { deriveTaskSteps, parseSubagentCompletionInfo } from './task-visualization';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
|
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
|
||||||
@@ -26,6 +29,8 @@ export function Chat() {
|
|||||||
|
|
||||||
const messages = useChatStore((s) => s.messages);
|
const messages = useChatStore((s) => s.messages);
|
||||||
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
|
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
|
||||||
|
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||||
|
const sessionLabels = useChatStore((s) => s.sessionLabels);
|
||||||
const loading = useChatStore((s) => s.loading);
|
const loading = useChatStore((s) => s.loading);
|
||||||
const sending = useChatStore((s) => s.sending);
|
const sending = useChatStore((s) => s.sending);
|
||||||
const error = useChatStore((s) => s.error);
|
const error = useChatStore((s) => s.error);
|
||||||
@@ -37,8 +42,10 @@ export function Chat() {
|
|||||||
const abortRun = useChatStore((s) => s.abortRun);
|
const abortRun = useChatStore((s) => s.abortRun);
|
||||||
const clearError = useChatStore((s) => s.clearError);
|
const clearError = useChatStore((s) => s.clearError);
|
||||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||||
|
const agents = useAgentsStore((s) => s.agents);
|
||||||
|
|
||||||
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
||||||
|
const [childTranscripts, setChildTranscripts] = useState<Record<string, RawMessage[]>>({});
|
||||||
|
|
||||||
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
|
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
|
||||||
const minLoading = useMinLoading(loading && messages.length > 0);
|
const minLoading = useMinLoading(loading && messages.length > 0);
|
||||||
@@ -61,6 +68,55 @@ export function Chat() {
|
|||||||
void fetchAgents();
|
void fetchAgents();
|
||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const completions = messages
|
||||||
|
.map((message) => parseSubagentCompletionInfo(message))
|
||||||
|
.filter((value): value is NonNullable<typeof value> => value != null);
|
||||||
|
const missing = completions.filter((completion) => !childTranscripts[completion.sessionId]);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void Promise.all(
|
||||||
|
missing.map(async (completion) => {
|
||||||
|
try {
|
||||||
|
const result = await hostApiFetch<{ success: boolean; messages?: RawMessage[] }>(
|
||||||
|
`/api/sessions/transcript?agentId=${encodeURIComponent(completion.agentId)}&sessionId=${encodeURIComponent(completion.sessionId)}`,
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn('Failed to load child transcript:', {
|
||||||
|
agentId: completion.agentId,
|
||||||
|
sessionId: completion.sessionId,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { sessionId: completion.sessionId, messages: result.messages || [] };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load child transcript:', {
|
||||||
|
agentId: completion.agentId,
|
||||||
|
sessionId: completion.sessionId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setChildTranscripts((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const result of results) {
|
||||||
|
if (!result) continue;
|
||||||
|
next[result.sessionId] = result.messages;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [messages, childTranscripts]);
|
||||||
|
|
||||||
// Update timestamp when sending starts
|
// Update timestamp when sending starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sending && streamingTimestamp === 0) {
|
if (sending && streamingTimestamp === 0) {
|
||||||
@@ -89,61 +145,182 @@ export function Chat() {
|
|||||||
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
|
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
|
||||||
|
|
||||||
const isEmpty = messages.length === 0 && !sending;
|
const isEmpty = messages.length === 0 && !sending;
|
||||||
|
const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message));
|
||||||
|
const nextUserMessageIndexes = new Array<number>(messages.length).fill(-1);
|
||||||
|
let nextUserMessageIndex = -1;
|
||||||
|
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
||||||
|
nextUserMessageIndexes[idx] = nextUserMessageIndex;
|
||||||
|
if (messages[idx].role === 'user' && !subagentCompletionInfos[idx]) {
|
||||||
|
nextUserMessageIndex = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRunCards = messages.flatMap((message, idx) => {
|
||||||
|
if (message.role !== 'user' || subagentCompletionInfos[idx]) return [];
|
||||||
|
|
||||||
|
const nextUserIndex = nextUserMessageIndexes[idx];
|
||||||
|
const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex;
|
||||||
|
const segmentMessages = messages.slice(idx + 1, segmentEnd);
|
||||||
|
const replyIndexOffset = segmentMessages.findIndex((candidate) => candidate.role === 'assistant');
|
||||||
|
const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset;
|
||||||
|
const completionInfos = subagentCompletionInfos
|
||||||
|
.slice(idx + 1, segmentEnd)
|
||||||
|
.filter((value): value is NonNullable<typeof value> => value != null);
|
||||||
|
const isLatestOpenRun = nextUserIndex === -1 && (sending || pendingFinal || hasAnyStreamContent);
|
||||||
|
let steps = deriveTaskSteps({
|
||||||
|
messages: segmentMessages,
|
||||||
|
streamingMessage: isLatestOpenRun ? streamingMessage : null,
|
||||||
|
streamingTools: isLatestOpenRun ? streamingTools : [],
|
||||||
|
sending: isLatestOpenRun ? sending : false,
|
||||||
|
pendingFinal: isLatestOpenRun ? pendingFinal : false,
|
||||||
|
showThinking,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const completion of completionInfos) {
|
||||||
|
const childMessages = childTranscripts[completion.sessionId];
|
||||||
|
if (!childMessages || childMessages.length === 0) continue;
|
||||||
|
const branchRootId = `subagent:${completion.sessionId}`;
|
||||||
|
const childSteps = deriveTaskSteps({
|
||||||
|
messages: childMessages,
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
sending: false,
|
||||||
|
pendingFinal: false,
|
||||||
|
showThinking,
|
||||||
|
}).map((step) => ({
|
||||||
|
...step,
|
||||||
|
id: `${completion.sessionId}:${step.id}`,
|
||||||
|
depth: step.depth + 1,
|
||||||
|
parentId: branchRootId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
...steps,
|
||||||
|
{
|
||||||
|
id: branchRootId,
|
||||||
|
label: `${completion.agentId} subagent`,
|
||||||
|
status: 'completed',
|
||||||
|
kind: 'system' as const,
|
||||||
|
detail: completion.sessionKey,
|
||||||
|
depth: 1,
|
||||||
|
parentId: 'agent-run',
|
||||||
|
},
|
||||||
|
...childSteps,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 0) return [];
|
||||||
|
|
||||||
|
const segmentAgentId = currentAgentId;
|
||||||
|
const segmentAgentLabel = agents.find((agent) => agent.id === segmentAgentId)?.name || segmentAgentId;
|
||||||
|
const segmentSessionLabel = sessionLabels[currentSessionKey] || currentSessionKey;
|
||||||
|
|
||||||
|
return [{
|
||||||
|
triggerIndex: idx,
|
||||||
|
replyIndex,
|
||||||
|
active: isLatestOpenRun,
|
||||||
|
agentLabel: segmentAgentLabel,
|
||||||
|
sessionLabel: segmentSessionLabel,
|
||||||
|
segmentEnd: replyIndex ?? (nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1),
|
||||||
|
steps,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative flex flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
|
<div className={cn("relative flex min-h-0 flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
||||||
<ChatToolbar />
|
<ChatToolbar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
<div className="min-h-0 flex-1 overflow-hidden px-4 py-4">
|
||||||
<div ref={contentRef} className="max-w-4xl mx-auto space-y-4">
|
<div className="mx-auto flex h-full min-h-0 max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
||||||
{isEmpty ? (
|
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||||
<WelcomeScreen />
|
<div ref={contentRef} className="max-w-4xl space-y-4">
|
||||||
) : (
|
{isEmpty ? (
|
||||||
<>
|
<WelcomeScreen />
|
||||||
{messages.map((msg, idx) => (
|
) : (
|
||||||
<ChatMessage
|
<>
|
||||||
key={msg.id || `msg-${idx}`}
|
{messages.map((msg, idx) => {
|
||||||
message={msg}
|
const suppressToolCards = userRunCards.some((card) =>
|
||||||
showThinking={showThinking}
|
idx > card.triggerIndex && idx <= card.segmentEnd,
|
||||||
/>
|
);
|
||||||
))}
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id || `msg-${idx}`}
|
||||||
|
className="space-y-3"
|
||||||
|
id={`chat-message-${idx}`}
|
||||||
|
data-testid={`chat-message-${idx}`}
|
||||||
|
>
|
||||||
|
<ChatMessage
|
||||||
|
message={msg}
|
||||||
|
showThinking={showThinking}
|
||||||
|
suppressToolCards={suppressToolCards}
|
||||||
|
/>
|
||||||
|
{userRunCards
|
||||||
|
.filter((card) => card.triggerIndex === idx)
|
||||||
|
.map((card) => (
|
||||||
|
<ExecutionGraphCard
|
||||||
|
key={`graph-${idx}`}
|
||||||
|
agentLabel={card.agentLabel}
|
||||||
|
sessionLabel={card.sessionLabel}
|
||||||
|
steps={card.steps}
|
||||||
|
active={card.active}
|
||||||
|
onJumpToTrigger={() => {
|
||||||
|
document.getElementById(`chat-message-${card.triggerIndex}`)?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onJumpToReply={() => {
|
||||||
|
if (card.replyIndex == null) return;
|
||||||
|
document.getElementById(`chat-message-${card.replyIndex}`)?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Streaming message */}
|
{/* Streaming message */}
|
||||||
{shouldRenderStreaming && (
|
{shouldRenderStreaming && (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
message={(streamMsg
|
message={(streamMsg
|
||||||
? {
|
? {
|
||||||
...(streamMsg as Record<string, unknown>),
|
...(streamMsg as Record<string, unknown>),
|
||||||
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
|
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
|
||||||
content: streamMsg.content ?? streamText,
|
content: streamMsg.content ?? streamText,
|
||||||
timestamp: streamMsg.timestamp ?? streamingTimestamp,
|
timestamp: streamMsg.timestamp ?? streamingTimestamp,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: streamText,
|
content: streamText,
|
||||||
timestamp: streamingTimestamp,
|
timestamp: streamingTimestamp,
|
||||||
}) as RawMessage}
|
}) as RawMessage}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
isStreaming
|
isStreaming
|
||||||
streamingTools={streamingTools}
|
streamingTools={streamingTools}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activity indicator: waiting for next AI turn after tool execution */}
|
{/* Activity indicator: waiting for next AI turn after tool execution */}
|
||||||
{sending && pendingFinal && !shouldRenderStreaming && (
|
{sending && pendingFinal && !shouldRenderStreaming && (
|
||||||
<ActivityIndicator phase="tool_processing" />
|
<ActivityIndicator phase="tool_processing" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Typing indicator when sending but no stream content yet */}
|
{/* Typing indicator when sending but no stream content yet */}
|
||||||
{sending && !pendingFinal && !hasAnyStreamContent && (
|
{sending && !pendingFinal && !hasAnyStreamContent && (
|
||||||
<TypingIndicator />
|
<TypingIndicator />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
278
src/pages/Chat/task-visualization.ts
Normal file
278
src/pages/Chat/task-visualization.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { extractThinking, extractToolUse } from './message-utils';
|
||||||
|
import type { RawMessage, ToolStatus } from '@/stores/chat';
|
||||||
|
|
||||||
|
export type TaskStepStatus = 'running' | 'completed' | 'error';
|
||||||
|
|
||||||
|
export interface TaskStep {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: TaskStepStatus;
|
||||||
|
kind: 'thinking' | 'tool' | 'system';
|
||||||
|
detail?: string;
|
||||||
|
depth: number;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TASK_STEPS = 8;
|
||||||
|
|
||||||
|
interface DeriveTaskStepsInput {
|
||||||
|
messages: RawMessage[];
|
||||||
|
streamingMessage: unknown | null;
|
||||||
|
streamingTools: ToolStatus[];
|
||||||
|
sending: boolean;
|
||||||
|
pendingFinal: boolean;
|
||||||
|
showThinking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentCompletionInfo {
|
||||||
|
sessionKey: string;
|
||||||
|
sessionId: string;
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(text: string | null | undefined): string | undefined {
|
||||||
|
if (!text) return undefined;
|
||||||
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToolId(prefix: string, name: string, index: number): string {
|
||||||
|
return `${prefix}:${name}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAgentIdFromSessionKey(sessionKey: string): string | null {
|
||||||
|
const parts = sessionKey.split(':');
|
||||||
|
if (parts.length < 2 || parts[0] !== 'agent') return null;
|
||||||
|
return parts[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubagentCompletionInfo(message: RawMessage): SubagentCompletionInfo | null {
|
||||||
|
const text = typeof message.content === 'string'
|
||||||
|
? message.content
|
||||||
|
: Array.isArray(message.content)
|
||||||
|
? message.content.map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')).join('\n')
|
||||||
|
: '';
|
||||||
|
if (!text.includes('[Internal task completion event]')) return null;
|
||||||
|
|
||||||
|
const sessionKeyMatch = text.match(/session_key:\s*(.+)/);
|
||||||
|
const sessionIdMatch = text.match(/session_id:\s*(.+)/);
|
||||||
|
const sessionKey = sessionKeyMatch?.[1]?.trim();
|
||||||
|
const sessionId = sessionIdMatch?.[1]?.trim();
|
||||||
|
if (!sessionKey || !sessionId) return null;
|
||||||
|
const agentId = parseAgentIdFromSessionKey(sessionKey);
|
||||||
|
if (!agentId) return null;
|
||||||
|
return { sessionKey, sessionId, agentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpawnLikeStep(label: string): boolean {
|
||||||
|
return /(spawn|subagent|delegate|parallel)/i.test(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJsonObject(detail: string | undefined): Record<string, unknown> | null {
|
||||||
|
if (!detail) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detail) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBranchAgent(step: TaskStep): string | null {
|
||||||
|
const parsed = tryParseJsonObject(step.detail);
|
||||||
|
const agentId = parsed?.agentId;
|
||||||
|
if (typeof agentId === 'string' && agentId.trim()) return agentId.trim();
|
||||||
|
|
||||||
|
const message = typeof parsed?.message === 'string' ? parsed.message : step.detail;
|
||||||
|
if (!message) return null;
|
||||||
|
const match = message.match(/\b(coder|reviewer|project-manager|manager|planner|researcher|worker|subagent)\b/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachTopology(steps: TaskStep[]): TaskStep[] {
|
||||||
|
const withTopology: TaskStep[] = [];
|
||||||
|
let activeBranchNodeId: string | null = null;
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.kind === 'system') {
|
||||||
|
activeBranchNodeId = null;
|
||||||
|
withTopology.push({ ...step, depth: 1, parentId: 'agent-run' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/sessions_spawn/i.test(step.label)) {
|
||||||
|
const branchAgent = extractBranchAgent(step) || 'subagent';
|
||||||
|
const branchNodeId = `${step.id}:branch`;
|
||||||
|
withTopology.push({ ...step, depth: 1, parentId: 'agent-run' });
|
||||||
|
withTopology.push({
|
||||||
|
id: branchNodeId,
|
||||||
|
label: `${branchAgent} run`,
|
||||||
|
status: step.status,
|
||||||
|
kind: 'system',
|
||||||
|
detail: `Spawned branch for ${branchAgent}`,
|
||||||
|
depth: 2,
|
||||||
|
parentId: step.id,
|
||||||
|
});
|
||||||
|
activeBranchNodeId = branchNodeId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/sessions_yield/i.test(step.label)) {
|
||||||
|
withTopology.push({
|
||||||
|
...step,
|
||||||
|
depth: activeBranchNodeId ? 3 : 1,
|
||||||
|
parentId: activeBranchNodeId ?? 'agent-run',
|
||||||
|
});
|
||||||
|
activeBranchNodeId = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.kind === 'thinking') {
|
||||||
|
withTopology.push({
|
||||||
|
...step,
|
||||||
|
depth: activeBranchNodeId ? 3 : 1,
|
||||||
|
parentId: activeBranchNodeId ?? 'agent-run',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSpawnLikeStep(step.label)) {
|
||||||
|
activeBranchNodeId = step.id;
|
||||||
|
withTopology.push({
|
||||||
|
...step,
|
||||||
|
depth: 1,
|
||||||
|
parentId: 'agent-run',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
withTopology.push({
|
||||||
|
...step,
|
||||||
|
depth: activeBranchNodeId ? 3 : 1,
|
||||||
|
parentId: activeBranchNodeId ?? 'agent-run',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return withTopology;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTaskSteps({
|
||||||
|
messages,
|
||||||
|
streamingMessage,
|
||||||
|
streamingTools,
|
||||||
|
sending,
|
||||||
|
pendingFinal,
|
||||||
|
showThinking,
|
||||||
|
}: DeriveTaskStepsInput): TaskStep[] {
|
||||||
|
const steps: TaskStep[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const activeToolNames = new Set<string>();
|
||||||
|
|
||||||
|
const pushStep = (step: TaskStep): void => {
|
||||||
|
if (seenIds.has(step.id)) return;
|
||||||
|
seenIds.add(step.id);
|
||||||
|
steps.push(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamMessage = streamingMessage && typeof streamingMessage === 'object'
|
||||||
|
? streamingMessage as RawMessage
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (streamMessage && showThinking) {
|
||||||
|
const thinking = extractThinking(streamMessage);
|
||||||
|
if (thinking) {
|
||||||
|
pushStep({
|
||||||
|
id: 'stream-thinking',
|
||||||
|
label: 'Thinking',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'thinking',
|
||||||
|
detail: normalizeText(thinking),
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamingTools.forEach((tool, index) => {
|
||||||
|
activeToolNames.add(tool.name);
|
||||||
|
pushStep({
|
||||||
|
id: tool.toolCallId || tool.id || makeToolId('stream-status', tool.name, index),
|
||||||
|
label: tool.name,
|
||||||
|
status: tool.status,
|
||||||
|
kind: 'tool',
|
||||||
|
detail: normalizeText(tool.summary),
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (streamMessage) {
|
||||||
|
extractToolUse(streamMessage).forEach((tool, index) => {
|
||||||
|
if (activeToolNames.has(tool.name)) return;
|
||||||
|
pushStep({
|
||||||
|
id: tool.id || makeToolId('stream-tool', tool.name, index),
|
||||||
|
label: tool.name,
|
||||||
|
status: 'running',
|
||||||
|
kind: 'tool',
|
||||||
|
detail: normalizeText(JSON.stringify(tool.input, null, 2)),
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sending && pendingFinal) {
|
||||||
|
pushStep({
|
||||||
|
id: 'system-finalizing',
|
||||||
|
label: 'Finalizing answer',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'system',
|
||||||
|
detail: 'Waiting for the assistant to finish this run.',
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
} else if (sending && steps.length === 0) {
|
||||||
|
pushStep({
|
||||||
|
id: 'system-preparing',
|
||||||
|
label: 'Preparing run',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'system',
|
||||||
|
detail: 'Waiting for the first streaming update.',
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 0) {
|
||||||
|
const relevantAssistantMessages = messages.filter((message) => {
|
||||||
|
if (!message || message.role !== 'assistant') return false;
|
||||||
|
if (extractToolUse(message).length > 0) return true;
|
||||||
|
return showThinking && !!extractThinking(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [messageIndex, assistantMessage] of relevantAssistantMessages.entries()) {
|
||||||
|
if (showThinking) {
|
||||||
|
const thinking = extractThinking(assistantMessage);
|
||||||
|
if (thinking) {
|
||||||
|
pushStep({
|
||||||
|
id: `history-thinking-${assistantMessage.id || messageIndex}`,
|
||||||
|
label: 'Thinking',
|
||||||
|
status: 'completed',
|
||||||
|
kind: 'thinking',
|
||||||
|
detail: normalizeText(thinking),
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractToolUse(assistantMessage).forEach((tool, index) => {
|
||||||
|
pushStep({
|
||||||
|
id: tool.id || makeToolId(`history-tool-${assistantMessage.id || messageIndex}`, tool.name, index),
|
||||||
|
label: tool.name,
|
||||||
|
status: 'completed',
|
||||||
|
kind: 'tool',
|
||||||
|
detail: normalizeText(JSON.stringify(tool.input, null, 2)),
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachTopology(steps).slice(0, MAX_TASK_STEPS);
|
||||||
|
}
|
||||||
221
tests/e2e/chat-task-visualizer.spec.ts
Normal file
221
tests/e2e/chat-task-visualizer.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
|
||||||
|
|
||||||
|
const PROJECT_MANAGER_SESSION_KEY = 'agent:main:main';
|
||||||
|
const CODER_SESSION_KEY = 'agent:coder:subagent:child-123';
|
||||||
|
const CODER_SESSION_ID = 'child-session-id';
|
||||||
|
|
||||||
|
function stableStringify(value: unknown): string {
|
||||||
|
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||||
|
return `{${entries.join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seededHistory = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '[Mon 2026-04-06 15:18 GMT+8] 分析 Velaria 当前未提交改动' }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{
|
||||||
|
type: 'toolCall',
|
||||||
|
id: 'spawn-call',
|
||||||
|
name: 'sessions_spawn',
|
||||||
|
arguments: { agentId: 'coder', task: 'analyze core blocks' },
|
||||||
|
}],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'toolResult',
|
||||||
|
toolCallId: 'spawn-call',
|
||||||
|
toolName: 'sessions_spawn',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
status: 'accepted',
|
||||||
|
childSessionKey: CODER_SESSION_KEY,
|
||||||
|
runId: 'child-run-id',
|
||||||
|
mode: 'run',
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
details: {
|
||||||
|
status: 'accepted',
|
||||||
|
childSessionKey: CODER_SESSION_KEY,
|
||||||
|
runId: 'child-run-id',
|
||||||
|
mode: 'run',
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{
|
||||||
|
type: 'toolCall',
|
||||||
|
id: 'yield-call',
|
||||||
|
name: 'sessions_yield',
|
||||||
|
arguments: { message: '我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。' },
|
||||||
|
}],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'toolResult',
|
||||||
|
toolCallId: 'yield-call',
|
||||||
|
toolName: 'sessions_yield',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
status: 'yielded',
|
||||||
|
message: '我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。',
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
details: {
|
||||||
|
status: 'yielded',
|
||||||
|
message: '我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。',
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `[Internal task completion event]
|
||||||
|
source: subagent
|
||||||
|
session_key: ${CODER_SESSION_KEY}
|
||||||
|
session_id: ${CODER_SESSION_ID}
|
||||||
|
type: subagent task
|
||||||
|
status: completed successfully`,
|
||||||
|
}],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: '我让 coder 分析完了,下面是结论。' }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const childTranscriptMessages = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '分析 ~/Velaria 当前未提交改动的核心内容' }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{
|
||||||
|
type: 'toolCall',
|
||||||
|
id: 'coder-exec-call',
|
||||||
|
name: 'exec',
|
||||||
|
arguments: {
|
||||||
|
command: "cd ~/Velaria && git status --short && sed -n '1,200p' src/dataflow/core/logical/planner/plan.h",
|
||||||
|
workdir: '/Users/bytedance/.openclaw/workspace-coder',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'toolResult',
|
||||||
|
toolCallId: 'coder-exec-call',
|
||||||
|
toolName: 'exec',
|
||||||
|
content: [{ type: 'text', text: 'M src/dataflow/core/logical/planner/plan.h' }],
|
||||||
|
details: {
|
||||||
|
status: 'completed',
|
||||||
|
aggregated: "M src/dataflow/core/logical/planner/plan.h\nM src/dataflow/core/execution/runtime/execution_optimizer.cc",
|
||||||
|
cwd: '/Users/bytedance/.openclaw/workspace-coder',
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: '已完成分析,最关键的有 4 块。' }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe('ClawX chat execution graph', () => {
|
||||||
|
test('renders internal yield status and linked subagent branch from mocked IPC', async ({ launchElectronApp }) => {
|
||||||
|
const app = await launchElectronApp({ skipSetup: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installIpcMocks(app, {
|
||||||
|
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
|
||||||
|
gatewayRpc: {
|
||||||
|
[stableStringify(['sessions.list', {}])]: {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessions: [{ key: PROJECT_MANAGER_SESSION_KEY, displayName: 'main' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200 }])]: {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
messages: seededHistory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000 }])]: {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
messages: seededHistory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hostApi: {
|
||||||
|
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: { state: 'running', port: 18789, pid: 12345 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[stableStringify(['/api/agents', 'GET'])]: {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
agents: [
|
||||||
|
{ id: 'main', name: 'main' },
|
||||||
|
{ id: 'coder', name: 'coder' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[stableStringify([`/api/sessions/transcript?agentId=coder&sessionId=${CODER_SESSION_ID}`, 'GET'])]: {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
messages: childTranscriptMessages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await getStableWindow(app);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('chat-execution-graph')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="chat-execution-graph"] [data-testid="chat-execution-step"]').getByText('sessions_yield', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText('coder subagent')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="chat-execution-graph"] [data-testid="chat-execution-step"]').getByText('exec', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="chat-execution-graph"]').getByText('我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。')).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await closeElectronApp(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,12 @@ type LaunchElectronOptions = {
|
|||||||
skipSetup?: boolean;
|
skipSetup?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type IpcMockConfig = {
|
||||||
|
gatewayStatus?: Record<string, unknown>;
|
||||||
|
gatewayRpc?: Record<string, unknown>;
|
||||||
|
hostApi?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
type ElectronFixtures = {
|
type ElectronFixtures = {
|
||||||
electronApp: ElectronApplication;
|
electronApp: ElectronApplication;
|
||||||
page: Page;
|
page: Page;
|
||||||
@@ -194,3 +200,57 @@ export async function completeSetup(page: Page): Promise<void> {
|
|||||||
export { closeElectronApp };
|
export { closeElectronApp };
|
||||||
export { getStableWindow };
|
export { getStableWindow };
|
||||||
export { expect };
|
export { expect };
|
||||||
|
|
||||||
|
export async function installIpcMocks(
|
||||||
|
app: ElectronApplication,
|
||||||
|
config: IpcMockConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
await app.evaluate(
|
||||||
|
async ({ app: _app }, mockConfig) => {
|
||||||
|
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||||
|
const stableStringify = (value: unknown): string => {
|
||||||
|
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||||
|
return `{${entries.join(',')}}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mockConfig.gatewayRpc) {
|
||||||
|
ipcMain.removeHandler('gateway:rpc');
|
||||||
|
ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, payload: unknown) => {
|
||||||
|
const key = stableStringify([method, payload ?? null]);
|
||||||
|
if (key in mockConfig.gatewayRpc!) {
|
||||||
|
return mockConfig.gatewayRpc![key];
|
||||||
|
}
|
||||||
|
const fallbackKey = stableStringify([method, null]);
|
||||||
|
if (fallbackKey in mockConfig.gatewayRpc!) {
|
||||||
|
return mockConfig.gatewayRpc![fallbackKey];
|
||||||
|
}
|
||||||
|
return { success: true, result: {} };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mockConfig.hostApi) {
|
||||||
|
ipcMain.removeHandler('hostapi:fetch');
|
||||||
|
ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string }) => {
|
||||||
|
const key = stableStringify([request?.path ?? '', request?.method ?? 'GET']);
|
||||||
|
if (key in mockConfig.hostApi!) {
|
||||||
|
return mockConfig.hostApi![key];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: { status: 200, ok: true, json: {} },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mockConfig.gatewayStatus) {
|
||||||
|
ipcMain.removeHandler('gateway:status');
|
||||||
|
ipcMain.handle('gateway:status', async () => mockConfig.gatewayStatus);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
156
tests/unit/task-visualization.test.ts
Normal file
156
tests/unit/task-visualization.test.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { deriveTaskSteps, parseSubagentCompletionInfo } from '@/pages/Chat/task-visualization';
|
||||||
|
import type { RawMessage, ToolStatus } from '@/stores/chat';
|
||||||
|
|
||||||
|
describe('deriveTaskSteps', () => {
|
||||||
|
it('builds running steps from streaming thinking and tool status', () => {
|
||||||
|
const streamingTools: ToolStatus[] = [
|
||||||
|
{
|
||||||
|
name: 'web_search',
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
summary: 'Searching docs',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const steps = deriveTaskSteps({
|
||||||
|
messages: [],
|
||||||
|
streamingMessage: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'thinking', thinking: 'Compare a few approaches before coding.' },
|
||||||
|
{ type: 'tool_use', id: 'tool-1', name: 'web_search', input: { query: 'openclaw task list' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
streamingTools,
|
||||||
|
sending: true,
|
||||||
|
pendingFinal: false,
|
||||||
|
showThinking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'stream-thinking',
|
||||||
|
label: 'Thinking',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'thinking',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
label: 'web_search',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'tool',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps recent completed steps from assistant history', () => {
|
||||||
|
const messages: RawMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
id: 'assistant-1',
|
||||||
|
content: [
|
||||||
|
{ type: 'thinking', thinking: 'Reviewing the code path.' },
|
||||||
|
{ type: 'tool_use', id: 'tool-2', name: 'read_file', input: { path: 'src/App.tsx' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const steps = deriveTaskSteps({
|
||||||
|
messages,
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
sending: false,
|
||||||
|
pendingFinal: false,
|
||||||
|
showThinking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'history-thinking-assistant-1',
|
||||||
|
label: 'Thinking',
|
||||||
|
status: 'completed',
|
||||||
|
kind: 'thinking',
|
||||||
|
depth: 1,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'tool-2',
|
||||||
|
label: 'read_file',
|
||||||
|
status: 'completed',
|
||||||
|
kind: 'tool',
|
||||||
|
depth: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a branch for spawned subagents', () => {
|
||||||
|
const messages: RawMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
id: 'assistant-2',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'spawn-1',
|
||||||
|
name: 'sessions_spawn',
|
||||||
|
input: { agentId: 'coder', task: 'inspect repo' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'yield-1',
|
||||||
|
name: 'sessions_yield',
|
||||||
|
input: { message: 'wait coder finishes' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const steps = deriveTaskSteps({
|
||||||
|
messages,
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
sending: false,
|
||||||
|
pendingFinal: false,
|
||||||
|
showThinking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'spawn-1',
|
||||||
|
label: 'sessions_spawn',
|
||||||
|
depth: 1,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'spawn-1:branch',
|
||||||
|
label: 'coder run',
|
||||||
|
depth: 2,
|
||||||
|
parentId: 'spawn-1',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'yield-1',
|
||||||
|
label: 'sessions_yield',
|
||||||
|
depth: 3,
|
||||||
|
parentId: 'spawn-1:branch',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses internal subagent completion events from injected user messages', () => {
|
||||||
|
const info = parseSubagentCompletionInfo({
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `[Internal task completion event]
|
||||||
|
source: subagent
|
||||||
|
session_key: agent:coder:subagent:child-123
|
||||||
|
session_id: child-session-id
|
||||||
|
status: completed successfully`,
|
||||||
|
}],
|
||||||
|
} as RawMessage);
|
||||||
|
|
||||||
|
expect(info).toEqual({
|
||||||
|
sessionKey: 'agent:coder:subagent:child-123',
|
||||||
|
sessionId: 'child-session-id',
|
||||||
|
agentId: 'coder',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user