feat: phase 3-5 — workflow engine, background subagents, multi-modal, cache shape, inline diff

Phase 3: Dynamic Workflow Engine
- VM sandbox (node:vm) with agent/parallel/pipeline API, Claude Code compatible
- Workflow file discovery (.boocode/workflows/*.js + ~/.boocode/workflows/*.js)
- Workflow manager with session/chat creation and inference dispatch
- Built-in catalog: deep-research, review-code, find-issues
- Resumability cache: SHA-256 hash of agent spec, in-memory Map

Phase 4: Background Subagents
- background-task.ts service: spawn/poll/cancel lifecycle
- spawn_subagent, subagent_status, subagent_result tools in ALL_TOOLS

Phase 5: Multi-modal + Cache Shape
- Multi-modal stub with type defs and hook point in payload.ts
- CacheShapeBadge component in trace viewer (colored bar + %)
This commit is contained in:
2026-06-08 03:11:39 +00:00
parent 74da084521
commit 45a1140fd3
23 changed files with 2938 additions and 33 deletions

View File

@@ -101,6 +101,7 @@ export interface Chat {
id: string;
session_id: string;
name: string | null;
model: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
@@ -131,6 +132,10 @@ export interface ToolResult {
output: unknown;
truncated: boolean;
error?: string;
// v2.8: unified diff snippet for write-tool results. Present when the tool
// modified files (edit_file, create_file, etc.) and the backend computed a
// diff. Rendered inline by DiffSnippet.
diff?: string;
}
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
@@ -172,6 +177,10 @@ export interface Message {
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null;
// v2.8-compare: compare group id. Set when the message is part of a
// multi-model compare response. All assistant messages in the same compare
// group share this id, keyed to the user message that triggered the compare.
compare_group_id?: string;
// v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active
@@ -513,8 +522,8 @@ export interface WorkspaceState {
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
| { type: 'delta'; message_id: string; chat_id?: string; content: string }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole; compare_group_id?: string }
| { type: 'delta'; message_id: string; chat_id?: string; content: string; compare_group_id?: string }
| { type: 'tool_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
| {
type: 'tool_result';
@@ -524,6 +533,7 @@ export type WsFrame =
output: unknown;
truncated: boolean;
error?: string;
diff?: string;
}
| {
type: 'message_complete';
@@ -547,6 +557,7 @@ export type WsFrame =
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the
// reducer renders a muted "Stopped" / failed state — no new frame type.
status?: 'complete' | 'cancelled' | 'failed';
compare_group_id?: string;
}
// v1.12.2: live throughput frame, published mid-stream every ~500ms with
// the latest token + ctx counts so ChatThroughput can render tok/s and
@@ -576,7 +587,7 @@ export type WsFrame =
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason }
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason; compare_group_id?: string }
// agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
// lifecycle status for external coding agents on the per-session channel. The
// CoderPane tracks the latest status per (chat_id, agent) and resets on chat
@@ -674,11 +685,13 @@ export type WsFrame =
message_id?: string;
chat_id?: string;
content?: string;
compare_group_id?: string;
tool_call?: ToolCall;
tool_message_id?: string;
tool_call_id?: string;
output?: unknown;
truncated?: boolean;
diff?: string;
error?: string;
reason?: string;
status?: 'running' | 'complete' | 'cancelled' | 'failed';

View File

@@ -0,0 +1,38 @@
// vDeepSeek: cache shape telemetry badge. Displays cache token count with
// a colored hit-rate bar in the trace viewer. Color thresholds are relative
// to output tokens (tokens_used) since the trace doesn't carry prompt miss
// tokens separately: green > 50%, yellow > 10%, red ≤ 10%.
export interface CacheShapeBadgeProps {
cacheTokens: number | null | undefined;
totalTokens: number | null | undefined;
}
function hitRate(cache: number, total: number): number {
if (cache <= 0 || total <= 0) return 0;
return cache / (cache + total);
}
function barColor(rate: number): string {
if (rate > 0.5) return 'bg-green-500';
if (rate > 0.1) return 'bg-yellow-500';
return 'bg-red-500';
}
export function CacheShapeBadge({ cacheTokens, totalTokens }: CacheShapeBadgeProps) {
if (cacheTokens == null || cacheTokens <= 0) return null;
const rate = hitRate(cacheTokens, totalTokens ?? 0);
const pct = Math.round(rate * 100);
const color = barColor(rate);
return (
<span className="shrink-0 inline-flex items-center gap-1 font-mono tabular-nums text-[10px] text-muted-foreground/60" title={`cache hit rate ${pct}%`}>
<span className={`inline-block w-1.5 h-3 rounded-sm ${color}`} />
<span>{cacheTokens}c</span>
{totalTokens != null && totalTokens > 0 && (
<span className="text-muted-foreground/40">{pct}%</span>
)}
</span>
);
}

View File

@@ -0,0 +1,88 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, FileCode } from 'lucide-react';
interface Props {
diff: string;
}
const INITIAL_LINES = 10;
export function DiffSnippet({ diff }: Props) {
const [expanded, setExpanded] = useState(false);
const lines = useMemo(() => diff.split('\n'), [diff]);
const totalLines = lines.length;
// Find the first and last content lines (skip leading ---/+++ headers)
const firstContentIdx = lines.findIndex(
(l) => l.startsWith('+') || l.startsWith('-') || l.startsWith(' '),
);
// Count content lines that are either +, -, or context lines
const contentLineCount = lines.filter(
(l) => l.startsWith('+') || l.startsWith('-') || l.startsWith(' '),
).length;
// Show first N content lines, plus header lines
const displayLines = useMemo(() => {
const sliceEnd = expanded ? lines.length : Math.min(firstContentIdx + INITIAL_LINES + contentLineCount, lines.length);
return lines.slice(0, sliceEnd);
}, [lines, expanded, firstContentIdx, contentLineCount]);
const hasMore = totalLines > displayLines.length;
if (totalLines === 0) return null;
return (
<div className="mt-1 rounded border border-border/40 bg-muted/20 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-left hover:bg-muted/30 text-[10px] font-mono text-muted-foreground"
>
<FileCode className="size-3 shrink-0" />
<span className="font-medium">diff</span>
<span className="text-muted-foreground/60">
{contentLineCount} line{contentLineCount === 1 ? '' : 's'} changed
</span>
<span className="ml-auto shrink-0">
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
</button>
<div className="px-0 pb-0.5">
{displayLines.map((line, i) => {
// Determine color class based on line prefix
let colorClass = 'text-muted-foreground/60';
if (line.startsWith('+')) colorClass = 'text-emerald-600 dark:text-emerald-400';
else if (line.startsWith('-')) colorClass = 'text-red-500 dark:text-red-400';
else if (line.startsWith('@@')) colorClass = 'text-muted-foreground';
else if (line.startsWith('---') || line.startsWith('+++')) colorClass = 'text-muted-foreground/50';
return (
<div
key={i}
className={`leading-[1.3] px-2 text-[10px] font-mono whitespace-pre ${colorClass} ${
line.startsWith('+')
? 'bg-emerald-500/5'
: line.startsWith('-')
? 'bg-red-500/5'
: ''
}`}
>
{line}
</div>
);
})}
{hasMore && !expanded && (
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full text-left px-2 py-0.5 text-[10px] font-mono text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/30"
>
Show {totalLines - displayLines.length} more lines
</button>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
import { Check, ChevronRight, Loader2, ShieldAlert, X } from 'lucide-react';
import type { ToolCall, ToolResult } from '@/api/types';
import { linkifyPaths } from '@/lib/linkify-paths';
import { DiffSnippet } from './DiffSnippet';
import { McpPermissionDialog } from './McpPermissionDialog';
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
// args + full result, so this is purely a single-line render budget.
@@ -105,14 +107,18 @@ interface Props {
// When rendered inside a ToolCallGroup the line is already nested under a
// shared header, so the leading arrow is dropped to avoid double indent.
insideGroup?: boolean;
chatId?: string;
}
export function ToolCallLine({ run, insideGroup }: Props) {
export function ToolCallLine({ run, insideGroup, chatId }: Props) {
const [open, setOpen] = useState(false);
const [approveOpen, setApproveOpen] = useState(false);
const status = runStatus(run);
const args = run.call.args ?? {};
const summary = formatToolArgs(run.call.name, args);
const needsApproval = run.result?.error?.startsWith('requires approval:') === true;
return (
<div className="text-xs">
<button
@@ -129,7 +135,7 @@ export function ToolCallLine({ run, insideGroup }: Props) {
/>
)}
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="font-mono text-foreground/90 shrink-0">{run.call.name}</span>
{summary && (
@@ -158,7 +164,27 @@ export function ToolCallLine({ run, insideGroup }: Props) {
{run.result && (
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
{run.result.error ? (
<span className="text-destructive">{run.result.error}</span>
needsApproval ? (
<span className="flex flex-col gap-2">
<span className="text-amber-600 dark:text-amber-400">
This tool requires your approval
</span>
{chatId && (
<span>
<button
type="button"
onClick={() => setApproveOpen(true)}
className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-500/20 dark:text-amber-400"
>
<ShieldAlert className="size-3" />
Approve
</button>
</span>
)}
</span>
) : (
<span className="text-destructive">{run.result.error}</span>
)
) : (
linkifyPaths(
typeof run.result.output === 'string'
@@ -171,6 +197,17 @@ export function ToolCallLine({ run, insideGroup }: Props) {
)}
</pre>
)}
{needsApproval && chatId && (
<McpPermissionDialog
toolCallId={run.call.id}
toolName={run.call.name}
toolArgs={run.call.args ?? {}}
chatId={chatId}
open={approveOpen}
onClose={() => setApproveOpen(false)}
/>
)}
{run.result?.diff && <DiffSnippet diff={run.result.diff} />}
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { api } from '@/api/client';
import type { ToolTrace } from '@/api/types';
import { CacheShapeBadge } from '@/components/CacheShapeBadge';
interface Props {
chatId: string;
@@ -58,11 +59,7 @@ function TraceRow({ trace }: { trace: ToolTrace }) {
{trace.tokens_used}t
</span>
)}
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
c{trace.cache_tokens}
</span>
)}
<CacheShapeBadge cacheTokens={trace.cache_tokens} totalTokens={trace.tokens_used} />
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
r{trace.reasoning_tokens}

View File

@@ -79,6 +79,7 @@ function channelDeltaToLegacyFrame(delta: ChannelDeltaWsFrame): WsFrame | null {
output: delta.output,
truncated: delta.truncated!,
...(delta.error ? { error: delta.error } : {}),
...(delta.diff ? { diff: delta.diff } : {}),
};
case 'error':
return {
@@ -172,6 +173,7 @@ function applyFrame(state: State, frame: WsFrame): State {
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
...(frame.compare_group_id ? { compare_group_id: frame.compare_group_id } : {}),
};
return { ...state, messages: [...state.messages, newMsg] };
}
@@ -195,21 +197,18 @@ function applyFrame(state: State, frame: WsFrame): State {
return { ...state, messages: next };
}
case 'tool_result': {
const toolResultsBase = {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
...(frame.diff ? { diff: frame.diff } : {}),
};
const exists = state.messages.some((m) => m.id === frame.tool_message_id);
if (exists) {
const next = state.messages.map((m) =>
m.id === frame.tool_message_id
? {
...m,
role: 'tool' as const,
tool_results: {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
status: 'complete' as const,
}
? { ...m, role: 'tool' as const, tool_results: toolResultsBase, status: 'complete' as const }
: m,
);
return { ...state, messages: next };
@@ -222,12 +221,7 @@ function applyFrame(state: State, frame: WsFrame): State {
content: '',
kind: 'message',
tool_calls: null,
tool_results: {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
tool_results: toolResultsBase,
status: 'complete',
last_seq: 0,
tokens_used: null,
@@ -258,6 +252,7 @@ function applyFrame(state: State, frame: WsFrame): State {
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
...(frame.model !== undefined ? { model: frame.model } : {}),
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
...(frame.compare_group_id !== undefined ? { compare_group_id: frame.compare_group_id } : {}),
}
: m,
);
@@ -301,6 +296,7 @@ function applyFrame(state: State, frame: WsFrame): State {
...m,
status: 'failed' as const,
...(errorMeta ? { metadata: errorMeta } : {}),
...(frame.compare_group_id !== undefined ? { compare_group_id: frame.compare_group_id } : {}),
}
: m,
)