diff --git a/console/web/.gitignore b/console/web/.gitignore index 50667e11e..2ac642368 100644 --- a/console/web/.gitignore +++ b/console/web/.gitignore @@ -6,3 +6,4 @@ storybook-static *.log .vite *.tsbuildinfo +dist-demo diff --git a/console/web/demo.html b/console/web/demo.html new file mode 100644 index 000000000..fd205ef58 --- /dev/null +++ b/console/web/demo.html @@ -0,0 +1,22 @@ + + + + + + + iii console β€” recorded session + + + +
+ + + diff --git a/console/web/package.json b/console/web/package.json index 84fe7af95..692f51d50 100644 --- a/console/web/package.json +++ b/console/web/package.json @@ -18,7 +18,8 @@ "test:e2e": "playwright test", "test:e2e:install": "playwright install chromium", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "build:demo": "vite build --config vite.demo.config.ts" }, "dependencies": { "@fontsource/chivo-mono": "^5.2.8", diff --git a/console/web/src/components/chat/FunctionTriggerGroup.tsx b/console/web/src/components/chat/FunctionTriggerGroup.tsx index 9a262a2bb..f74dc70a7 100644 --- a/console/web/src/components/chat/FunctionTriggerGroup.tsx +++ b/console/web/src/components/chat/FunctionTriggerGroup.tsx @@ -6,6 +6,7 @@ import { } from '@/components/function-trigger/FunctionTriggerCard' import type { FilesystemAccessAction } from '@/components/permissions/FilesystemAccessPrompt' import { StatusDot } from '@/components/ui/StatusDot' +import { formatCallDuration } from '@/lib/format-call-duration' import { cn } from '@/lib/utils' import type { FunctionTriggerMessage as FunctionTriggerMessageType } from '@/types/chat' @@ -117,7 +118,7 @@ function deriveStatus(messages: FunctionTriggerMessageType[]): GroupStatus { label: ( <> {total} functions for{' '} - {sum}ms + {formatCallDuration(sum)} ), } diff --git a/console/web/src/components/chat/Message.tsx b/console/web/src/components/chat/Message.tsx index 4784c6f9d..7051745ba 100644 --- a/console/web/src/components/chat/Message.tsx +++ b/console/web/src/components/chat/Message.tsx @@ -38,6 +38,8 @@ interface MessageProps { /** Copy payload for an assistant turn (prose + its function calls). Lazy so the string is built on click, not on every streaming re-render. */ copyText?: string | (() => string) + /** Render function-call cards already expanded (showcase surfaces). */ + defaultOpenCalls?: boolean } export function Message({ @@ -48,6 +50,7 @@ export function Message({ onManageFilesystemAccess, workingDir, copyText, + defaultOpenCalls, }: MessageProps) { switch (message.role) { case 'user': @@ -89,6 +92,7 @@ export function Message({ return ( Promise onManageFilesystemAccess?: () => void workingDir?: string | null + /** + * Render every function-call card (and group) already expanded. Off in the + * product, where a turn's calls collapse to one line each; on for showcase + * surfaces whose whole point is the result renderers. + */ + defaultOpenCalls?: boolean } type RenderItem = @@ -94,11 +104,13 @@ export function MessageList({ isThinking, thinkingDetail, density = 'route', + header, onResolveApproval, onAlwaysAllow, onResolveFilesystemAccess, onManageFilesystemAccess, workingDir, + defaultOpenCalls, }: MessageListProps) { const bottomRef = useRef(null) const containerRef = useRef(null) @@ -166,7 +178,7 @@ export function MessageList({ }) }, [messages]) - if (messages.length === 0) { + if (messages.length === 0 && !header) { return } @@ -175,12 +187,14 @@ export function MessageList({ return (
+ {header} {items.map((item) => { if (item.kind === 'fcall-group') { return ( {' '} - for {message.durationMs} - ms + for{' '} + + {formatCallDuration(message.durationMs)} + ) : null} diff --git a/console/web/src/demo/EmptyState.tsx b/console/web/src/demo/EmptyState.tsx new file mode 100644 index 000000000..e053aa09c --- /dev/null +++ b/console/web/src/demo/EmptyState.tsx @@ -0,0 +1,36 @@ +/** + * Demo-local copy of the chat empty state's hero. The product's `EmptyState` + * keeps its own words; the landing demo wants this greeting, rendered inside + * the transcript scroller (via `MessageList`'s `header` slot) so the replay + * scrolls it away instead of unmounting it. + */ + +import { Prompt } from '@/components/ui/Prompt' + +export function DemoEmptyState() { + return ( +
+
+ new session +
+

+ welcome to iii! πŸ‘‹ +

+
+

+ You're in a 1:1 reproduction of our agent-oriented workspace where you + can leverage the power of a full-featured agentic system: +

+
    +
  • Β· trigger functions via the function registry
  • +
  • Β· spawn subagents for parallel/async work
  • +
  • Β· register triggers for loop and graph control
  • +
  • + Β· query state, publish to queues, and do everything production + systems do +
  • +
+
+
+ ) +} diff --git a/console/web/src/demo/LandingDemo.tsx b/console/web/src/demo/LandingDemo.tsx new file mode 100644 index 000000000..9bb72d16f --- /dev/null +++ b/console/web/src/demo/LandingDemo.tsx @@ -0,0 +1,582 @@ +/** + * The landing-page demo surface: the console's real chat transcript and real + * traces surface, side by side, replaying a scripted turn. + * + * Everything below the chrome is the shipped product code β€” `MessageList` + * (and through it every per-worker result renderer), and on the traces side + * the page's own stack: the live `TimelineStrip` masthead, `TraceFilters`, + * the `timeline`/`waterfall` switcher over `TraceTimeline` / `WaterfallChart`, + * and the `WorkerBreakdown` footer: same components, same defaults (the + * hierarchical timeline, not the waterfall). Only three things are demo-local: + * the composer is a lookalike that types instead of a Lexical editor, the + * events come from `scenario.ts` instead of the engine, and the callout chips + * are new. + */ + +import { ArrowUp, Paperclip } from 'lucide-react' +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { ContextUsage } from '@/components/chat/ContextUsage' +import { MessageList } from '@/components/chat/MessageList' +import { ConversationSidebar } from '@/components/sidebar/ConversationSidebar' +import { StatusDot } from '@/components/ui/StatusDot' +import { cn } from '@/lib/utils' +import { TraceFilters } from '@/pages/TracesV2/components/TraceFilters' +import { TimelineStrip } from '@/pages/TracesV2/components/timeline/TimelineStrip' +import { TraceTimeline } from '@/pages/TracesV2/components/timeline/TraceTimeline' +import { + ViewSwitcher, + type ViewType, +} from '@/pages/TracesV2/components/ViewSwitcher' +import { WaterfallChart } from '@/pages/TracesV2/components/WaterfallChart' +import { WorkerBreakdown } from '@/pages/TracesV2/components/WorkerBreakdown' +import { useTraceFilters } from '@/pages/TracesV2/hooks/useTraceFilters' +import type { VisualizationSpan } from '@/pages/TracesV2/lib/traceTransform' +import { DemoEmptyState } from './EmptyState' +import { MODEL_ID, SESSION_ID, TRACE_ID } from './scenario' +import { usePlayer } from './usePlayer' + +const MODEL_LABEL = 'claude sonnet 5' +const CTA_TEXT = 'click here to try the harness for yourself' +const CTA_HREF = 'https://workers.iii.dev/workers/harness#quickstart' +const CONTEXT_WINDOW = 1_000_000 + +/** The sidebar's write actions are wired to nothing: this is a recording. */ +const noop = () => {} + +/** Back within this many px of the bottom counts as "following again". */ +const FOLLOW_SLACK_PX = 120 + +/** + * Keep a pane's scroll on the newest row while the turn plays, and stop the + * moment the reader takes the scrollbar. + * + * The demo has to pin: `MessageList` only follows when the viewport is + * already near the bottom, and its scroll-the-approval-into-view pass parks + * it mid-transcript for the rest of the turn, while the waterfall never + * follows at all. But pinning must lose to a person β€” scrolling up to reread + * a card should not be yanked back by the next token. + * + * Following is released on real input (wheel, touch) rather than on any + * scroll, because the programmatic scrolls are indistinguishable from a + * user's by position alone. It re-arms when a scroll lands back at the + * bottom, so scrolling down to catch up resumes the follow. + */ +function useTailFollow(wrapRef: RefObject) { + const followRef = useRef(true) + + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return + const release = () => { + followRef.current = false + } + const onScroll = (event: Event) => { + const el = event.target as HTMLElement | null + if (!el || typeof el.scrollHeight !== 'number') return + const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + if (fromBottom <= FOLLOW_SLACK_PX) followRef.current = true + } + wrap.addEventListener('wheel', release, { passive: true }) + wrap.addEventListener('touchmove', release, { passive: true }) + // Scroll does not bubble; capture catches whichever child is scrolling. + wrap.addEventListener('scroll', onScroll, { capture: true, passive: true }) + return () => { + wrap.removeEventListener('wheel', release) + wrap.removeEventListener('touchmove', release) + wrap.removeEventListener('scroll', onScroll, { capture: true }) + } + }, [wrapRef]) + + return useCallback(() => { + if (!followRef.current) return + const el = wrapRef.current?.querySelector('.overflow-y-auto') + if (!el) return + const id = requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight + }) + return () => cancelAnimationFrame(id) + }, [wrapRef]) +} + +export interface LandingDemoProps { + /** Pause the whole thing when the overlay is closed. */ + active?: boolean + /** + * Replay forever. Off by default: the turn ends holding a finished + * transcript, a full trace and three child sessions, and that is the state + * worth clicking around in. Restarting on top of someone reading it is not. + */ + loop?: boolean +} + +export function LandingDemo({ active = true, loop = false }: LandingDemoProps) { + const player = usePlayer(active, loop) + const [selectedSpanId, setSelectedSpanId] = useState(null) + // The page's default view, and the page's own filter state. The filters + // control a single recorded trace, so narrowing has nothing to narrow; + // they are here because the surface is not the surface without them. + const [view, setView] = useState('timeline') + const traceFilters = useTraceFilters() + const listWrapRef = useRef(null) + const traceWrapRef = useRef(null) + + const handleSpanClick = useCallback((span: VisualizationSpan) => { + setSelectedSpanId((prev) => (prev === span.span_id ? null : span.span_id)) + }, []) + + /* ~8s into the replay the composer types out an invitation to run the + harness yourself β€” the type-out is the attention cue, and the typed text + is a live link. Hidden whenever the player owns the composer (the loop + retyping the scenario prompt). Keyed on the run, so a replay types it out + again instead of revealing the finished line the moment the composer + frees up. */ + const started = player.phase !== 'idle' + const [ctaChars, setCtaChars] = useState(0) + // biome-ignore lint/correctness/useExhaustiveDependencies: runKey is the restart trigger, not a value read here. + useEffect(() => { + if (!started) return + setCtaChars(0) + let interval: ReturnType | undefined + const delay = setTimeout(() => { + interval = setInterval(() => { + setCtaChars((c) => { + if (c >= CTA_TEXT.length) { + clearInterval(interval) + return c + } + return c + 1 + }) + }, 55) + }, 8000) + return () => { + clearTimeout(delay) + clearInterval(interval) + } + }, [started, player.runKey]) + const cta = player.typed ? '' : CTA_TEXT.slice(0, ctaChars) + + const pinTranscript = useTailFollow(listWrapRef) + const pinTrace = useTailFollow(traceWrapRef) + + // biome-ignore lint/correctness/useExhaustiveDependencies: the transcript is the trigger, not a value read here. + useEffect(pinTranscript, [pinTranscript, player.messages, player.callout]) + // biome-ignore lint/correctness/useExhaustiveDependencies: the span count is the trigger, not a value read here. + useEffect(pinTrace, [pinTrace, player.spanCount, player.callout]) + + const working = player.phase === 'streaming' + /* The header follows whichever session the sidebar has selected. */ + const paneWorking = player.activeChild + ? player.activeChild.status === 'working' + : working + + return ( +
+ + + {/* + Sidebar, transcript, traces β€” the console's own three columns, once + there is room for them. Narrower than that only the chat survives: + the sidebar and the traces pane hide, and the transcript takes the + whole frame. + */} +
+
+ +
+ + {/* ── transcript ─────────────────────────────────────────────── */} +
+
+ +
+
+
+ + $ + + + {MODEL_LABEL} + + + Β· + + agent + {player.activeChild ? ( + <> + Β· + + subagent Β· depth 1 + + + ) : null} + + Β· + + + {player.activeChild ? player.activeChild.id : SESSION_ID} + +
+
+ + + +
+ + + {paneWorking ? 'working' : 'ready'} + +
+
+
+ +
+ } + isThinking={player.isThinking} + thinkingDetail={ + player.thinkingDetail ?? `dispatching ${MODEL_LABEL}` + } + defaultOpenCalls + onResolveApproval={player.resolveApproval} + /> +
+ + + +
+
+ +
+
+
+ + {/* ── traces ─────────────────────────────────────────────────── */} + +
+
+ ) +} + +/** + * The window frame. The real console is a full app with a sidebar and route + * tabs; the demo keeps just enough of it to read as the same product. + */ +function DemoChrome({ + working, + paused, + onTogglePause, + onReplay, +}: { + working: boolean + paused: boolean + onTogglePause: () => void + onReplay: () => void +}) { + const controlClass = + 'flex items-center gap-1.5 border border-rule px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-ink-faint transition-colors hover:text-ink' + return ( +
+ + iii console + + + Β· + + payments ledger + + + recorded session + + + + + {/* Embedded only: the host page listens for iii-demo-close and scrolls + the console away. Inverted so the way out is unmissable. */} + {window.self !== window.top && ( + + )} +
+ ) +} + +/** + * Centered in the window chrome: the one thing in the frame that leads out of + * the recording and into the reader's own terminal. A shine sweep and a slow + * 3D tilt carry the attention; the colors are the page's accent tokens, so it + * reads as part of whichever theme the host is in. Centered absolutely so it + * stays mid-bar regardless of what the controls on either side measure, and + * dropped below `md` where there is no room between them. + */ +function TryItCta() { + return ( + + + try it for yourself + + πŸ‘©β€πŸ’» + + + + ) +} + +/** Phone-width only: drops in from the top, over the transcript header. */ +function MobileNotice() { + const [shown, setShown] = useState(false) + useEffect(() => { + const t = setTimeout(() => setShown(true), 400) + return () => clearTimeout(t) + }, []) + return ( +
+ this is a simplified view. the iii console is best viewed on larger + screens. +
+ ) +} + +/** Composer lookalike: same frame as `Composer`, types instead of editing. + `cta` types out an invitation in the editor when the player isn't using + it; the typed text is a live link and the frame glows accent while it + has something to say. */ +function FakeComposer({ + typed, + streaming, + cta = '', +}: { + typed: string + streaming: boolean + cta?: string +}) { + return ( +
+
+
+ {typed.length > 0 ? ( + + {typed} + + + ) : cta ? ( + + {cta} + + + ) : ( + + {streaming ? 'streaming response…' : 'send a message…'} + + )} +
+
+ +
+
+ + agent + + + {MODEL_LABEL} + + + {MODEL_ID.split('::')[0]} + +
+
+ + + + + + +
+
+
+ ) +} + +/** + * The annotation strip. One at a time, replaced as the turn moves on. In + * flow rather than floating: both panes pin to their newest row, and a + * floating chip would sit exactly on top of it. + */ +function CalloutStrip({ + callout, +}: { + callout: { title: string; text: string } | null +}) { + if (!callout) return null + return ( +
+ {callout ? ( +
+
+ {callout.title} +
+

+ {callout.text} +

+
+ ) : null} +
+ ) +} diff --git a/console/web/src/demo/demo.css b/console/web/src/demo/demo.css new file mode 100644 index 000000000..545c93c66 --- /dev/null +++ b/console/web/src/demo/demo.css @@ -0,0 +1,64 @@ +/* Demo-only decoration. Kept out of the console's index.css so the product + stylesheet stays the product's. Colors come from the theme tokens; only + the motion lives here. */ + +/* Shine sweeping across the CTA pill, in the pill's own text color. */ +.demo-shine::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 30%, + currentcolor 50%, + transparent 70% + ); + background-size: 250% 100%; + background-repeat: no-repeat; + opacity: 0.35; + animation: demo-shine 3.2s ease-in-out infinite; + pointer-events: none; +} +@keyframes demo-shine { + 0%, + 20% { + background-position: 200% 0; + } + 60%, + 100% { + background-position: -100% 0; + } +} + +/* The pill as a physical chip: a lip and a cast shadow in its own text color, + tumbling slowly through a shallow perspective. Rotation lives on the inner + element so the wrapper keeps owning the centering transform. */ +.demo-3d { + perspective: 420px; +} +.demo-tilt { + box-shadow: + 0 2px 0 color-mix(in srgb, currentcolor 40%, transparent), + 0 6px 14px color-mix(in srgb, currentcolor 22%, transparent); + animation: demo-tilt 7s ease-in-out infinite; +} +@keyframes demo-tilt { + 0%, + 100% { + transform: rotateX(7deg) rotateZ(-1.4deg); + } + 50% { + transform: rotateX(-7deg) rotateZ(1.4deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .demo-shine::after { + animation: none; + opacity: 0; + } + .demo-tilt { + animation: none; + transform: none; + } +} diff --git a/console/web/src/demo/main.tsx b/console/web/src/demo/main.tsx new file mode 100644 index 000000000..c485bf431 --- /dev/null +++ b/console/web/src/demo/main.tsx @@ -0,0 +1,150 @@ +/** + * Entry for the landing-page demo build (`npm run build:demo`). + * + * Standalone page, not part of the console SPA: no engine client, no + * injectable-UI loader, no router. It renders one component over a scripted + * turn and is embedded by the marketing site in an iframe, which is what + * keeps the console's Tailwind reset from touching the host page. + * + * URL params: + * ?theme=dark|light follow the host page's theme (default light) + * ?loop=1 replay forever instead of holding the finished turn + * ?paused=1 mount paused; the host posts `{type:'iii-demo', + * active:true}` when the frame is actually on screen + */ + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { TooltipProvider } from '@/components/ui/Tooltip' +import { LandingDemo } from './LandingDemo' +import { setScenarioSpeed } from './scenario' +import '../index.css' +import './demo.css' + +const params = new URLSearchParams(window.location.search) +document.documentElement.dataset.theme = + params.get('theme') === 'dark' ? 'dark' : 'light' + +/* Reduced motion: fill the turn in at once and hold it, rather than + animating a minute of typing at someone who asked for less of that. */ +if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + setScenarioSpeed(0.02) +} + +/** + * Keep every scroll inside this document. + * + * `scrollIntoView` walks the ancestor scroll chain past the iframe and into + * the EMBEDDER's viewport, so the transcript's auto-follow (`MessageList` + * pins the newest row, and pulls a fresh approval into view) drags the host + * page around while the session plays inline on it. The replacement does the + * same alignment in the nearest scrollport and stops there; it is the whole + * behavior the demo needs, and the product code stays untouched. + */ +Element.prototype.scrollIntoView = function scrollWithinFrame( + this: Element, + arg?: boolean | ScrollIntoViewOptions, +) { + const opts: ScrollIntoViewOptions = + typeof arg === 'object' ? arg : { block: arg === false ? 'end' : 'start' } + let port: HTMLElement | null = null + for (let p = this.parentElement; p; p = p.parentElement) { + const overflowY = getComputedStyle(p).overflowY + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + p.scrollHeight > p.clientHeight + ) { + port = p + break + } + } + if (!port) return + const offset = + this.getBoundingClientRect().top - + port.getBoundingClientRect().top + + port.scrollTop + const height = this.getBoundingClientRect().height + const top = + opts.block === 'center' + ? offset - (port.clientHeight - height) / 2 + : opts.block === 'end' || opts.block === 'nearest' + ? offset - port.clientHeight + height + : offset + port.scrollTo({ top, behavior: opts.behavior ?? 'auto' }) +} + +/** + * Inner scrollports keep the wheel they catch: when the transcript or the + * trace list bottoms out, the scroll ends there. Surface with nothing of its + * own to scroll still chains out to the embedding page (html and body keep + * the default), and that chain is what un-pins the marketing site's + * scroll-zoom. Below the lg breakpoint the whole demo is one scroller, so + * containing it would trap the reader; the rule only covers the two-pane + * layout. + */ +const contain = document.createElement('style') +contain.textContent = + '@media (min-width: 1024px) { body * { overscroll-behavior: contain; } }' +document.head.appendChild(contain) + +const root = document.getElementById('root') +if (!root) throw new Error('missing #root container') + +/** + * The host page pauses the demo by posting `{ type: 'iii-demo', active }` + * when the overlay opens and closes, so a hidden iframe is not burning a + * timer loop. Without a host it just plays. + */ +function mount() { + const reactRoot = createRoot(root as HTMLElement) + let active = params.get('paused') !== '1' + /* Bumped to replay: a fresh key remounts the player from the top. */ + let runKey = 0 + + const render = () => + reactRoot.render( + + + + + , + ) + + window.addEventListener('message', (event: MessageEvent) => { + const data = event.data + if (!data || typeof data !== 'object') return + if (data.type === 'iii-demo-theme') { + /* The host's theme button flipped while the overlay is open. */ + document.documentElement.dataset.theme = + data.theme === 'dark' ? 'dark' : 'light' + return + } + if (data.type === 'iii-demo-replay') { + runKey += 1 + render() + return + } + if (data.type !== 'iii-demo') return + const next = !!data.active + if (next === active) return + active = next + render() + }) + + /* Once the viewer clicks inside the frame (the approval buttons are real), + keystrokes land here, not on the host page β€” forward the one the host + cares about so Escape keeps closing the overlay. */ + window.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + window.parent?.postMessage({ type: 'iii-demo-close' }, '*') + } + }) + + render() +} + +mount() diff --git a/console/web/src/demo/scenario.ts b/console/web/src/demo/scenario.ts new file mode 100644 index 000000000..1d7b08ad9 --- /dev/null +++ b/console/web/src/demo/scenario.ts @@ -0,0 +1,1377 @@ +/** + * The canned turn the landing-page demo replays. + * + * The chat half of the stream is the real `ChatBackend.stream()` contract + * (`@/lib/backend/types`) β€” the same `StreamEvent`s the live harness backend + * emits, in the same order, consumed by the same reducer the console's + * `ChatView` uses. Three demo-only markers ride alongside it so the trace + * pane and the callout layer stay in lockstep with the transcript without a + * second timeline to keep in sync: + * + * demo-span-open / demo-span-close the waterfall's spans + * demo-callout the annotation chip + * + * Span shapes mirror what a real turn produces: one `harness::turn step` + * root per durable loop step (`workers/harness/src/functions/turn.rs`), + * `execute router::chat` β†’ `execute provider::anthropic::stream` for the + * generation, and `execute ` for each dispatched call. + */ + +import type { StreamEvent } from '@/lib/backend' +import { sleep, tokenize } from '@/stories/playground/scenarios/helpers' + +export const PROMPT = 'build a payments ledger service with a durable db' + +export const MODEL_ID = 'anthropic::claude-sonnet-5' +export const SESSION_ID = 'console-payments-ledger-demo' +export const TRACE_ID = 'trace-payments-ledger-0000000001' + +/** + * Global playback rate. `prefers-reduced-motion` sets it near zero so the + * turn fills in at once and holds, instead of animating for a minute. + */ +let SPEED = 1 +export function setScenarioSpeed(multiplier: number) { + SPEED = multiplier +} +const nap = (ms: number, signal?: AbortSignal) => sleep(ms * SPEED, signal) + +export type CalloutAnchor = 'transcript' | 'waterfall' | 'composer' + +export interface Callout { + title: string + text: string + anchor: CalloutAnchor +} + +export interface DemoSpanInit { + id: string + parent?: string + name: string + service: string + kind?: string + attributes?: Array<[string, unknown]> +} + +/** A child session `harness::spawn` created, as the sidebar shows it. */ +export interface DemoSession { + id: string + title: string + /** The task the parent handed down: the child's seeding user message. */ + task: string +} + +/** + * A line a child wrote in its own transcript. Children are replayed a whole + * entry at a time rather than token by token: only one session is on screen, + * and the three off-screen ones would be animating at nobody. + */ +export type ChildEntry = + | { role: 'thought'; content: string; durationMs: number } + | { role: 'assistant'; content: string } + | { + role: 'function-trigger' + functionId: string + /** Worker that runs it β€” the child's `execute` span's service name. */ + worker: string + input: unknown + output: unknown + durationMs: number + } + +export type DemoEvent = + | StreamEvent + | { kind: 'demo-span-open'; span: DemoSpanInit } + | { + kind: 'demo-span-close' + id: string + status?: 'OK' | 'ERROR' + /** Close the span this long after it opened, rather than now. */ + durationMs?: number + } + | { kind: 'demo-callout'; callout: Callout } + | { kind: 'demo-session-open'; session: DemoSession } + | { kind: 'demo-session-msg'; id: string; entry: ChildEntry } + | { kind: 'demo-session-done'; id: string; result: string } + +export interface ScenarioOptions { + signal?: AbortSignal + /** + * Resolves when the gated call is released β€” by the viewer clicking + * approve/deny on the real card, or by the demo's own timeout. The demo + * never denies; a deny resolves the same way so the card can't hang. + */ + gate: (functionTriggerId: string) => Promise +} + +/* ── span bookkeeping ─────────────────────────────────────────────────── */ + +let spanSeq = 0 +function spanId(prefix: string): string { + spanSeq += 1 + return `${prefix}-${String(spanSeq).padStart(3, '0')}` +} + +/** Turn-identity baggage the harness stamps on every span of a step. */ +const TURN_TAGS: Array<[string, unknown]> = [ + ['iii.session.id', SESSION_ID], + ['iii.message.id', 'turn-01'], + ['iii.tag.kind', 'harness.turn'], + ['iii.tag.message', PROMPT], +] + +/* ── stream fragments ─────────────────────────────────────────────────── */ + +/** + * Time to leave a finished block of prose on screen before the next thing + * lands on top of it. Scaled by length and capped: the point is that the + * reader gets to finish the sentence, not that the demo waits out a full + * read of the closing answer. + */ +function readingDwellMs(body: string): number { + return Math.min(2800, Math.max(600, tokenize(body).length * 10)) +} + +async function* thought( + body: string, + signal?: AbortSignal, + meanDelayMs = 38, +): AsyncGenerator { + const startedAt = Date.now() + yield { kind: 'thought-start' } + for (const token of tokenize(body)) { + if (signal?.aborted) return + if (token) yield { kind: 'thought-token', token } + await nap(meanDelayMs * (0.6 + Math.random() * 0.8), signal) + } + yield { kind: 'thought-end', durationMs: Date.now() - startedAt } + await nap(readingDwellMs(body), signal) +} + +async function* assistant( + body: string, + signal?: AbortSignal, + meanDelayMs = 38, +): AsyncGenerator { + for (const token of tokenize(body)) { + if (signal?.aborted) return + if (token) yield { kind: 'assistant-token', token } + await nap(meanDelayMs * (0.5 + Math.random()), signal) + } + yield { kind: 'assistant-end' } + await nap(readingDwellMs(body), signal) +} + +/** + * A held beat after a result worth reading: the worker catalogue, a worker's + * contract, a test run's output. Rendered as a thought so the pause is a + * visible line in the transcript rather than the demo appearing to stall, + * and placed BETWEEN durable steps so it never inflates a step's span. + */ +async function* readPause( + signal: AbortSignal | undefined, + holdMs = 2800, +): AsyncGenerator { + const startedAt = Date.now() + yield { kind: 'thought-start' } + for (const token of tokenize('letting the user read…')) { + if (signal?.aborted) return + if (token) yield { kind: 'thought-token', token } + await nap(55, signal) + } + await nap(holdMs, signal) + if (signal?.aborted) return + yield { kind: 'thought-end', durationMs: Date.now() - startedAt } +} + +interface CallOptions { + fn: string + /** Worker that runs it β€” the `execute` span's service name. */ + worker: string + input: unknown + output: unknown + /** How long the demo lingers on the call, so a viewer can see it happen. */ + runMs: number + /** + * What the card and the span report, when that differs from how long the + * demo dwells. An engine-local call really does finish in microseconds; + * animating it that fast would make it invisible, and reporting the dwell + * instead would claim a trigger registration costs a third of a second. + */ + reportedMs?: number + parentSpan: string + signal?: AbortSignal + /** Nested spans opened inside the call, as [name, service, share-of-runMs]. */ + inner?: Array<[string, string, number]> + /** Hold the call in the approval gate before it executes. */ + gate?: (functionTriggerId: string) => Promise + functionTriggerId?: string + callout?: Callout +} + +async function* call(opts: CallOptions): AsyncGenerator { + const { + fn, + worker, + input, + output, + runMs, + reportedMs, + parentSpan, + signal, + inner, + gate, + functionTriggerId, + callout, + } = opts + const startedAt = Date.now() + + yield { + kind: 'fcall-start', + functionId: fn, + input, + ...(gate + ? { + pendingApproval: true, + functionTriggerId, + sessionId: SESSION_ID, + } + : {}), + } + if (callout) yield { kind: 'demo-callout', callout } + + if (gate && functionTriggerId) { + await gate(functionTriggerId) + if (signal?.aborted) return + yield { kind: 'fcall-approval-cleared', functionTriggerId, running: true } + } + + const execSpan = spanId('exec') + yield { + kind: 'demo-span-open', + span: { + id: execSpan, + parent: parentSpan, + name: `execute ${fn}`, + service: worker, + kind: 'internal', + attributes: [...TURN_TAGS, ['iii.function.id', fn]], + }, + } + + if (inner?.length) { + let elapsed = 0 + for (const [name, service, share] of inner) { + const childSpan = spanId('inner') + yield { + kind: 'demo-span-open', + span: { + id: childSpan, + parent: execSpan, + name, + service, + kind: 'internal', + attributes: TURN_TAGS, + }, + } + const slice = runMs * share + await nap(slice, signal) + elapsed += slice + if (signal?.aborted) return + yield { + kind: 'demo-span-close', + id: childSpan, + /* Inner work takes its share of the REPORTED time, so a child can + never outlast the call it happened inside. */ + ...(reportedMs === undefined ? {} : { durationMs: reportedMs * share }), + } + } + await nap(Math.max(0, runMs - elapsed), signal) + } else { + await nap(runMs, signal) + } + if (signal?.aborted) return + + yield { + kind: 'demo-span-close', + id: execSpan, + ...(reportedMs === undefined ? {} : { durationMs: reportedMs }), + } + yield { + kind: 'fcall-end', + output, + durationMs: reportedMs ?? Date.now() - startedAt, + ...(functionTriggerId ? { functionTriggerId } : {}), + } +} + +/** + * One durable loop step: the harness dequeues, asks the router for a + * completion, then dispatches whatever the model asked for. `body` streams + * the model's visible output; the router/provider spans close when it ends. + */ +async function* step( + index: number, + body: AsyncGenerator, + signal: AbortSignal | undefined, + after: (stepSpan: string) => AsyncGenerator, +): AsyncGenerator { + const stepSpan = spanId('step') + const routerSpan = spanId('router') + const providerSpan = spanId('provider') + + yield { + kind: 'demo-span-open', + span: { + id: stepSpan, + name: 'harness::turn step', + service: 'harness', + kind: 'internal', + attributes: [...TURN_TAGS, ['iii.turn.step', index]], + }, + } + yield { + kind: 'demo-span-open', + span: { + id: routerSpan, + parent: stepSpan, + name: 'execute router::chat', + service: 'llm-router', + kind: 'internal', + attributes: [...TURN_TAGS, ['gen_ai.request.model', 'claude-sonnet-5']], + }, + } + yield { + kind: 'demo-span-open', + span: { + id: providerSpan, + parent: routerSpan, + name: 'execute provider::anthropic::stream', + service: 'llm-provider-anthropic', + kind: 'client', + attributes: TURN_TAGS, + }, + } + + yield* body + if (signal?.aborted) return + + yield { kind: 'demo-span-close', id: providerSpan } + yield { kind: 'demo-span-close', id: routerSpan } + + yield* after(stepSpan) + if (signal?.aborted) return + + yield { kind: 'demo-span-close', id: stepSpan } +} + +/** + * One step that dispatches every child in `CHILDREN` as a parallel batch. + * + * All three cards land together; the gated one holds while the other two are + * already running, which is what the batch actually looks like when a + * deny-by-default policy only objects to one call in it. Each child opens its + * own `execute harness::spawn` span with the child's own `harness::turn step` + * nested inside, so the fan-out shows up as three branches of one trace, and + * its own session, so it shows up as three rows in the sidebar. + * + * Each child then works out loud: its `work` beats append to its own + * transcript and open their own `execute` spans under its turn step, so a + * viewer who clicks a sidebar row watches that child reason and call, and + * sees those calls in the same trace as the parent's. + */ +async function* spawnFanOut( + stepSpan: string, + gate: ScenarioOptions['gate'], + signal: AbortSignal | undefined, +): AsyncGenerator { + const startedAt = Date.now() + const spans = new Map() + + /** Open the child's span pair and its session β€” it is running now. */ + function* launch(child: ChildSpec): Generator { + const execSpan = spanId('exec') + const childStep = spanId('child') + spans.set(child.callId, execSpan) + yield { + kind: 'demo-span-open', + span: { + id: execSpan, + parent: stepSpan, + name: 'execute harness::spawn', + service: 'harness', + kind: 'internal', + attributes: [...TURN_TAGS, ['iii.child.session_id', child.sessionId]], + }, + } + yield { + kind: 'demo-span-open', + span: { + id: childStep, + parent: execSpan, + name: 'harness::turn step', + service: 'harness', + kind: 'internal', + attributes: [ + ['iii.session.id', child.sessionId], + ['iii.tag.kind', 'harness.subagent'], + ['iii.tag.display_name', `Subagent Β· ${child.title}`], + ], + }, + } + spans.set(`${child.callId}:step`, childStep) + yield { + kind: 'demo-session-open', + session: { id: child.sessionId, title: child.title, task: child.task }, + } + /* Whatever the child has to say the moment it wakes up. */ + for (const beat of child.work) { + if (beat.at === 0) yield* childBeat(child, beat.entry) + } + } + + /** One line of a child's own work: its transcript entry, and its span. */ + function* childBeat( + child: ChildSpec, + entry: ChildEntry, + ): Generator { + yield { kind: 'demo-session-msg', id: child.sessionId, entry } + if (entry.role !== 'function-trigger') return + const callSpan = spanId('childcall') + yield { + kind: 'demo-span-open', + span: { + id: callSpan, + parent: spans.get(`${child.callId}:step`), + name: `execute ${entry.functionId}`, + service: entry.worker, + kind: 'internal', + attributes: [ + ['iii.session.id', child.sessionId], + ['iii.function.id', entry.functionId], + ], + }, + } + /* Closed at what the call really costs, like every other call here: the + child's turn span is the wide one, its dispatches are hairlines. */ + yield { + kind: 'demo-span-close', + id: callSpan, + durationMs: entry.durationMs, + } + } + + /* Every card at once β€” a parallel tool-call batch is one assistant turn. */ + for (const child of CHILDREN) { + yield { + kind: 'fcall-start', + functionId: 'harness::spawn', + input: spawnInput(child), + functionTriggerId: child.callId, + sessionId: SESSION_ID, + ...(child.gated ? { pendingApproval: true } : {}), + } + } + for (const child of CHILDREN) { + if (!child.gated) yield* launch(child) + } + + yield { + kind: 'demo-callout', + callout: { + anchor: 'transcript', + title: 'three subagents, one held at the gate', + text: "Each subagent is a real session started in iii's native way, the sandbox worker, with its own transcript, its own turn budget and its own function policy, listed under this chat as it starts. Only `ledger core` asked for `database::*`, a write scope this session does not hold, so only that one waits for a human. Click approve to release it.", + }, + } + + for (const child of CHILDREN) { + if (!child.gated) continue + await gate(child.callId) + if (signal?.aborted) return + yield { + kind: 'fcall-approval-cleared', + functionTriggerId: child.callId, + running: true, + } + yield* launch(child) + } + + yield { + kind: 'demo-callout', + callout: { + anchor: 'transcript', + title: 'three sessions, running at once', + text: 'Every subagent is a session of its own, listed in the sidebar. Click one to watch it think and call while the others keep going; whatever it dispatches lands in this same trace, under its own `harness::turn step`.', + }, + } + + /* One clock over all three: the children interleave, and each reports when + it is done rather than in dispatch order. */ + const beats = [ + ...CHILDREN.flatMap((child) => + child.work + .filter((beat) => beat.at > 0) + .map((beat) => ({ at: beat.at, child, entry: beat.entry })), + ), + ...CHILDREN.map((child) => ({ + at: child.finishAfterMs, + child, + entry: undefined, + })), + ].sort((a, b) => a.at - b.at) + + let waited = 0 + for (const beat of beats) { + await nap(beat.at - waited, signal) + if (signal?.aborted) return + waited = beat.at + + if (beat.entry) { + yield* childBeat(beat.child, beat.entry) + continue + } + + const child = beat.child + const childStep = spans.get(`${child.callId}:step`) + const execSpan = spans.get(child.callId) + if (childStep) yield { kind: 'demo-span-close', id: childStep } + if (execSpan) yield { kind: 'demo-span-close', id: execSpan } + yield { + kind: 'demo-session-done', + id: child.sessionId, + result: child.resultText, + } + yield { + kind: 'fcall-end', + output: spawnResult(child), + durationMs: Date.now() - startedAt, + functionTriggerId: child.callId, + } + } +} + +/* ── outputs ──────────────────────────────────────────────────────────── */ + +const CONNECTED_AT = () => Date.now() - 4 * 60 * 60 * 1000 + +const WORKERS_LIST = () => ({ + workers: [ + { + id: 'wrk_01hq4m8database', + name: 'database', + description: 'Durable postgres: tables, queries, migrations.', + version: '0.21.0', + runtime: 'rust', + os: 'linux', + status: 'connected', + function_count: 9, + connected_at_ms: CONNECTED_AT(), + active_invocations: 0, + isolation: 'container', + tag: 'core', + }, + { + id: 'wrk_01hq4m8shell00', + name: 'shell', + description: 'Scoped command execution and filesystem access.', + version: '0.21.0', + runtime: 'rust', + os: 'linux', + status: 'connected', + function_count: 16, + connected_at_ms: CONNECTED_AT(), + active_invocations: 0, + isolation: 'container', + }, + { + id: 'wrk_01hq4m8coder000', + name: 'coder', + description: 'Reads, writes and patches files in a scoped workspace.', + version: '0.21.0', + runtime: 'node', + os: 'linux', + status: 'connected', + function_count: 12, + connected_at_ms: CONNECTED_AT(), + active_invocations: 0, + isolation: 'container', + }, + { + id: 'wrk_01hq4m8harness0', + name: 'harness', + description: 'The durable agent turn loop.', + version: '0.21.0', + runtime: 'rust', + os: 'linux', + status: 'connected', + function_count: 14, + connected_at_ms: CONNECTED_AT(), + active_invocations: 1, + isolation: 'container', + }, + { + id: 'wrk_01hq4m8observ00', + name: 'observability', + description: 'OTel collector: traces, logs and metrics for the engine.', + version: '0.21.0', + runtime: 'rust', + os: 'linux', + status: 'connected', + function_count: 6, + connected_at_ms: CONNECTED_AT(), + active_invocations: 0, + isolation: 'container', + }, + ], +}) + +const DATABASE_INFO = () => ({ + worker: { + id: 'wrk_01hq4m8database', + name: 'database', + description: 'Durable postgres: tables, queries, migrations.', + version: '0.21.0', + runtime: 'rust', + os: 'linux', + status: 'connected', + function_count: 9, + connected_at_ms: CONNECTED_AT(), + active_invocations: 0, + isolation: 'container', + internal: false, + pid: 1421, + latest_metrics: null, + }, + functions: [ + { + function_id: 'database::create_table', + worker_name: 'database', + description: 'Create a table from a column spec.', + }, + { + function_id: 'database::query', + worker_name: 'database', + description: 'Run a parameterised read query.', + }, + { + function_id: 'database::execute', + worker_name: 'database', + description: 'Run a parameterised write statement.', + }, + { + function_id: 'database::transaction', + worker_name: 'database', + description: 'Run several statements atomically.', + }, + { + function_id: 'database::migrate', + worker_name: 'database', + description: 'Apply pending migrations.', + }, + ], + trigger_types: [ + { + id: 'database.row_changed', + worker_name: 'database', + description: 'Fires when a watched table changes.', + }, + ], + registered_triggers: [], +}) + +/** + * The fan-out. Three children, dispatched in one step, each its own session + * with its own budget and its own function policy. Only the first asks for + * `database::*` β€” the write scope this session does not hold β€” so only the + * first stops at the approval gate; the other two dispatch immediately. + */ +interface ChildSpec { + /** iii function_call_id, and the key the approval resolves against. */ + callId: string + sessionId: string + title: string + task: string + allow: string[] + /** Held at the gate before it may run. */ + gated?: boolean + /** The child's own transcript, played out while the parent waits. */ + work: ChildBeat[] + resultText: string + resultDetails: Record + /** Delay after the gate clears before this child reports, in ms. */ + finishAfterMs: number +} + +interface ChildBeat { + /** ms after the gate clears; 0 lands the moment the child starts. */ + at: number + entry: ChildEntry +} + +/* The source the children write, shown by the coder card as they write it. */ + +const CHARGE_RECORD_RS = `use crate::types::{Charge, Entry}; +use iii_sdk::{Error, IIIClient, RegisterFunction, TriggerRequest}; +use serde_json::json; + +/// A charge is two rows: debit the customer, credit the house account. +/// Both land in one \`database::transaction\`, or neither does. +pub fn register(iii: &IIIClient) { + let db = iii.clone(); + iii.register_function( + "payments::charge::record", + RegisterFunction::new_async(move |charge: Charge| { + let db = db.clone(); + async move { + if let Some(posted) = replay_of(&db, &charge.provider_event_id).await? { + return Ok::(posted); + } + let rows = db + .trigger(TriggerRequest { + function_id: "database::transaction".into(), + payload: json!({ "statements": double_entry(&charge) }), + action: None, + timeout_ms: Some(5_000), + }) + .await?; + Ok(Entry::from_rows(&charge, rows)?) + } + }) + .description("Post a charge to the ledger as a balanced double entry."), + ); +}` + +const CHARGE_REFUND_RS = `use crate::types::{Entry, Refund}; +use iii_sdk::{Error, IIIClient, RegisterFunction, TriggerRequest}; +use serde_json::json; + +/// A refund reverses a posted entry and can never exceed what is left of it. +pub fn register(iii: &IIIClient) { + let db = iii.clone(); + iii.register_function( + "payments::charge::refund", + RegisterFunction::new_async(move |refund: Refund| { + let db = db.clone(); + async move { + let original = fetch_entry(&db, &refund.entry_id).await?; + guard_refundable(&original, &refund)?; + let rows = db + .trigger(TriggerRequest { + function_id: "database::transaction".into(), + payload: json!({ "statements": reversal(&original, &refund) }), + action: None, + timeout_ms: Some(5_000), + }) + .await?; + Ok::(Entry::from_reversal(&original, rows)?) + } + }) + .description("Reverse a posted charge, in part or in full."), + ); +}` + +const WEBHOOK_STRIPE_RS = `use crate::types::{StripeEvent, WebhookAck}; +use iii_sdk::{Error, IIIClient, RegisterFunction, TriggerRequest}; +use serde_json::json; + +/// Stripe retries. \`charge::record\` already dedupes on the provider event +/// id, so a replayed delivery posts nothing and returns the first entry. +pub fn register(iii: &IIIClient) { + let engine = iii.clone(); + iii.register_function( + "payments::webhook::stripe", + RegisterFunction::new_async(move |event: StripeEvent| { + let engine = engine.clone(); + async move { + let Some(charge) = charge_from(&event) else { + return Ok::(WebhookAck::ignored(&event.kind)); + }; + let entry = engine + .trigger(TriggerRequest { + function_id: "payments::charge::record".into(), + payload: json!(charge), + action: None, + timeout_ms: Some(10_000), + }) + .await?; + Ok(WebhookAck::posted(entry)) + } + }) + .description("Post a Stripe charge event to the ledger, exactly once."), + ); +}` + +const RECONCILE_RS = `use crate::types::{ReconcileReport, Window}; +use iii_sdk::{Error, IIIClient, RegisterFunction, TriggerRequest}; +use serde_json::json; + +/// Every account's entries must sum to its balance and every entry must have +/// a counterpart. Anything else is reported, never silently repaired. +pub fn register(iii: &IIIClient) { + let db = iii.clone(); + iii.register_function( + "payments::ledger::reconcile", + RegisterFunction::new_async(move |window: Window| { + let db = db.clone(); + async move { + let rows = db + .trigger(TriggerRequest { + function_id: "database::query".into(), + payload: json!({ "sql": UNBALANCED, "params": [window.since] }), + action: None, + timeout_ms: Some(30_000), + }) + .await?; + Ok::(ReconcileReport::from_rows(rows)?) + } + }) + .description("Check the ledger balances and flag orphaned entries."), + ); +}` + +const TESTS_RS = `use crate::support::{ledger, TestEngine}; + +// The suite runs through the engine, against the contract, so it passes and +// fails the same way a caller does. + +#[tokio::test] +async fn charge_record_writes_double_entry() { + let engine = TestEngine::start().await; + let entry = ledger::record(&engine, 4_200, "cus_8Kd2Qw", "evt_3PfL2m").await; + + assert_eq!(entry.balance, 4_200); + assert_eq!(ledger::rows_for(&engine, &entry.entry_id).await.len(), 2); + assert_eq!(ledger::sum_of(&engine, &entry.account).await, 0); +} + +#[tokio::test] +async fn stripe_webhook_dedupes_by_event_id() { + let engine = TestEngine::start().await; + let first = ledger::webhook(&engine, "evt_3PfL2m").await; + let replay = ledger::webhook(&engine, "evt_3PfL2m").await; + + assert_eq!(first.entry_id, replay.entry_id); + assert!(replay.idempotent_replay); +} + +#[tokio::test] +async fn refund_rejects_over_refund() { + let engine = TestEngine::start().await; + let entry = ledger::record(&engine, 4_200, "cus_8Kd2Qw", "evt_9Qm1Zx").await; + let err = ledger::try_refund(&engine, &entry.entry_id, 5_000).await.unwrap_err(); + + assert!(err.to_string().contains("exceeds")); +} + +// 8 more: reversal maths, unknown event kinds, orphaned entries, reads of +// uncommitted rows, concurrent charges on one account, schema drift.` + +/** A `coder::create-file` exchange, as the batch card renders it. */ +function writeFiles( + files: Array<[path: string, content: string]>, + durationMs: number, +): ChildEntry { + return { + role: 'function-trigger', + functionId: 'coder::create-file', + worker: 'coder', + input: { + files: files.map(([path, content]) => ({ path, content, parents: true })), + }, + output: { + results: files.map(([path, content]) => ({ + path: `/workspace/payments-ledger/${path}`, + success: true, + bytes_written: content.length, + })), + }, + durationMs, + } +} + +/** A `database::create_table` exchange. */ +function createTable( + table: string, + columns: Array<[name: string, type: string]>, + durationMs: number, +): ChildEntry { + return { + role: 'function-trigger', + functionId: 'database::create_table', + worker: 'database', + input: { + table, + if_not_exists: true, + columns: columns.map(([name, type]) => ({ name, type })), + }, + output: { table, created: true, columns: columns.length }, + durationMs, + } +} + +const CHILDREN: ChildSpec[] = [ + { + callId: 'fc_spawn_ledger_core', + sessionId: 'console-sub-ledger-core', + title: 'ledger core', + task: `Write the payments-ledger worker's core against the existing \`database\` worker. + +\`payments::charge::record\` and \`payments::charge::refund\`, double-entry rows, +\`database::transaction\` for anything that writes two rows. Create the +\`ledger_entries\` and \`ledger_accounts\` tables.`, + allow: ['coder::*', 'database::*'], + gated: true, + work: [ + { + at: 0, + entry: { + role: 'assistant', + content: + 'Double-entry, so a charge is never one row: debit the customer account, credit the house account, both inside one `database::transaction` or neither. That also gives the tests their invariant, every account summing to zero across its entries. Tables first, then the two writers.', + }, + }, + { + at: 1300, + entry: createTable( + 'ledger_accounts', + [ + ['id', 'text primary key'], + ['owner', 'text not null'], + ['currency', 'text not null'], + ['balance_minor', 'bigint not null default 0'], + ], + 18.7, + ), + }, + { + at: 2500, + entry: createTable( + 'ledger_entries', + [ + ['id', 'text primary key'], + ['account_id', 'text not null references ledger_accounts(id)'], + ['amount_minor', 'bigint not null'], + ['provider_event_id', 'text unique'], + ['posted_at', 'timestamptz not null default now()'], + ], + 22.4, + ), + }, + { + at: 3500, + entry: { + role: 'thought', + content: + 'Both tables are up, and the foreign key ties every entry to an account. Now the writers. Refund is the one with a rule attached: it reverses a posted entry and can never take out more than is left in it.', + durationMs: 2400, + }, + }, + { + at: 4400, + entry: writeFiles( + [ + ['src/functions/charge_record.rs', CHARGE_RECORD_RS], + ['src/functions/charge_refund.rs', CHARGE_REFUND_RS], + ], + 61.5, + ), + }, + ], + resultText: + 'ledger core done: charge::record + charge::refund over a double-entry schema, 2 tables created.', + resultDetails: { + functions: ['payments::charge::record', 'payments::charge::refund'], + tables: ['ledger_entries', 'ledger_accounts'], + turns_used: 7, + }, + finishAfterMs: 7400, + }, + { + callId: 'fc_spawn_ledger_webhook', + sessionId: 'console-sub-ledger-webhook', + title: 'stripe webhook', + task: `Write \`payments::webhook::stripe\` and \`payments::ledger::reconcile\` for the +payments-ledger worker. Idempotent on the provider event id: a replayed event +must post nothing and return the original entry.`, + allow: ['coder::*', 'web::search'], + work: [ + { + at: 0, + entry: { + role: 'assistant', + content: + 'Stripe retries on any non-2xx, so this has to be idempotent at the ledger rather than at the HTTP layer. The provider event id is the natural key and `charge::record` is already unique on it, so the webhook stays thin: map the event, hand it over, return whatever comes back.', + }, + }, + { + at: 1500, + entry: writeFiles( + [['src/functions/webhook_stripe.rs', WEBHOOK_STRIPE_RS]], + 44.6, + ), + }, + { + at: 2500, + entry: { + role: 'thought', + content: + 'Reconcile is the other half of trusting the ledger: read-only, so it can run on a cron without taking a lock, and it reports drift rather than repairing it. Silently fixing a payments discrepancy is how you lose the audit trail that made it findable.', + durationMs: 1900, + }, + }, + { + at: 3300, + entry: writeFiles([['src/functions/reconcile.rs', RECONCILE_RS]], 31.9), + }, + ], + resultText: + 'webhook + reconcile done: dedupe keyed on provider_event_id, replays return the original entry.', + resultDetails: { + functions: ['payments::webhook::stripe', 'payments::ledger::reconcile'], + idempotency_key: 'provider_event_id', + turns_used: 5, + }, + finishAfterMs: 4600, + }, + { + callId: 'fc_spawn_ledger_tests', + sessionId: 'console-sub-ledger-tests', + title: 'test suite', + task: `Write the payments-ledger test suite. Cover double-entry balance, refunds +over the original amount, webhook replay, concurrent charges on one account, +and that the schema matches the migration.`, + allow: ['coder::*', 'shell::exec'], + work: [ + { + at: 0, + entry: { + role: 'assistant', + content: + 'The other two are still writing, so I will test the contract rather than their implementation: call every function through the engine the way a caller would. The suite is then ready the moment the worker installs, and it fails for the same reasons production would.', + }, + }, + { at: 1100, entry: writeFiles([['tests/ledger.rs', TESTS_RS]], 38.2) }, + ], + resultText: + 'test suite done: 11 tests over the ledger, webhook and schema.', + resultDetails: { tests: 11, files: ['tests/ledger.rs'], turns_used: 4 }, + finishAfterMs: 2600, + }, +] + +function spawnInput(child: ChildSpec) { + return { + task: child.task, + model: 'claude-sonnet-5', + session_id: child.sessionId, + parent_session_id: SESSION_ID, + options: { + mode: 'agent', + max_turns: 12, + output: { type: 'text' }, + functions: { allow: child.allow, deny: ['compose::*', 'worker::remove'] }, + }, + } +} + +function spawnResult(child: ChildSpec) { + return { + content: [{ type: 'text' as const, text: child.resultText }], + details: { session_id: child.sessionId, ...child.resultDetails }, + } +} + +const TEST_STDOUT = ` Compiling payments-ledger v0.1.0 + Finished test profile in 4.21s + Running tests/ledger.rs + +test charge_record_writes_double_entry ... ok +test charge_record_is_idempotent ......... ok +test refund_reverses_original_entry ...... ok +test refund_rejects_over_refund .......... ok +test stripe_webhook_dedupes_by_event_id .. ok +test stripe_webhook_ignores_unknown_type . ok +test reconcile_balances_to_zero .......... ok +test reconcile_flags_orphan_entries ...... ok +test balance_reads_committed_only ........ ok +test concurrent_charges_serialize ........ ok +test schema_matches_migration ............ ok + +test result: ok. 11 passed; 0 failed; finished in 1.83s` + +const HTTP_TRIGGERS = [ + ['payments::charge::record', 'POST', '/payments/charges'], + ['payments::charge::refund', 'POST', '/payments/refunds'], + ['payments::webhook::stripe', 'POST', '/payments/webhooks/stripe'], + ['payments::ledger::reconcile', 'POST', '/payments/reconcile'], +] as const + +const FINAL_ANSWER = `\`payments-ledger\` is live on the engine and answering. + +| function | trigger | backed by | +| --- | --- | --- | +| \`payments::charge::record\` | \`POST /payments/charges\` | \`database::transaction\` | +| \`payments::charge::refund\` | \`POST /payments/refunds\` | \`database::transaction\` | +| \`payments::webhook::stripe\` | \`POST /payments/webhooks/stripe\` | \`database::execute\` | +| \`payments::ledger::reconcile\` | \`POST /payments/reconcile\` | \`database::query\` | + +Nothing here needed to be integrated. The \`database\` worker was available on the registry, +so I installed it like a library. Unlike a library it was immediately ready to use. It joined +the same engine the rest of your workers are on. + +**About those three subagents.** \`ledger core\`, \`stripe webhook\` and +\`test suite\` are still listed under this chat. They are real sessions +started in iii's native way: the sandbox worker. Each one had its own transcript, +its own turn budget and its own function policy, and you can open any of +them to read what it did. + +**About the pane on the right.** That is the +trace the observability worker recorded while the harness worker did its work. +There was no separate setup, as with any worker if it's added to the iii engine +it's immediately and completely observable. Every row above opened a span: +each \`harness::turn step\` is one durable step of my loop running on top of the queue worker, the +three \`harness::spawn\` branches are the subagents running at the same time, and +\`execute payments::charge::record\` near the bottom is the function they built +answering a live request. In the console you would click any bar for its +arguments, its result, and its logs. If this turn had crashed halfway, the loop +would have resumed from the last completed step, into the same trace.` + +/* ── the script ───────────────────────────────────────────────────────── */ + +export async function* runScenario( + opts: ScenarioOptions, +): AsyncGenerator { + const { signal, gate } = opts + spanSeq = 0 + + yield { kind: 'turn-status', phase: 'accepted' } + await nap(420, signal) + yield { kind: 'turn-status', phase: 'started' } + await nap(260, signal) + + /* step 1 β€” look at what is already running */ + yield* step( + 1, + thought( + 'The user wants a payments ledger with durable storage. Before I write a line of it I should look at what is already connected to this engine. iii keeps a live catalog of every running worker, so a durable database may already be here. If it is, there is nothing to scaffold and nothing to deploy alongside.', + signal, + ), + signal, + (stepSpan) => + call({ + fn: 'engine::workers::list', + worker: 'iii', + input: { status: 'connected' }, + output: WORKERS_LIST(), + runMs: 900, + reportedMs: 2.1, + parentSpan: stepSpan, + signal, + callout: { + anchor: 'transcript', + title: 'discovery', + text: 'The agent reads the engine’s live worker catalog. Whatever is already running is already integrated and callable.', + }, + }), + ) + if (signal?.aborted) return + yield* readPause(signal) + + /* step 2 β€” read the database worker's contract */ + yield* step( + 2, + thought( + '`database` is available: durable postgres, nine functions. Let me read its contract so the ledger is written against the real signatures instead of guesses.', + signal, + ), + signal, + (stepSpan) => + call({ + fn: 'engine::workers::info', + worker: 'iii', + input: { name: 'database' }, + output: DATABASE_INFO(), + runMs: 700, + reportedMs: 1.4, + parentSpan: stepSpan, + signal, + callout: { + anchor: 'waterfall', + title: 'every operation is traced', + text: 'Every call opens a span. This is the console’s own trace view, filling in live, recorded by the observability worker. Any worker added to the engine is immediately observable, no separate setup, and it is OTel compatible.', + }, + }), + ) + if (signal?.aborted) return + yield* readPause(signal) + + /* step 3 β€” fan out to three subagents; one of them stops at the gate */ + yield* step( + 3, + assistant( + '`database` gives me durable postgres with transactions, so the ledger can be double-entry without a second datastore. Three pieces here are independent, so I’ll run them as three subagents and stay on the wiring myself.', + signal, + ), + signal, + (stepSpan) => spawnFanOut(stepSpan, gate, signal), + ) + if (signal?.aborted) return + yield* readPause(signal) + + /* step 4 β€” install the worker the subagents wrote */ + yield* step( + 4, + thought( + 'All three subagents are back: core, webhook and tests. Install the worker so the engine can route to it.', + signal, + ), + signal, + (stepSpan) => + call({ + fn: 'worker::add', + worker: 'worker-manager', + input: { + source: { kind: 'local', path: './payments-ledger' }, + wait: true, + }, + output: { + name: 'payments-ledger', + version: '0.1.0', + status: 'installed', + awaited_ready: true, + config_path: './config.yaml', + }, + runMs: 1600, + parentSpan: stepSpan, + signal, + }), + ) + if (signal?.aborted) return + + /* step 5 β€” one HTTP trigger per function, dispatched in a single step */ + yield* step( + 5, + thought( + 'Now the entry points. I can use the http worker and each function gets an HTTP trigger.', + signal, + ), + signal, + async function* (stepSpan) { + for (const [fn, method, path] of HTTP_TRIGGERS) { + yield* call({ + fn: 'engine::register_trigger', + worker: 'iii', + input: { + trigger_type: 'http', + function_id: fn, + config: { method, path }, + }, + output: { + id: `trg_${path.replace(/\W+/g, '_')}`, + trigger_type: 'http', + function_id: fn, + registered: true, + }, + runMs: 320, + reportedMs: 0.31, + parentSpan: stepSpan, + signal, + }) + if (signal?.aborted) return + } + }, + ) + if (signal?.aborted) return + + /* step 6 β€” run the subagent's tests against the live worker */ + yield* step( + 6, + thought( + 'Run the tests the subagent wrote against the installed worker.', + signal, + ), + signal, + (stepSpan) => + call({ + fn: 'shell::exec', + worker: 'shell', + input: { + command: 'iii test payments-ledger', + cwd: '/workspace/payments-ledger', + timeout_ms: 120000, + }, + output: { + exit_code: 0, + stdout: TEST_STDOUT, + stderr: '', + duration_ms: 6041, + timed_out: false, + stdout_truncated: false, + stderr_truncated: false, + }, + runMs: 2600, + reportedMs: 6041, + parentSpan: stepSpan, + signal, + }), + ) + if (signal?.aborted) return + yield* readPause(signal) + + /* step 7 β€” call the thing it just built */ + yield* step( + 7, + thought('Tests pass. Last check: call the function for real.', signal), + signal, + (stepSpan) => + call({ + fn: 'payments::charge::record', + worker: 'payments-ledger', + input: { + amount: 4200, + currency: 'usd', + customer_id: 'cus_8Kd2Qw', + provider_event_id: 'evt_3PfL2m', + }, + output: { + entry_id: 'led_01hq5r7t9m', + account: 'cus_8Kd2Qw', + amount: 4200, + currency: 'usd', + balance: 4200, + posted_at: new Date().toISOString(), + idempotent_replay: false, + }, + runMs: 900, + reportedMs: 12.4, + parentSpan: stepSpan, + signal, + inner: [['execute database::transaction', 'database', 0.6]], + callout: { + anchor: 'waterfall', + title: 'a new payments worker, just added to the system', + text: 'The new worker’s span lands under the same trace, next to the `database` call it makes. Nothing was re-instrumented to get it there.', + }, + }), + ) + if (signal?.aborted) return + yield* readPause(signal) + + /* step 8 β€” the answer, and a tour of the pane on the right */ + yield* step( + 8, + assistant(FINAL_ANSWER, signal, 22), + signal, + async function* () { + yield { + kind: 'demo-callout', + callout: { + anchor: 'waterfall', + title: 'one trace, eight durable steps', + text: 'Each `harness::turn step` row is one durable step of the loop, running on top of the queue worker. A crash mid-turn resumes from the last completed step, into the same trace. Agentic loops are a normal engineering pattern in iii. They work the same as any programmatic loop would in iii.', + }, + } + }, + ) +} diff --git a/console/web/src/demo/stubs/pierre-diffs.tsx b/console/web/src/demo/stubs/pierre-diffs.tsx new file mode 100644 index 000000000..b1c7b7043 --- /dev/null +++ b/console/web/src/demo/stubs/pierre-diffs.tsx @@ -0,0 +1,36 @@ +/** + * Build stub for `@pierre/diffs` (and `/react`), aliased in only by + * `vite.demo.config.ts`. + * + * The package pulls all of shiki's grammars, which lands ~13MB of language + * chunks in the output directory. The demo's sub-agents do write files, so + * the coder card is reachable β€” it just renders the body unhighlighted here. + * Everything around it (batch chips, paths, byte counts) is the real view. + */ + +export const DEFAULT_THEMES = { light: 'github-light', dark: 'github-dark' } + +export interface FileContents { + name: string + contents: string +} + +/** The body, monospaced and scrollable, with no tokenizer behind it. */ +function PlainFile({ contents }: { contents: string }) { + return ( +
+      {contents}
+    
+ ) +} + +export function File({ file }: { file: FileContents }) { + return +} + +/** New-file diffs are all the demo produces: show the new side. */ +export function MultiFileDiff({ newFile }: { newFile: FileContents }) { + return +} + +export default { DEFAULT_THEMES, File, MultiFileDiff } diff --git a/console/web/src/demo/usePlayer.ts b/console/web/src/demo/usePlayer.ts new file mode 100644 index 000000000..3ca43b367 --- /dev/null +++ b/console/web/src/demo/usePlayer.ts @@ -0,0 +1,671 @@ +/** + * Drives the landing demo: walks `runScenario()` and folds its events into + * the three pieces of state the surface renders. + * + * The `StreamEvent` half of the switch below is the reducer from + * `ChatView`'s stream loop (`components/chat/ChatView.tsx`), trimmed to the + * cases a scripted turn can produce. Keeping the same reducer is the point: + * the transcript is built the way the real console builds it, so + * `MessageList` renders exactly what it renders in the product. + * + * Lifecycle: idle β†’ typing the prompt β†’ streaming β†’ done β†’ (hold) β†’ reset. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { StoredSpan } from '@/pages/TracesV2/api/traces' +import { + toWaterfallData, + type WaterfallData, +} from '@/pages/TracesV2/lib/traceTransform' +import type { + AssistantMessage, + Conversation, + FunctionTriggerMessage, + Message, + MessagePatch, + ThoughtMessage, + UserMessage, +} from '@/types/chat' +import { + type Callout, + type ChildEntry, + type DemoEvent, + MODEL_ID, + PROMPT, + runScenario, + SESSION_ID, + TRACE_ID, +} from './scenario' + +export type Phase = 'idle' | 'typing' | 'streaming' | 'done' + +/** How long the finished turn stays up before the loop restarts. */ +const HOLD_MS = 14000 +const TYPE_MS_PER_CHAR = 42 +/** Repaint cadence while a span is still open, so its bar grows. */ +const PENDING_TICK_MS = 120 +/** The gate releases itself if nobody clicks approve. */ +const GATE_TIMEOUT_MS = 5200 +/** A callout clears itself so a stale one never annotates the wrong beat. */ +const CALLOUT_MS = 11000 + +let seq = 0 +function uid(): string { + seq += 1 + return `demo-${seq}` +} + +/** Root chat title, shown in the sidebar. */ +const ROOT_TITLE = 'payments ledger' + +/** One replayed child entry, as the `Message` the transcript renders. */ +function childMessage( + sessionId: string, + index: number, + entry: ChildEntry, + now: number, +): Message { + const id = `${sessionId}-${index}` + switch (entry.role) { + case 'thought': + return { + id, + role: 'thought', + content: entry.content, + durationMs: entry.durationMs, + createdAt: now, + } + case 'assistant': + return { + id, + role: 'assistant', + content: entry.content, + model: MODEL_ID, + mode: 'agent', + createdAt: now, + } + case 'function-trigger': + return { + id, + role: 'function-trigger', + functionId: entry.functionId, + input: entry.input, + output: entry.output, + durationMs: entry.durationMs, + running: false, + sessionId, + createdAt: now, + } + } +} + +function rootConversation(status: Conversation['status']): Conversation { + const now = Date.now() + return { + id: SESSION_ID, + title: ROOT_TITLE, + model: MODEL_ID, + mode: 'agent', + messages: [], + depth: 0, + status, + createdAt: now, + updatedAt: now, + } +} + +export interface PlayerState { + phase: Phase + /** The selected session's transcript: the root turn, or a child's. */ + messages: Message[] + /** Characters of the prompt typed so far, for the composer. */ + typed: string + waterfall: WaterfallData | null + /** The raw feed, for the live TimelineStrip masthead. */ + spans: readonly StoredSpan[] + spanCount: number + callout: Callout | null + /** The turn is between visible outputs β€” drives the thinking shimmer. */ + isThinking: boolean + thinkingDetail?: string + /** Set while a call sits in the approval gate. */ + resolveApproval: ( + sessionId: string, + functionTriggerId: string, + decision: 'allow' | 'deny', + ) => Promise + /** Root chat plus every session `harness::spawn` created, for the sidebar. */ + conversations: Conversation[] + activeId: string + select: (id: string) => void + /** The selected session, when it is one of the children. */ + activeChild: Conversation | null + /** Reader-controlled freeze: the run holds between events until resumed. */ + paused: boolean + togglePause: () => void + /** Restart the turn from the top, whatever state it is in. */ + replay: () => void + /** Bumped by every restart, for anything outside that plays along. */ + runKey: number +} + +export function usePlayer(active: boolean, loop = true): PlayerState { + const [phase, setPhase] = useState('idle') + const [messages, setMessages] = useState([]) + const [typed, setTyped] = useState('') + const [callout, setCallout] = useState(null) + const [turnPhase, setTurnPhase] = useState(null) + const [isStreaming, setIsStreaming] = useState(false) + /* Child sessions, in spawn order, each carrying its own little transcript. */ + const [children, setChildren] = useState([]) + const [activeId, setActiveId] = useState(SESSION_ID) + + // Spans live in a ref: the scenario mutates them far more often than the + // waterfall needs to repaint, and the pending tick owns the cadence. + const spansRef = useRef([]) + const [waterfall, setWaterfall] = useState(null) + // The strip masthead reads spans directly (it does its own layout), so the + // repaint publishes the array alongside the derived waterfall. + const [spans, setSpans] = useState([]) + const [spanCount, setSpanCount] = useState(0) + + /* Pause is a ref the running loop polls (state alone would be stale inside + the closure) plus state for the button label. */ + const [paused, setPaused] = useState(false) + const pausedRef = useRef(false) + const togglePause = useCallback(() => { + pausedRef.current = !pausedRef.current + setPaused(pausedRef.current) + }, []) + const [runKey, setRunKey] = useState(0) + const replay = useCallback(() => { + pausedRef.current = false + setPaused(false) + setRunKey((k) => k + 1) + }, []) + + const gateResolveRef = useRef<(() => void) | null>(null) + const calloutTimerRef = useRef | undefined>( + undefined, + ) + const runIdRef = useRef(0) + + /** Show a callout, and retire it on its own so it never outlives its beat. */ + const showCallout = useCallback((next: Callout | null) => { + clearTimeout(calloutTimerRef.current) + setCallout(next) + if (next) { + calloutTimerRef.current = setTimeout(() => setCallout(null), CALLOUT_MS) + } + }, []) + + const openChild = useCallback((id: string, title: string, task: string) => { + const now = Date.now() + setChildren((prev) => + prev.some((c) => c.id === id) + ? prev + : [ + ...prev, + { + id, + title, + model: MODEL_ID, + mode: 'agent', + parentId: SESSION_ID, + depth: 1, + spawnedBy: 'agent', + status: 'working', + messages: [ + { + id: `${id}-task`, + role: 'user', + content: task, + spawn: true, + createdAt: now, + }, + ], + createdAt: now, + updatedAt: now, + }, + ], + ) + }, []) + + const appendChild = useCallback((id: string, entry: ChildEntry) => { + const now = Date.now() + setChildren((prev) => + prev.map((c) => + c.id === id + ? { + ...c, + updatedAt: now, + messages: [ + ...c.messages, + childMessage(id, c.messages.length, entry, now), + ], + } + : c, + ), + ) + }, []) + + const finishChild = useCallback((id: string, result: string) => { + const now = Date.now() + setChildren((prev) => + prev.map((c) => + c.id === id + ? { + ...c, + status: 'done', + updatedAt: now, + messages: [ + ...c.messages, + { + id: `${id}-result`, + role: 'assistant', + content: result, + model: MODEL_ID, + mode: 'agent', + createdAt: now, + }, + ], + } + : c, + ), + ) + }, []) + + const append = useCallback((message: Message) => { + setMessages((prev) => [...prev, message]) + }, []) + + const patch = useCallback((id: string, p: MessagePatch) => { + setMessages((prev) => + prev.map((m) => (m.id === id ? ({ ...m, ...p } as Message) : m)), + ) + }, []) + + const repaintSpans = useCallback(() => { + const spans = spansRef.current + setSpans(spans) + setSpanCount(spans.length) + setWaterfall(spans.length ? toWaterfallData(spans, TRACE_ID) : null) + }, []) + + const resolveApproval = useCallback(async () => { + gateResolveRef.current?.() + gateResolveRef.current = null + }, []) + + /* Repaint while anything is still open so pending bars grow. */ + useEffect(() => { + if (phase !== 'streaming') return + const t = setInterval(() => { + if (!pausedRef.current && spansRef.current.some((s) => s.pending)) + repaintSpans() + }, PENDING_TICK_MS) + return () => clearInterval(t) + }, [phase, repaintSpans]) + + useEffect(() => { + if (!active) return + runIdRef.current += 1 + const runId = runIdRef.current + const controller = new AbortController() + const { signal } = controller + let holdTimer: ReturnType | undefined + + const stale = () => signal.aborted || runIdRef.current !== runId + + /* The listener is dropped when the timer wins too: one controller serves a + whole looping run, and the paused-hold polls every 150ms, so leaving + them attached grows the list for as long as the demo is on screen. */ + const wait = (ms: number) => + new Promise((resolve) => { + const done = () => { + clearTimeout(t) + signal.removeEventListener('abort', done) + resolve() + } + const t = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + }) + + const holdWhilePaused = async () => { + while (pausedRef.current && !stale()) await wait(150) + } + + const gate = (_functionTriggerId: string) => + new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + gateResolveRef.current = null + clearTimeout(timer) + resolve() + } + const timer = setTimeout(finish, GATE_TIMEOUT_MS) + gateResolveRef.current = finish + signal.addEventListener('abort', finish, { once: true }) + }) + + async function play() { + /* reset */ + spansRef.current = [] + setMessages([]) + setChildren([]) + setActiveId(SESSION_ID) + setTyped('') + showCallout(null) + setWaterfall(null) + setSpans([]) + setSpanCount(0) + setIsStreaming(false) + setTurnPhase(null) + setPhase('typing') + + /* type the prompt into the composer */ + await wait(700) + for (let i = 1; i <= PROMPT.length; i++) { + await holdWhilePaused() + if (stale()) return + setTyped(PROMPT.slice(0, i)) + await wait(TYPE_MS_PER_CHAR * (0.55 + Math.random() * 0.9)) + } + if (stale()) return + await wait(520) + + /* submit */ + const userMsg: UserMessage = { + id: uid(), + role: 'user', + content: PROMPT, + createdAt: Date.now(), + } + setTyped('') + append(userMsg) + setPhase('streaming') + setIsStreaming(true) + + /* ── the ChatView stream reducer ─────────────────────────────── */ + let thoughtId: string | null = null + let thoughtBuffer = '' + let fcallId: string | null = null + const fcallMap = new Map() + let assistantId: string | null = null + let assistantBuffer = '' + + for await (const event of runScenario({ signal, gate })) { + /* The generator is pull-based: holding here suspends the scenario. */ + await holdWhilePaused() + if (stale()) return + const ev = event as DemoEvent + switch (ev.kind) { + case 'thought-start': { + const msg: ThoughtMessage = { + id: uid(), + role: 'thought', + content: '', + durationMs: 0, + streaming: true, + createdAt: Date.now(), + } + thoughtId = msg.id + thoughtBuffer = '' + append(msg) + break + } + case 'thought-token': { + if (!thoughtId) break + thoughtBuffer += ev.token + patch(thoughtId, { content: thoughtBuffer }) + break + } + case 'thought-end': { + if (!thoughtId) break + patch(thoughtId, { streaming: false, durationMs: ev.durationMs }) + thoughtId = null + break + } + case 'fcall-start': { + if (assistantId) { + patch(assistantId, { streaming: false }) + assistantId = null + assistantBuffer = '' + } + const msg: FunctionTriggerMessage = { + id: uid(), + role: 'function-trigger', + functionId: ev.functionId, + input: ev.input, + running: !ev.pendingApproval, + pendingApproval: ev.pendingApproval, + functionTriggerId: ev.functionTriggerId, + sessionId: ev.sessionId, + createdAt: Date.now(), + } + fcallId = msg.id + if (ev.functionTriggerId) fcallMap.set(msg.id, ev.functionTriggerId) + append(msg) + break + } + case 'fcall-approval-cleared': { + const clearedId = [...fcallMap.entries()].find( + ([, fcid]) => fcid === ev.functionTriggerId, + )?.[0] + if (clearedId) { + patch(clearedId, { + pendingApproval: false, + ...(ev.running ? { running: true } : {}), + }) + } + break + } + case 'fcall-end': { + const targetId: string | null = ev.functionTriggerId + ? ([...fcallMap.entries()].find( + ([, fcid]) => fcid === ev.functionTriggerId, + )?.[0] ?? fcallId) + : fcallId + if (!targetId) break + patch(targetId, { + output: ev.output, + durationMs: ev.durationMs, + running: false, + pendingApproval: false, + }) + fcallMap.delete(targetId) + if (targetId === fcallId) fcallId = null + break + } + case 'assistant-token': { + if (!assistantId) { + const msg: AssistantMessage = { + id: uid(), + role: 'assistant', + content: '', + model: MODEL_ID, + mode: 'agent', + streaming: true, + createdAt: Date.now(), + } + assistantId = msg.id + assistantBuffer = '' + append(msg) + } + assistantBuffer += ev.token + patch(assistantId, { content: assistantBuffer }) + break + } + case 'assistant-end': { + if (assistantId) patch(assistantId, { streaming: false }) + assistantId = null + assistantBuffer = '' + break + } + case 'turn-status': { + setTurnPhase(ev.phase) + break + } + + /* ── demo-only markers ─────────────────────────────────── */ + case 'demo-span-open': { + const now = Date.now() + spansRef.current = [ + ...spansRef.current, + { + trace_id: TRACE_ID, + span_id: ev.span.id, + parent_span_id: ev.span.parent, + name: ev.span.name, + kind: ev.span.kind, + service_name: ev.span.service, + start_time_unix_nano: now, + end_time_unix_nano: 0, + status: 'UNSET', + attributes: ev.span.attributes ?? [], + events: [], + links: [], + pending: true, + }, + ] + repaintSpans() + break + } + case 'demo-span-close': { + const now = Date.now() + spansRef.current = spansRef.current.map((s) => + s.span_id === ev.id + ? { + ...s, + /* An explicit duration is what the call really costs; + the demo dwelt longer only so it could be seen. */ + end_time_unix_nano: + ev.durationMs === undefined + ? now + : s.start_time_unix_nano + ev.durationMs, + status: ev.status ?? 'OK', + pending: false, + } + : s, + ) + repaintSpans() + break + } + case 'demo-callout': { + showCallout(ev.callout) + break + } + case 'demo-session-open': { + openChild(ev.session.id, ev.session.title, ev.session.task) + break + } + case 'demo-session-msg': { + appendChild(ev.id, ev.entry) + break + } + case 'demo-session-done': { + finishChild(ev.id, ev.result) + break + } + } + + if ( + ev.kind === 'fcall-start' || + ev.kind === 'assistant-token' || + ev.kind === 'thought-start' + ) { + setTurnPhase(null) + } + } + + if (stale()) return + setIsStreaming(false) + setPhase('done') + + if (loop) { + holdTimer = setTimeout(() => { + if (!stale()) play() + }, HOLD_MS) + } + } + + play() + + return () => { + controller.abort() + clearTimeout(holdTimer) + clearTimeout(calloutTimerRef.current) + gateResolveRef.current = null + } + }, [ + active, + loop, + runKey, + append, + patch, + repaintSpans, + showCallout, + openChild, + appendChild, + finishChild, + ]) + + const lastRole = messages.length + ? messages[messages.length - 1].role + : undefined + const rootThinking = + isStreaming && + (lastRole === 'user' || + (lastRole === 'function-trigger' && + !(messages[messages.length - 1] as FunctionTriggerMessage).running && + !(messages[messages.length - 1] as FunctionTriggerMessage) + .pendingApproval)) + + /* Rebuilt only when a child or the root's status changes β€” not per token, + which is what the transcript re-renders on. */ + const conversations = useMemo( + () => [ + rootConversation( + phase === 'streaming' ? 'working' : phase === 'done' ? 'done' : 'idle', + ), + ...children, + ], + [children, phase], + ) + + const activeChild = + activeId === SESSION_ID + ? null + : (children.find((c) => c.id === activeId) ?? null) + + return { + phase, + messages: activeChild ? activeChild.messages : messages, + typed, + waterfall, + spans, + spanCount, + callout, + isThinking: activeChild ? activeChild.status === 'working' : rootThinking, + thinkingDetail: activeChild + ? 'subagent working…' + : turnPhase === 'accepted' + ? 'turn accepted, step queued…' + : turnPhase === 'started' + ? 'harness::turn started…' + : undefined, + resolveApproval, + conversations, + activeId, + select: setActiveId, + activeChild, + paused, + togglePause, + replay, + runKey, + } +} + +export { SESSION_ID } diff --git a/console/web/src/index.css b/console/web/src/index.css index 6dc3082bb..460848ff1 100644 --- a/console/web/src/index.css +++ b/console/web/src/index.css @@ -331,10 +331,26 @@ animation: timeline-enter 240ms ease-out both; } +/* live-timeline re-packs β€” geometry changes glide instead of teleporting. + Composes with timeline-enter: the entrance animates transform/opacity, + this transitions the box, so a new bar mounts in place and only later + moves smoothly. */ +@utility timeline-glide { + transition: + left 300ms ease, + top 300ms ease, + width 300ms ease, + height 300ms ease, + max-width 300ms ease; +} + @media (prefers-reduced-motion: reduce) { .timeline-enter { animation: none; } + .timeline-glide { + transition: none; + } } @keyframes trace-flash { diff --git a/console/web/src/lib/format-call-duration.ts b/console/web/src/lib/format-call-duration.ts new file mode 100644 index 000000000..8262c3d70 --- /dev/null +++ b/console/web/src/lib/format-call-duration.ts @@ -0,0 +1,14 @@ +/** + * How long a function call took, for the chat surface's card header. + * + * Milliseconds above a millisecond, exactly as before. Below one, the same + * `ΞΌs` the traces surface uses (`pages/TracesV2/lib/traceUtils.ts`): a lot of + * what an agent dispatches is engine-local β€” a trigger registration, a state + * read β€” and finishes in microseconds, where rounding to `0ms` reads like the + * call never happened. + */ +export function formatCallDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0ΞΌs' + if (ms < 1) return `${Math.max(1, Math.round(ms * 1000))}ΞΌs` + return `${Math.round(ms)}ms` +} diff --git a/console/web/src/pages/TracesV2/components/WaterfallChart.tsx b/console/web/src/pages/TracesV2/components/WaterfallChart.tsx index bbb962502..678410875 100644 --- a/console/web/src/pages/TracesV2/components/WaterfallChart.tsx +++ b/console/web/src/pages/TracesV2/components/WaterfallChart.tsx @@ -108,6 +108,12 @@ interface WaterfallChartProps { * only when BOTH this and `spanGroupKey` are provided. */ spanFilter?: SpanFilterControls + /** + * Show expand-all / collapse-all. Pass `false` when the trace is still + * arriving: every change to `data.spans` re-expands the tree, so a + * collapse taken mid-stream is undone by the next span to land. + */ + showExpandControls?: boolean } interface WaterfallRowProps { @@ -338,6 +344,12 @@ function indentKeys(spanId: string, depth: number): string[] { interface ToolbarProps { expandAll: () => void collapseAll: () => void + /** + * Expand-all / collapse-all. Off for a trace that is still arriving: the + * effect below re-expands everything whenever `data.spans` changes, so a + * collapse taken mid-stream is undone by the next span. + */ + showExpandControls: boolean spanGroups: readonly SpanGroup[] workerGroups: readonly SpanGroup[] internalGroups: readonly SpanGroup[] @@ -351,6 +363,7 @@ function Toolbar(props: ToolbarProps) { const { expandAll, collapseAll, + showExpandControls, spanGroups, workerGroups, internalGroups, @@ -362,32 +375,36 @@ function Toolbar(props: ToolbarProps) { return (
- - - - - expand all - - - - - - collapse all - + {showExpandControls ? ( + <> + + + + + expand all + + + + + + collapse all + + + ) : null} {spanFilter && (spanGroups.length > 0 || workerGroups.length > 0 || @@ -427,6 +444,7 @@ export function WaterfallChart({ selectedSpanId, spanGroupKey, spanFilter, + showExpandControls = true, }: WaterfallChartProps) { const [displayState, dispatch] = useReducer( displayReducer, @@ -707,6 +725,7 @@ export function WaterfallChart({ const toolbarProps: ToolbarProps = { expandAll, collapseAll, + showExpandControls, spanGroups, workerGroups, internalGroups, diff --git a/console/web/src/pages/TracesV2/components/timeline/TraceTimeline.tsx b/console/web/src/pages/TracesV2/components/timeline/TraceTimeline.tsx index 386137662..3c9a57f33 100644 --- a/console/web/src/pages/TracesV2/components/timeline/TraceTimeline.tsx +++ b/console/web/src/pages/TracesV2/components/timeline/TraceTimeline.tsx @@ -124,6 +124,9 @@ interface PlacedSpan { interface TraceLayout { placed: PlacedSpan[] lineCount: number + /** each span's packed offset relative to its parent β€” fed back into the + * next `buildLayout` so a streaming re-pack keeps bars on their lines */ + offsets: ReadonlyMap } /** A subtree's footprint: the lines it spans and its full time extent. */ @@ -170,6 +173,12 @@ function rowTop(line: number): number { * stack further down. Rectangles never interleave, which keeps every * subtree a contiguous visual block. * + * Sticky re-packs: when `prev` holds a span's offset from the previous + * layout and the subtree still fits there, it stays β€” a streaming trace + * re-packs on every span, and without the preference a growing sibling + * reshuffles lines that were already settled. Only spans whose remembered + * spot now collides fall back to first-fit. + * * Mutates each child's `info.offset`; returns the deepest occupied * offset+height (i.e. lines consumed), or `firstOffset` when empty. */ @@ -177,25 +186,41 @@ function packSubtrees( children: readonly VisualizationSpan[], info: ReadonlyMap, firstOffset: number, + prev?: ReadonlyMap, ): number { const placed: SubtreeInfo[] = [] let deepest = firstOffset + const collides = (rect: SubtreeInfo, offset: number): boolean => { + for (const other of placed) { + const timeOverlap = + rect.minStart < other.maxEnd && other.minStart < rect.maxEnd + const lineOverlap = + offset < other.offset + other.height && + other.offset < offset + rect.height + if (timeOverlap && lineOverlap) return true + } + return false + } for (const child of children) { const rect = info.get(child.span_id) if (!rect) continue - let offset = firstOffset - let moved = true - while (moved) { - moved = false - for (const other of placed) { - const timeOverlap = - rect.minStart < other.maxEnd && other.minStart < rect.maxEnd - const lineOverlap = - offset < other.offset + other.height && - other.offset < offset + rect.height - if (timeOverlap && lineOverlap) { - offset = other.offset + other.height - moved = true + const kept = prev?.get(child.span_id) + let offset: number + if (kept != null && kept >= firstOffset && !collides(rect, kept)) { + // ponytail: holes a departed subtree leaves behind persist until the + // remembered spot collides; re-compact from scratch if sparse layouts + // ever matter more than stability. + offset = kept + } else { + offset = firstOffset + while (collides(rect, offset)) { + for (const other of placed) { + const timeOverlap = + rect.minStart < other.maxEnd && other.minStart < rect.maxEnd + const lineOverlap = + offset < other.offset + other.height && + other.offset < offset + rect.height + if (timeOverlap && lineOverlap) offset = other.offset + other.height } } } @@ -223,6 +248,7 @@ function packSubtrees( function buildLayout( source: readonly VisualizationSpan[], spans: readonly TimelineSpan[], + prev?: ReadonlyMap, ): TraceLayout { const byId = new Map(spans.map((s) => [s.id, s])) const childrenOf = new Map() @@ -281,11 +307,11 @@ function buildLayout( if (ki.maxEnd > maxEnd) maxEnd = ki.maxEnd } } - const height = kids ? Math.max(1, packSubtrees(kids, info, 1)) : 1 + const height = kids ? Math.max(1, packSubtrees(kids, info, 1, prev)) : 1 info.set(id, { height, minStart, maxEnd, offset: 0 }) } - let lineCount = packSubtrees(roots, info, 0) + let lineCount = packSubtrees(roots, info, 0, prev) // Pre-order: absolute lines, parents emitted before their children. const placed: PlacedSpan[] = [] @@ -326,7 +352,10 @@ function buildLayout( if (span) placed.push({ span, line: lineCount++, parentIndex: null }) } } - return { placed, lineCount } + + const offsets = new Map() + for (const [id, i] of info) offsets.set(id, i.offset) + return { placed, lineCount, offsets } } export function TraceTimeline({ @@ -414,10 +443,14 @@ export function TraceTimeline({ ) const total = Math.max(detail.totalDurationMs, 1) + // A cache, never a dependency: each layout prefers the lines the previous + // one assigned, so a streaming re-pack moves as few bars as possible. + const stickyOffsets = useRef>(new Map()) const layout = useMemo( - () => buildLayout(visibleData.spans, detail.spans), + () => buildLayout(visibleData.spans, detail.spans, stickyOffsets.current), [visibleData.spans, detail.spans], ) + stickyOffsets.current = layout.offsets const innerWidth = Math.max(stage.width - PADDING_X * 2, 0) const pxPerMs = innerWidth > 0 ? innerWidth / total : 0 @@ -680,7 +713,7 @@ export function TraceTimeline({
handleClick(span) : undefined } className={cn( - 'timeline-enter absolute flex items-center gap-1 overflow-hidden rounded-[4px] px-[3px]', + 'timeline-enter timeline-glide absolute flex items-center gap-1 overflow-hidden rounded-[4px] px-[3px]', 'focus-visible:outline-1 focus-visible:outline-accent', onSpanClick ? 'cursor-pointer' : 'cursor-default', )} @@ -773,7 +806,7 @@ export function TraceTimeline({ {spillLabel && (