mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes
- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no tool-accordion). Header derives label from active task; non-active rows fade. - Replace raw JSON dumps with structured key/value summaries via formatToolResultSummary; nested error extraction for clearer failures. - Fix loaded-session grouping: stitch interleaved assistant/tool iterations into one bubble instead of orphaned synthetic messages. - Stable tool/thinking timers via keyed registry so unmount/scroll doesn't reset elapsed counts; gate "running" on real live thread state. - Reorganize chat-only assistant-ui components under components/chat/.
This commit is contained in:
@@ -2,7 +2,7 @@ import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
@@ -786,9 +786,6 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
)
|
||||
}
|
||||
|
||||
const CELL_ACTION_CLASS =
|
||||
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline'
|
||||
|
||||
// Single click target for any row cell. External URLs render as <ExternalLink>;
|
||||
// local actions render as <button>. Padding lives here, NOT on the <td>, so
|
||||
// the entire cell area is hoverable and clickable in both branches.
|
||||
@@ -805,14 +802,27 @@ function ArtifactCellAction({
|
||||
}) {
|
||||
if (href) {
|
||||
return (
|
||||
<ExternalLink className={CELL_ACTION_CLASS} href={href} showExternalIcon={false} title={title}>
|
||||
<ExternalLink
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline"
|
||||
href={href}
|
||||
showExternalIcon={false}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</ExternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={cn(CELL_ACTION_CLASS, 'cursor-pointer')} onClick={onClick} title={title} type="button">
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -50,9 +50,6 @@ import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
const COMPOSER_SHELL_CLASS =
|
||||
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]'
|
||||
|
||||
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
const blobs: Blob[] = []
|
||||
const seen = new Set<Blob>()
|
||||
@@ -111,21 +108,6 @@ function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
const COMPOSER_SCROLLED_DIM_CLASS =
|
||||
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
|
||||
const COMPOSER_FROST_CLASS = cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[var(--dt-card)]',
|
||||
'group-focus-within/composer:[backdrop-filter:none]',
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
)
|
||||
|
||||
const COMPOSER_GLASS = {
|
||||
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
|
||||
liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
|
||||
@@ -921,7 +903,7 @@ export function ChatBar({
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
<ComposerPrimitive.Root
|
||||
className={COMPOSER_SHELL_CLASS}
|
||||
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
|
||||
data-drag-active={dragActive ? '' : undefined}
|
||||
data-slot="composer-root"
|
||||
data-thread-scrolled-up={scrolledUp ? '' : undefined}
|
||||
@@ -955,7 +937,9 @@ export function ChatBar({
|
||||
<div
|
||||
className={cn(
|
||||
'composer-liquid-shell-wrap absolute -inset-px isolate overflow-hidden rounded-[calc(var(--radius-2xl)+1px)] transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-glass-frame="true"
|
||||
data-show-library-rims={COMPOSER_GLASS.showLibraryRims ? 'true' : undefined}
|
||||
@@ -990,7 +974,20 @@ export function ChatBar({
|
||||
data-slot="composer-surface"
|
||||
ref={composerSurfaceRef}
|
||||
>
|
||||
<div aria-hidden className={COMPOSER_FROST_CLASS} />
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[var(--dt-card)]',
|
||||
'group-focus-within/composer:[backdrop-filter:none]',
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
)}
|
||||
/>
|
||||
{dragActive && (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -1002,7 +999,9 @@ export function ChatBar({
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
@@ -1042,11 +1041,27 @@ export function ChatBar({
|
||||
export function ChatBarFallback() {
|
||||
return (
|
||||
<div
|
||||
className={cn(COMPOSER_SHELL_CLASS, 'bg-linear-to-b from-transparent to-background/55')}
|
||||
className={cn(
|
||||
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]',
|
||||
'bg-linear-to-b from-transparent to-background/55'
|
||||
)}
|
||||
data-slot="composer-root"
|
||||
>
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer">
|
||||
<div aria-hidden className={COMPOSER_FROST_CLASS} />
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[var(--dt-card)]',
|
||||
'group-focus-within/composer:[backdrop-filter:none]',
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -50,7 +50,6 @@ export function SessionActionsMenu({
|
||||
align = 'end',
|
||||
sideOffset = 6
|
||||
}: SessionActionsMenuProps) {
|
||||
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -59,7 +58,7 @@ export function SessionActionsMenu({
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
@@ -71,14 +70,14 @@ export function SessionActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
<CopyButton
|
||||
appearance="menu-item"
|
||||
className={itemClass}
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
errorMessage="Could not copy session ID"
|
||||
label="Copy ID"
|
||||
text={sessionId}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
@@ -89,7 +88,7 @@ export function SessionActionsMenu({
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
@@ -101,7 +100,10 @@ export function SessionActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
className={cn(
|
||||
'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4',
|
||||
'text-destructive focus:text-destructive'
|
||||
)}
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
|
||||
@@ -62,6 +62,15 @@ interface QueuedStreamDeltas {
|
||||
|
||||
const STREAM_DELTA_FLUSH_MS = 16
|
||||
|
||||
// Anonymous progress events that carry todos but no name still belong to the
|
||||
// todo stream; named todo events are obviously routed there too.
|
||||
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
|
||||
if (!payload) {return undefined}
|
||||
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
|
||||
|
||||
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
|
||||
}
|
||||
|
||||
export function useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
@@ -552,17 +561,13 @@ export function useMessageStream({
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!sessionId) {return}
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
upsertToolCall(sessionId, payload, 'running')
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
|
||||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, payload, 'complete')
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete')
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
|
||||
@@ -43,9 +43,6 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
items?: readonly StatusbarItem[]
|
||||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -91,7 +88,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(statusbarItemClass, item.className)}
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
@@ -162,7 +162,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a
|
||||
className={cn(statusbarItemClass, item.className)}
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@@ -175,7 +178,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(statusbarItemClass, item.className)}
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.to) {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const ELAPSED_TICK_MS = 1000
|
||||
|
||||
export function formatElapsed(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainder = seconds % 60
|
||||
|
||||
return `${minutes}:${String(remainder).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function useElapsedSeconds(active = true): number {
|
||||
const startedAt = useRef(Date.now())
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setElapsed(Math.max(0, Math.floor((Date.now() - startedAt.current) / 1000)))
|
||||
}
|
||||
|
||||
update()
|
||||
const id = window.setInterval(update, ELAPSED_TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
return elapsed
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// Minimal stubs — attachment upload not wired in the desktop app yet.
|
||||
export const ComposerAddAttachment = () => null
|
||||
export const ComposerAttachments = () => null
|
||||
export const UserMessageAttachments = () => null
|
||||
@@ -5,7 +5,7 @@ import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-
|
||||
import type { FC } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line'] as const
|
||||
@@ -42,8 +42,6 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
|
||||
|
||||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||||
|
||||
const ICON_CLASS = 'size-3 shrink-0 opacity-80'
|
||||
|
||||
const SVG_ATTRS =
|
||||
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
||||
|
||||
@@ -55,12 +53,12 @@ export function directiveIconSvg(type: string) {
|
||||
.map(d => `<path d="${d}"/>`)
|
||||
.join('')
|
||||
|
||||
return `<svg ${SVG_ATTRS} class="${ICON_CLASS}">${inner}</svg>`
|
||||
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
|
||||
}
|
||||
|
||||
export function directiveIconElement(type: string) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', ICON_CLASS)
|
||||
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
|
||||
svg.setAttribute('fill', 'none')
|
||||
svg.setAttribute('stroke', 'currentColor')
|
||||
svg.setAttribute('stroke-linecap', 'round')
|
||||
@@ -80,7 +78,7 @@ export function directiveIconElement(type: string) {
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
<svg
|
||||
className={ICON_CLASS}
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { code } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// Minimal reasoning stubs — not surfaced by the Hermes gateway yet.
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export const ReasoningRoot = ({ children }: { children: ReactNode; defaultOpen?: boolean }) => (
|
||||
<div className="my-1">{children}</div>
|
||||
)
|
||||
export const ReasoningTrigger = (_props: { active?: boolean }) => null
|
||||
export const ReasoningContent = ({ children, 'aria-busy': _busy }: { children: ReactNode; 'aria-busy'?: boolean }) => (
|
||||
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">{children}</div>
|
||||
)
|
||||
export const ReasoningText = ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
export const Reasoning = (_props: object) => null
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -119,6 +119,67 @@ function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
|
||||
running = true
|
||||
): ThreadMessage {
|
||||
const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty'
|
||||
|
||||
return {
|
||||
id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
...(running ? {} : { result: { todos } })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-todo-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
result: { todos }
|
||||
},
|
||||
{ type: 'text', text: 'Done — fake list created.' }
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
@@ -157,6 +218,20 @@ function StreamingHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function TodoHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: message.status?.type === 'running',
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
|
||||
@@ -298,4 +373,69 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(reasoningParts[0]?.textContent).toBe('First thought.')
|
||||
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
|
||||
})
|
||||
|
||||
it('renders live todo rows during a running turn', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
|
||||
{ content: 'Boil water', id: 'boil', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const ui = within(container)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
expect(ui.getByText('Gather ingredients')).toBeTruthy()
|
||||
expect(ui.queryByText(/pending/i)).toBeNull()
|
||||
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders archived todos after turn completion regardless of pending state', () => {
|
||||
const first = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
{ content: 'Boil water', id: 'boil', status: 'pending' }
|
||||
], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const ui = within(first.container)
|
||||
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
|
||||
first.unmount()
|
||||
|
||||
const second = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
{ content: 'Serve latte', id: 'serve', status: 'completed' }
|
||||
], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const archivedUi = within(second.container)
|
||||
|
||||
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantReasoningTodoMessage([
|
||||
{ content: 'Buy oats', id: 'oats', status: 'completed' },
|
||||
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
|
||||
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
|
||||
|
||||
expect(todoPanel).toBeTruthy()
|
||||
expect(thinkingDisclosure).toBeTruthy()
|
||||
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,18 +14,19 @@ import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { DisclosureRow } from '@/components/assistant-ui/disclosure-row'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -354,6 +355,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
@@ -379,6 +381,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-base leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
@@ -458,13 +461,12 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
}
|
||||
|
||||
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
||||
if (props.toolName === 'image_generate') {
|
||||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
// todo parts are hoisted to a dedicated panel above the message content.
|
||||
if (props.toolName === 'todo') {return null}
|
||||
|
||||
if (props.toolName === 'clarify') {
|
||||
return <ClarifyTool {...props} />
|
||||
}
|
||||
if (props.toolName === 'image_generate') {return <ImageGenerateTool {...props} />}
|
||||
|
||||
if (props.toolName === 'clarify') {return <ClarifyTool {...props} />}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
@@ -472,9 +474,10 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
||||
const ThinkingDisclosure: FC<{
|
||||
children: ReactNode
|
||||
pending?: boolean
|
||||
}> = ({ children, pending = false }) => {
|
||||
timerKey?: string
|
||||
}> = ({ children, pending = false, timerKey }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const elapsed = useElapsedSeconds(pending)
|
||||
const elapsed = useElapsedSeconds(pending, timerKey)
|
||||
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure">
|
||||
@@ -503,9 +506,10 @@ const ThinkingDisclosure: FC<{
|
||||
}
|
||||
|
||||
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => {
|
||||
const pending = useAuiState(s => s.message.status?.type === 'running')
|
||||
const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running')
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
|
||||
return <ThinkingDisclosure pending={pending}>{children}</ThinkingDisclosure>
|
||||
return <ThinkingDisclosure pending={pending} timerKey={`reasoning:${messageId}`}>{children}</ThinkingDisclosure>
|
||||
}
|
||||
|
||||
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
@@ -654,13 +658,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className={branchButtonClass}>
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className={branchButtonClass}>
|
||||
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
@@ -668,9 +672,6 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
</div>
|
||||
)
|
||||
|
||||
const branchButtonClass =
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
||||
|
||||
const EMPTY_ATTACHMENT_REFS: string[] = []
|
||||
|
||||
function messageAttachmentRefs(value: unknown): string[] {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function todosFromMessageContent(content: unknown): TodoItem[] {
|
||||
if (!Array.isArray(content)) {return []}
|
||||
|
||||
let latest: null | TodoItem[] = null
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') {continue}
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {continue}
|
||||
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
|
||||
|
||||
if (parsed !== null) {latest = parsed}
|
||||
}
|
||||
|
||||
return latest ?? []
|
||||
}
|
||||
|
||||
const headerLabel = (todos: readonly TodoItem[]): string =>
|
||||
todos.find(t => t.status === 'in_progress')?.content
|
||||
?? todos.find(t => t.status === 'pending')?.content
|
||||
?? todos.at(-1)?.content
|
||||
?? 'Tasks'
|
||||
|
||||
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<span
|
||||
aria-label={`In progress: ${label}`}
|
||||
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin text-ring" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const checked = status === 'completed'
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
|
||||
checked && 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
status === 'cancelled' && 'border-muted-foreground/40'
|
||||
)}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
if (!todos.length) {return null}
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
|
||||
data-slot="aui_todo-hoisted"
|
||||
>
|
||||
<header className="px-3 pt-3 pb-2">
|
||||
<span className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
|
||||
{todos.map(todo => (
|
||||
<li
|
||||
// Active row at full presence; everything else fades. Opacity on
|
||||
// the row so the checkbox glyph dims with the text.
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
|
||||
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
|
||||
)}
|
||||
key={todo.id}
|
||||
>
|
||||
<Checkmark label={todo.content} status={todo.status} />
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">
|
||||
{todo.content}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/assistant-ui/compact-markdown'
|
||||
import { DisclosureRow } from '@/components/assistant-ui/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { normalizeExternalUrl, PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
@@ -26,12 +26,11 @@ import {
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolDisclosureStates, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
const TOOL_DETAIL_INDENT_CLASS = 'w-full pl-(--message-text-indent) pr-2'
|
||||
|
||||
type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
|
||||
@@ -386,52 +385,6 @@ function firstStringField(record: Record<string, unknown>, keys: readonly string
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatScalar(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function summarizeRecord(record: Record<string, unknown>): string {
|
||||
const title = firstStringField(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link'])
|
||||
const subtitle = firstStringField(record, ['url', 'href', 'link', 'status', 'category'])
|
||||
const body = firstStringField(record, ['snippet', 'description', 'summary', 'message', 'preview', 'text', 'content'])
|
||||
|
||||
if (title || subtitle || body) {
|
||||
return cleanVisibleText([title, subtitle !== title ? subtitle : '', body].filter(Boolean).join('\n'))
|
||||
}
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([key, value]) => {
|
||||
const scalar = formatScalar(value)
|
||||
|
||||
return scalar ? `${titleForTool(key)}: ${compactPreview(cleanVisibleText(scalar), 96)}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeList(items: unknown[], max = 5): string {
|
||||
return items
|
||||
.map(item => {
|
||||
if (isRecord(item) || (typeof item === 'string' && item.trim().startsWith('{'))) {
|
||||
return summarizeRecord(parseMaybeObject(item))
|
||||
}
|
||||
|
||||
return compactPreview(item, 140)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, max)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function collectResultItems(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -460,46 +413,6 @@ function collectResultItems(value: unknown): unknown[] {
|
||||
return payload === record ? [] : collectResultItems(payload)
|
||||
}
|
||||
|
||||
function friendlyJsonSummary(value: unknown, depth = 0): string {
|
||||
if (depth > 2) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return summarizeList(value)
|
||||
}
|
||||
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
if (!Object.keys(record).length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const direct = firstStringField(record, ['message', 'summary', 'preview', 'content', 'text', 'stdout', 'stderr', 'error'])
|
||||
|
||||
if (direct) {
|
||||
return cleanVisibleText(direct)
|
||||
}
|
||||
|
||||
const items = collectResultItems(record)
|
||||
|
||||
if (items.length) {
|
||||
return summarizeList(items)
|
||||
}
|
||||
|
||||
const payload = unwrapToolPayload(record)
|
||||
|
||||
if (payload !== value && payload !== record) {
|
||||
const payloadSummary = friendlyJsonSummary(payload, depth + 1)
|
||||
|
||||
if (payloadSummary) {
|
||||
return payloadSummary
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
|
||||
const list = collectResultItems(result)
|
||||
|
||||
@@ -518,16 +431,26 @@ function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
|
||||
}
|
||||
|
||||
function toolErrorText(part: ToolPart, result: Record<string, unknown>): string {
|
||||
const extractedError = extractToolErrorMessage(part.result)
|
||||
|
||||
if (part.isError) {
|
||||
return 'Tool returned an error.'
|
||||
return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.'
|
||||
}
|
||||
|
||||
if (typeof result.error === 'string' && result.error.trim()) {
|
||||
return result.error.trim()
|
||||
}
|
||||
|
||||
if (result.success === false) {
|
||||
return firstStringField(result, ['message', 'reason']) || 'Tool returned success=false.'
|
||||
if (extractedError) {
|
||||
return extractedError
|
||||
}
|
||||
|
||||
if (result.success === false || result.ok === false) {
|
||||
return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.'
|
||||
}
|
||||
|
||||
if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) {
|
||||
return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
|
||||
}
|
||||
|
||||
const exit = numberValue(result.exit_code)
|
||||
@@ -630,6 +553,32 @@ function inlineDiffFromResult(result: unknown): string {
|
||||
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
|
||||
}
|
||||
|
||||
function minimalValueSummary(value: unknown): string {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? `Returned ${value.length} items.` : 'No items returned.'
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const count = Object.keys(value).length
|
||||
|
||||
return count ? `Returned object with ${count} fields.` : 'Returned an empty object.'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function fallbackDetailText(args: unknown, result: unknown): string {
|
||||
const argContext = contextValue(args)
|
||||
const resultContext = contextValue(result)
|
||||
@@ -643,10 +592,10 @@ function fallbackDetailText(args: unknown, result: unknown): string {
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
return friendlyJsonSummary(result) || prettyJson(result)
|
||||
return formatToolResultSummary(result) || minimalValueSummary(result)
|
||||
}
|
||||
|
||||
return friendlyJsonSummary(args) || prettyJson(args)
|
||||
return formatToolResultSummary(args) || minimalValueSummary(args)
|
||||
}
|
||||
|
||||
function toolSubtitle(
|
||||
@@ -743,7 +692,7 @@ function toolSubtitle(
|
||||
}
|
||||
|
||||
return (
|
||||
compactPreview(friendlyJsonSummary(part.result), 120) ||
|
||||
compactPreview(formatToolResultSummary(part.result), 120) ||
|
||||
compactPreview(resultRecord, 120) ||
|
||||
compactPreview(argsRecord, 120) ||
|
||||
fallbackDetailText(argsRecord, resultRecord)
|
||||
@@ -1011,15 +960,14 @@ function isToolPart(part: unknown): part is ToolPart {
|
||||
}
|
||||
|
||||
function groupToolParts(content: unknown): ToolPart[][] {
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(content)) {return []}
|
||||
|
||||
const groups: ToolPart[][] = []
|
||||
let current: ToolPart[] = []
|
||||
|
||||
for (const part of content) {
|
||||
if (isToolPart(part)) {
|
||||
// todo parts render in their own hoisted panel; skip from grouped tools.
|
||||
if (isToolPart(part) && part.toolName !== 'todo') {
|
||||
current.push(part)
|
||||
|
||||
continue
|
||||
@@ -1031,9 +979,7 @@ function groupToolParts(content: unknown): ToolPart[][] {
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length) {
|
||||
groups.push(current)
|
||||
}
|
||||
if (current.length) {groups.push(current)}
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -1134,17 +1080,25 @@ interface ToolEntryProps {
|
||||
}
|
||||
|
||||
function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
const isPending = part.result === undefined
|
||||
const elapsed = useElapsedSeconds(isPending)
|
||||
const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running')
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const disclosureStates = useStore($toolDisclosureStates)
|
||||
const disclosureId = toolPartDisclosureId(part)
|
||||
const isPending = messageRunning && part.result === undefined
|
||||
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
|
||||
const open = disclosureStates[disclosureId] ?? false
|
||||
const preview = compactPreview(part.args) || compactPreview(part.result)
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
|
||||
const view = useMemo(() => buildToolView(part, inlineDiff), [inlineDiff, part])
|
||||
|
||||
// Stale parts (no result, but message stopped running) get a synthetic
|
||||
// empty result so buildToolView treats them as completed-no-output.
|
||||
const view = useMemo(() => {
|
||||
const p = !isPending && part.result === undefined ? { ...part, result: {} } : part
|
||||
|
||||
return buildToolView(p, inlineDiff)
|
||||
}, [inlineDiff, isPending, part])
|
||||
|
||||
const detailSections = useMemo(() => {
|
||||
if (!view.detail) {
|
||||
@@ -1261,7 +1215,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
))}
|
||||
</DisclosureRow>
|
||||
{open && (
|
||||
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden pb-2')}>
|
||||
<div className={cn('w-full pl-(--message-text-indent) pr-2', 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden pb-2')}>
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
@@ -1359,7 +1313,8 @@ function groupPreviewTargets(parts: ToolPart[]): string[] {
|
||||
}
|
||||
|
||||
function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
const isRunning = parts.some(part => part.result === undefined)
|
||||
const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running')
|
||||
const isRunning = messageRunning && parts.some(part => part.result === undefined)
|
||||
const disclosureStates = useStore($toolDisclosureStates)
|
||||
const disclosureId = toolGroupDisclosureId(parts)
|
||||
const open = disclosureStates[disclosureId] ?? isRunning
|
||||
@@ -1374,6 +1329,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
}, [disclosureId, isRunning])
|
||||
|
||||
const status = groupStatus(parts)
|
||||
const displayStatus = !isRunning && status === 'running' ? 'warning' : status
|
||||
|
||||
const failedStepCount = useMemo(
|
||||
() => parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length,
|
||||
@@ -1395,9 +1351,9 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
}, [parts])
|
||||
|
||||
const statusSummary =
|
||||
status === 'running' || failedStepCount === 0
|
||||
displayStatus === 'running' || failedStepCount === 0
|
||||
? ''
|
||||
: status === 'warning'
|
||||
: displayStatus === 'warning'
|
||||
? failedStepCount === 1
|
||||
? 'Recovered after 1 failed step'
|
||||
: `Recovered after ${failedStepCount} failed steps`
|
||||
@@ -1430,7 +1386,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
.join('\n\n')
|
||||
}, [parts])
|
||||
|
||||
const showGroupStatusGlyph = status !== 'success'
|
||||
const showGroupStatusGlyph = displayStatus !== 'success'
|
||||
const previewTargets = useMemo(() => groupPreviewTargets(parts), [parts])
|
||||
|
||||
return (
|
||||
@@ -1446,13 +1402,13 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
{showGroupStatusGlyph && (
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(status)}</span>
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(displayStatus)}</span>
|
||||
)}
|
||||
<FadeText
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
|
||||
status === 'error' && 'text-destructive',
|
||||
status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
displayStatus === 'error' && 'text-destructive',
|
||||
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{groupTitle(parts)}
|
||||
@@ -1472,7 +1428,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
<FadeText
|
||||
className={cn(
|
||||
'text-[0.68rem] leading-[1.05rem]',
|
||||
status === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
|
||||
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
|
||||
)}
|
||||
>
|
||||
{statusSummary}
|
||||
@@ -1480,14 +1436,14 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
)}
|
||||
</DisclosureRow>
|
||||
{previewTargets.length > 0 && (
|
||||
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden')}>
|
||||
<div className={cn('w-full pl-(--message-text-indent) pr-2', 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden')}>
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} source="tool-result" target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-0.5 min-w-0 max-w-full overflow-hidden')}>
|
||||
<div className={cn('w-full pl-(--message-text-indent) pr-2', 'mt-0.5 min-w-0 max-w-full overflow-hidden')}>
|
||||
{parts.map(part => (
|
||||
<ToolEntry embedded key={part.toolCallId || `${part.toolName}-${JSON.stringify(part.args)}`} part={part} />
|
||||
))}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export const ToolGroupRoot = ({ children }: { children: ReactNode }) => (
|
||||
<div className="my-2 flex flex-col gap-1">{children}</div>
|
||||
)
|
||||
export const ToolGroupTrigger = (_props: { count?: number; active?: boolean }) => null
|
||||
export const ToolGroupContent = ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer'
|
||||
|
||||
function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) {
|
||||
const elapsed = useElapsedSeconds(active, timerKey)
|
||||
|
||||
return <span data-testid="elapsed">{elapsed}</span>
|
||||
}
|
||||
|
||||
describe('useElapsedSeconds', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
|
||||
__resetElapsedTimerRegistryForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
__resetElapsedTimerRegistryForTests()
|
||||
})
|
||||
|
||||
it('keeps elapsed time stable across remounts for the same key', () => {
|
||||
const first = render(<Probe active timerKey="tool:abc" />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('elapsed').textContent).toBe('5')
|
||||
|
||||
first.unmount()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3_000)
|
||||
})
|
||||
|
||||
render(<Probe active timerKey="tool:abc" />)
|
||||
|
||||
expect(screen.getByTestId('elapsed').textContent).toBe('8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Module-level registry so timers survive component unmount/remount (e.g.
|
||||
// when a tool row scrolls out and back). Keyed by caller-supplied timerKey;
|
||||
// anonymous timers (no key) start fresh each mount.
|
||||
const startedAtByKey = new Map<string, number>()
|
||||
|
||||
function startedAt(key?: string): number {
|
||||
if (!key) {return Date.now()}
|
||||
const existing = startedAtByKey.get(key)
|
||||
|
||||
if (existing !== undefined) {return existing}
|
||||
const now = Date.now()
|
||||
startedAtByKey.set(key, now)
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
export function formatElapsed(seconds: number): string {
|
||||
if (seconds < 60) {return `${seconds}s`}
|
||||
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function useElapsedSeconds(active = true, timerKey?: string): number {
|
||||
const start = useRef(startedAt(timerKey))
|
||||
const lastKey = useRef(timerKey)
|
||||
const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
|
||||
|
||||
if (lastKey.current !== timerKey) {
|
||||
start.current = startedAt(timerKey)
|
||||
lastKey.current = timerKey
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {return}
|
||||
|
||||
if (timerKey) {start.current = startedAt(timerKey)}
|
||||
|
||||
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
|
||||
tick()
|
||||
const id = window.setInterval(tick, 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active, timerKey])
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
export function __resetElapsedTimerRegistryForTests() {
|
||||
startedAtByKey.clear()
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import { Skeleton } from './ui/skeleton'
|
||||
|
||||
const pickerPanelClass = 'max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0'
|
||||
|
||||
interface ModelPickerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -73,7 +71,7 @@ export function ModelPickerDialog({
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className={pickerPanelClass}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-4 py-3">
|
||||
<DialogTitle>Switch model</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs leading-relaxed">
|
||||
|
||||
@@ -26,6 +26,46 @@ describe('toChatMessages', () => {
|
||||
expect(chatMessageText(messages[0])).toBe('Planning.Done.')
|
||||
})
|
||||
|
||||
it('keeps assistant tool-call iterations in one loaded assistant bubble', () => {
|
||||
const messages = toChatMessages([
|
||||
{ role: 'user', content: 'check this repo', timestamp: 1 },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: "Let me also check if there's a top-level lint workflow.",
|
||||
timestamp: 2,
|
||||
tool_calls: [{ id: 'tc-1', function: { name: 'search_files', arguments: '{"path":".github"}' } }]
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc-1',
|
||||
tool_name: 'search_files',
|
||||
content: '{"error":"Path not found: /repo/.github"}',
|
||||
timestamp: 3
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'No CI in this repo. Build is enough.',
|
||||
timestamp: 4,
|
||||
tool_calls: [{ id: 'tc-2', function: { name: 'terminal', arguments: '{"command":"git status --short"}' } }]
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc-2',
|
||||
tool_name: 'terminal',
|
||||
content: '{"output":"M src/ui/components/image-distortion.tsx\\n","exit_code":0}',
|
||||
timestamp: 5
|
||||
},
|
||||
{ role: 'assistant', content: 'Now let me check git status and commit.', timestamp: 6 }
|
||||
])
|
||||
|
||||
const assistantMessages = messages.filter(message => message.role === 'assistant')
|
||||
|
||||
expect(assistantMessages).toHaveLength(1)
|
||||
expect(assistantMessages[0].parts.filter(part => part.type === 'tool-call')).toHaveLength(2)
|
||||
expect(chatMessageText(assistantMessages[0])).toContain("Let me also check if there's a top-level lint workflow.")
|
||||
expect(chatMessageText(assistantMessages[0])).toContain('Now let me check git status and commit.')
|
||||
})
|
||||
|
||||
it('hides attached context payloads from user message display', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
@@ -120,4 +160,68 @@ describe('upsertToolPart', () => {
|
||||
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps live todo rows stable across sparse progress payloads', () => {
|
||||
const first = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
name: 'todo',
|
||||
todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }],
|
||||
tool_id: 'todo-1'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressed = upsertToolPart(
|
||||
first,
|
||||
{
|
||||
name: 'todo',
|
||||
preview: 'updating plan',
|
||||
tool_id: 'todo-1'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const [part] = progressed
|
||||
const args = part && 'args' in part ? (part.args as Record<string, unknown>) : {}
|
||||
|
||||
expect(args.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
|
||||
})
|
||||
|
||||
it('archives todo state on completion and accepts explicit empty clears', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
name: 'todo',
|
||||
todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }],
|
||||
tool_id: 'todo-1'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
name: 'todo',
|
||||
tool_id: 'todo-1'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const cleared = upsertToolPart(
|
||||
completed,
|
||||
{
|
||||
name: 'todo',
|
||||
todos: [],
|
||||
tool_id: 'todo-1'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
|
||||
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
|
||||
|
||||
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
|
||||
expect(clearedResult.todos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
|
||||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import type { SessionMessage, UsageStats } from '@/types/hermes'
|
||||
|
||||
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
||||
@@ -211,21 +212,42 @@ function toolId(payload: GatewayEventPayload | undefined): string {
|
||||
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
|
||||
}
|
||||
|
||||
function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
// Carry todo state across sparse progress payloads: if this todo event lacks
|
||||
// a `todos` field, fall back to whatever we previously stored on the part.
|
||||
function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]): { todos: unknown } | undefined {
|
||||
if (payload && Object.hasOwn(payload, 'todos')) {
|
||||
const next = parseTodos(payload.todos)
|
||||
|
||||
return next === null ? undefined : { todos: next }
|
||||
}
|
||||
|
||||
if (payload?.name !== 'todo') {return undefined}
|
||||
|
||||
for (const p of prev) {
|
||||
const carried = parseTodos(recordFromUnknown(p)?.todos)
|
||||
|
||||
if (carried !== null) {return { todos: carried }}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
|
||||
return {
|
||||
...(payload?.context ? { context: payload.context } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {})
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
...carryTodos(payload, prevArgs)
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
|
||||
return {
|
||||
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
|
||||
...(payload?.summary ? { summary: payload.summary } : {}),
|
||||
...(payload?.message ? { message: payload.message } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
|
||||
...(payload?.todos ? { todos: payload.todos } : {}),
|
||||
...carryTodos(payload, prevResult, prevArgs),
|
||||
...(payload?.error ? { error: payload.error } : {})
|
||||
}
|
||||
}
|
||||
@@ -243,24 +265,21 @@ export function upsertToolPart(
|
||||
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
|
||||
)
|
||||
|
||||
const prev = index >= 0 ? next[index] : null
|
||||
const prevArgs = prev && 'args' in prev ? prev.args : undefined
|
||||
const prevResult = prev && 'result' in prev ? prev.result : undefined
|
||||
const args = toolArgs(payload, prevArgs)
|
||||
|
||||
const base = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: id,
|
||||
toolName: name,
|
||||
args: toolArgs(payload) as never,
|
||||
argsText: JSON.stringify(toolArgs(payload)),
|
||||
...(phase === 'complete'
|
||||
? {
|
||||
result: toolResult(payload),
|
||||
isError: Boolean(payload?.error)
|
||||
}
|
||||
: {})
|
||||
args: args as never,
|
||||
argsText: JSON.stringify(args),
|
||||
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
|
||||
} satisfies ChatMessagePart
|
||||
|
||||
if (index === -1) {
|
||||
return [...next, base]
|
||||
}
|
||||
|
||||
if (index === -1) {return [...next, base]}
|
||||
next[index] = { ...next[index], ...base }
|
||||
|
||||
return next
|
||||
@@ -457,20 +476,48 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
const result: ChatMessage[] = []
|
||||
let pendingToolParts: ChatMessagePart[] = []
|
||||
let pendingToolTimestamp: number | undefined
|
||||
let activeAssistantIndex: null | number = null
|
||||
|
||||
const clearPendingTools = () => {
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
}
|
||||
|
||||
const appendPartsToActiveAssistant = (parts: ChatMessagePart[], timestamp?: number): boolean => {
|
||||
if (activeAssistantIndex === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const active = result[activeAssistantIndex]
|
||||
|
||||
if (!active || active.role !== 'assistant') {
|
||||
activeAssistantIndex = null
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
active.parts = [...active.parts, ...parts]
|
||||
active.timestamp = timestamp ?? active.timestamp
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const flushPendingTools = (index: number) => {
|
||||
if (!pendingToolParts.length) {
|
||||
return
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
|
||||
role: 'assistant',
|
||||
parts: pendingToolParts,
|
||||
timestamp: pendingToolTimestamp
|
||||
})
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
if (!appendPartsToActiveAssistant(pendingToolParts, pendingToolTimestamp)) {
|
||||
result.push({
|
||||
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
|
||||
role: 'assistant',
|
||||
parts: pendingToolParts,
|
||||
timestamp: pendingToolTimestamp
|
||||
})
|
||||
activeAssistantIndex = result.length - 1
|
||||
}
|
||||
|
||||
clearPendingTools()
|
||||
}
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
@@ -515,6 +562,11 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
if (message.role !== 'assistant') {
|
||||
flushPendingTools(index)
|
||||
activeAssistantIndex = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -528,22 +580,30 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && pendingToolParts.length) {
|
||||
const last = result.at(-1)
|
||||
if (message.role === 'assistant') {
|
||||
if (pendingToolParts.length) {
|
||||
if (!appendPartsToActiveAssistant(pendingToolParts, message.timestamp ?? pendingToolTimestamp)) {
|
||||
parts.unshift(...pendingToolParts)
|
||||
}
|
||||
|
||||
if (last?.role === 'assistant') {
|
||||
last.parts = [...last.parts, ...pendingToolParts, ...parts]
|
||||
last.timestamp = message.timestamp ?? last.timestamp
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
clearPendingTools()
|
||||
}
|
||||
|
||||
const activeAssistant =
|
||||
activeAssistantIndex !== null && result[activeAssistantIndex]?.role === 'assistant'
|
||||
? result[activeAssistantIndex]
|
||||
: null
|
||||
|
||||
const currentHasToolCall = parts.some(part => part.type === 'tool-call')
|
||||
const activeHasToolCall = Boolean(activeAssistant?.parts.some(part => part.type === 'tool-call'))
|
||||
|
||||
if (activeAssistant && (currentHasToolCall || activeHasToolCall)) {
|
||||
activeAssistant.parts = [...activeAssistant.parts, ...parts]
|
||||
activeAssistant.timestamp = message.timestamp ?? activeAssistant.timestamp
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
parts.unshift(...pendingToolParts)
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
} else if (message.role !== 'assistant') {
|
||||
} else {
|
||||
flushPendingTools(index)
|
||||
}
|
||||
|
||||
@@ -553,6 +613,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
parts,
|
||||
timestamp: message.timestamp
|
||||
})
|
||||
|
||||
activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null
|
||||
})
|
||||
flushPendingTools(messages.length)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseTodos } from './todos'
|
||||
|
||||
describe('parseTodos', () => {
|
||||
it('parses todo arrays with valid ids, content, and statuses', () => {
|
||||
expect(
|
||||
parseTodos([
|
||||
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
|
||||
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
|
||||
{ content: 'Serve', id: 'serve', status: 'pending' }
|
||||
])
|
||||
).toEqual([
|
||||
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
|
||||
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
|
||||
{ content: 'Serve', id: 'serve', status: 'pending' }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses nested todo payloads from wrapped objects and JSON strings', () => {
|
||||
expect(parseTodos({ todos: [{ content: 'Plate', id: 'plate', status: 'pending' }] })).toEqual([
|
||||
{ content: 'Plate', id: 'plate', status: 'pending' }
|
||||
])
|
||||
|
||||
expect(parseTodos('{"todos":[{"id":"plate","content":"Plate","status":"pending"}]}')).toEqual([
|
||||
{ content: 'Plate', id: 'plate', status: 'pending' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null for non-todo payloads', () => {
|
||||
expect(parseTodos(undefined)).toBeNull()
|
||||
expect(parseTodos('not json')).toBeNull()
|
||||
expect(parseTodos({ message: 'no todos here' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
|
||||
export interface TodoItem {
|
||||
content: string
|
||||
id: string
|
||||
status: TodoStatus
|
||||
}
|
||||
|
||||
const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled']
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === 'object' && !Array.isArray(v))
|
||||
const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string)
|
||||
|
||||
function parseArray(value: unknown[]): TodoItem[] {
|
||||
return value.flatMap(item => {
|
||||
if (!isRecord(item) || !isStatus(item.status)) {return []}
|
||||
const id = String(item.id ?? '').trim()
|
||||
const content = String(item.content ?? '').trim()
|
||||
|
||||
return id && content ? [{ content, id, status: item.status }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function parse(value: unknown, depth: number): null | TodoItem[] {
|
||||
if (depth > 2) {return null}
|
||||
|
||||
if (Array.isArray(value)) {return parseArray(value)}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try { return parse(JSON.parse(value), depth + 1) } catch { return null }
|
||||
}
|
||||
|
||||
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractToolErrorMessage, formatToolResultSummary } from './tool-result-summary'
|
||||
|
||||
describe('formatToolResultSummary', () => {
|
||||
it('unwraps wrapper payloads into structured key-value lines', () => {
|
||||
const summary = formatToolResultSummary({
|
||||
success: true,
|
||||
result: {
|
||||
data: {
|
||||
path: '/tmp/demo.txt',
|
||||
status: 'ok',
|
||||
lines_written: 12,
|
||||
checksum: 'abc123'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(summary).toContain('- Path: /tmp/demo.txt')
|
||||
expect(summary).toContain('- Status: ok')
|
||||
expect(summary).toContain('- Lines Written: 12')
|
||||
expect(summary).not.toContain('"path"')
|
||||
})
|
||||
|
||||
it('summarizes object arrays as readable list items', () => {
|
||||
const summary = formatToolResultSummary([
|
||||
{ title: 'First result', snippet: 'alpha preview text' },
|
||||
{ title: 'Second result', status: 'cached' },
|
||||
{ title: 'Third result', summary: 'more details' },
|
||||
{ title: 'Fourth result', summary: 'line 4' },
|
||||
{ title: 'Fifth result', summary: 'line 5' },
|
||||
{ title: 'Sixth result', summary: 'line 6' },
|
||||
{ title: 'Seventh result', summary: 'line 7' }
|
||||
])
|
||||
|
||||
expect(summary).toContain('- First result - alpha preview text')
|
||||
expect(summary).toContain('- Second result (cached)')
|
||||
expect(summary).toContain('- … 1 more item')
|
||||
})
|
||||
|
||||
it('truncates long field values for compact display', () => {
|
||||
const summary = formatToolResultSummary({
|
||||
message: 'ok',
|
||||
details: `prefix ${'x'.repeat(500)}`
|
||||
})
|
||||
|
||||
const detailsLine = summary
|
||||
.split('\n')
|
||||
.find(line => line.startsWith('- Details:'))
|
||||
|
||||
expect(detailsLine).toBeTruthy()
|
||||
expect(detailsLine?.length).toBeLessThan(230)
|
||||
expect(detailsLine).toContain('…')
|
||||
})
|
||||
|
||||
it('formats stringified json payloads without raw dumps', () => {
|
||||
const summary = formatToolResultSummary(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
title: 'Build report',
|
||||
completed: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary).toContain('- Title: Build report')
|
||||
expect(summary).toContain('- Completed: true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractToolErrorMessage', () => {
|
||||
it('finds nested error messages through wrappers', () => {
|
||||
const error = extractToolErrorMessage({
|
||||
success: false,
|
||||
result: {
|
||||
output: {
|
||||
error: {
|
||||
message: 'Permission denied writing /tmp/demo.txt'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBe('Permission denied writing /tmp/demo.txt')
|
||||
})
|
||||
|
||||
it('does not treat successful payload messages as errors', () => {
|
||||
const error = extractToolErrorMessage({
|
||||
success: true,
|
||||
message: 'Completed successfully',
|
||||
data: { count: 3 }
|
||||
})
|
||||
|
||||
expect(error).toBe('')
|
||||
})
|
||||
|
||||
it('ignores placeholder error fields in successful payloads', () => {
|
||||
const error = extractToolErrorMessage({
|
||||
success: true,
|
||||
data: {
|
||||
error: 'none',
|
||||
status: 'ok'
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
// Heuristic JSON → human summary for tool results. Default view; technical
|
||||
// mode still gets the raw JSON section.
|
||||
|
||||
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
|
||||
const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
|
||||
type Json = Record<string, unknown>
|
||||
|
||||
const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v))
|
||||
|
||||
function tryJson(value: string): unknown {
|
||||
const t = value.trim()
|
||||
|
||||
if (!t) {return ''}
|
||||
|
||||
if (!/^[{[]|^"/.test(t)) {return value}
|
||||
|
||||
try { return JSON.parse(t) } catch { return value }
|
||||
}
|
||||
|
||||
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
|
||||
|
||||
const titleCase = (k: string) =>
|
||||
k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
|
||||
|
||||
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
|
||||
|
||||
function clipInline(value: string, max = 180): string {
|
||||
const c = value.replace(/\s+/g, ' ').trim()
|
||||
|
||||
return c.length > max ? `${c.slice(0, max - 1)}…` : c
|
||||
}
|
||||
|
||||
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
|
||||
const t = value.trim()
|
||||
|
||||
if (!t) {return ''}
|
||||
const lines = t.split('\n')
|
||||
let text = lines.slice(0, maxLines).join('\n')
|
||||
const clipped = lines.length > maxLines || text.length > maxChars
|
||||
|
||||
if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
|
||||
|
||||
return clipped && !text.endsWith('…') ? `${text}…` : text
|
||||
}
|
||||
|
||||
function firstString(record: Json, keys: readonly string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = record[k]
|
||||
|
||||
if (typeof v === 'string' && v.trim()) {return v.trim()}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function orderedKeys(keys: string[]): string[] {
|
||||
const priority = PRIORITY_KEYS.filter(k => keys.includes(k))
|
||||
const rest = keys.filter(k => !priority.includes(k as never))
|
||||
|
||||
return [...priority, ...rest]
|
||||
}
|
||||
|
||||
const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k)
|
||||
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
|
||||
|
||||
function summarizeScalar(v: unknown): string {
|
||||
if (typeof v === 'string') {return clipInline(v)}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function summarizeRecordInline(record: Json, depth: number): string {
|
||||
if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
|
||||
|
||||
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
|
||||
const status = firstString(record, ['status', 'category', 'type'])
|
||||
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
|
||||
|
||||
if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
|
||||
|
||||
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
|
||||
|
||||
if (title) {return clipInline(title, 150)}
|
||||
|
||||
const pairs = orderedKeys(Object.keys(record))
|
||||
.filter(k => !skipField(k, record[k]))
|
||||
.map(k => {
|
||||
const s = summarizeScalar(record[k])
|
||||
|
||||
return s ? `${titleCase(k)}: ${s}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
|
||||
return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field')
|
||||
}
|
||||
|
||||
function summarizeListItem(item: unknown, depth: number): string {
|
||||
const v = norm(item)
|
||||
|
||||
if (typeof v === 'string') {return clipInline(v)}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
|
||||
if (v == null) {return ''}
|
||||
|
||||
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
|
||||
|
||||
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
|
||||
function formatFieldValue(value: unknown, depth: number): string {
|
||||
const v = norm(value)
|
||||
const scalar = summarizeScalar(v)
|
||||
|
||||
if (scalar) {return scalar}
|
||||
|
||||
if (v == null) {return ''}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
if (!v.length) {return '0 items'}
|
||||
const scalars = v.map(summarizeScalar).filter(Boolean)
|
||||
|
||||
if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
|
||||
const first = summarizeListItem(v[0], depth + 1)
|
||||
|
||||
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
|
||||
function formatArraySummary(value: unknown[], depth: number): string {
|
||||
if (!value.length) {return 'No items returned.'}
|
||||
|
||||
const max = 6
|
||||
const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
|
||||
|
||||
if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
|
||||
|
||||
if (value.length > max) {
|
||||
const remaining = value.length - max
|
||||
lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatRecordSummary(record: Json, depth: number): string {
|
||||
const keys = Object.keys(record)
|
||||
|
||||
if (!keys.length) {return 'Returned an empty object.'}
|
||||
|
||||
if (depth <= 2) {
|
||||
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
|
||||
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
|
||||
|
||||
if (direct && meaningful.length <= 1) {return clipBlock(direct)}
|
||||
}
|
||||
|
||||
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
|
||||
const max = 8
|
||||
const lines: string[] = []
|
||||
|
||||
for (const k of candidates) {
|
||||
const v = formatFieldValue(record[k], depth + 1)
|
||||
|
||||
if (!v) {continue}
|
||||
lines.push(`- ${titleCase(k)}: ${v}`)
|
||||
|
||||
if (lines.length >= max) {break}
|
||||
}
|
||||
|
||||
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
|
||||
|
||||
if (candidates.length > lines.length) {
|
||||
const remaining = candidates.length - lines.length
|
||||
lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatSummaryValue(value: unknown, depth: number): string {
|
||||
if (depth > 4) {return ''}
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {return clipBlock(v)}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
|
||||
if (v == null) {return ''}
|
||||
|
||||
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
|
||||
|
||||
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
|
||||
function unwrapPayload(value: unknown): unknown {
|
||||
let cur: unknown = norm(value)
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
if (!isRecord(cur)) {
|
||||
return cur
|
||||
}
|
||||
|
||||
const record = cur
|
||||
const key = WRAPPER_KEYS.find(k => record[k] != null)
|
||||
|
||||
if (!key) {
|
||||
return record
|
||||
}
|
||||
|
||||
cur = norm(record[key])
|
||||
}
|
||||
|
||||
return cur
|
||||
}
|
||||
|
||||
function hasMeaningfulErrorValue(value: unknown): boolean {
|
||||
const v = norm(value)
|
||||
|
||||
if (v == null) {return false}
|
||||
|
||||
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
|
||||
|
||||
if (typeof v === 'boolean') {return v}
|
||||
|
||||
if (typeof v === 'number') {return v !== 0}
|
||||
|
||||
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
|
||||
|
||||
if (isRecord(v)) {return Object.keys(v).length > 0}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function hasErrorSignal(record: Json): boolean {
|
||||
const status = typeof record.status === 'string' ? record.status : ''
|
||||
|
||||
return (
|
||||
record.success === false ||
|
||||
record.ok === false ||
|
||||
/\b(error|failed|failure|fatal|exception)\b/i.test(status) ||
|
||||
ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k]))
|
||||
)
|
||||
}
|
||||
|
||||
function valueErrorText(value: unknown): string {
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
|
||||
}
|
||||
|
||||
if (isRecord(v)) {
|
||||
const direct = firstString(v, ERROR_MSG_KEYS)
|
||||
|
||||
if (direct) {return clipBlock(direct, 700, 12)}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
|
||||
if (depth > 5) {return ''}
|
||||
const v = norm(value)
|
||||
|
||||
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
|
||||
seen.add(v)
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) {
|
||||
const nested = findNestedError(item, depth + 1, seen)
|
||||
|
||||
if (nested) {return nested}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const record = v as Json
|
||||
|
||||
for (const k of ERROR_KEYS) {
|
||||
if (!hasMeaningfulErrorValue(record[k])) {continue}
|
||||
const text = valueErrorText(record[k])
|
||||
|
||||
if (text) {return text}
|
||||
}
|
||||
|
||||
if (hasErrorSignal(record)) {
|
||||
const direct = firstString(record, ERROR_MSG_KEYS)
|
||||
|
||||
if (direct) {return clipBlock(direct, 700, 12)}
|
||||
}
|
||||
|
||||
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
|
||||
const nested = findNestedError(record[k], depth + 1, seen)
|
||||
|
||||
if (nested) {return nested}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatToolResultSummary(value: unknown): string {
|
||||
return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0)
|
||||
}
|
||||
|
||||
export function extractToolErrorMessage(value: unknown): string {
|
||||
return findNestedError(value, 0, new Set())
|
||||
}
|
||||
Reference in New Issue
Block a user