feat(desktop): reconcile live tool events, polish thread chrome, harden boot
- chat-messages: match tool rows by overlapping query/context/preview values so preview-first `tool.progress` rows reliably adopt later stable-id `tool.start` payloads instead of spawning ghost rows or mis-merging parallel same-name calls; preserve prior args/result across phases. - tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`, drop redundant `tool.started` re-emit from `tool.progress`. - electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so local backend edits actually run; split hardening helpers into `electron/hardening.cjs` with tests. - thread/tool UI: one-shot enter animation keyed by stable ids, braille spinner for running rows, Cursor-like disclosure rows, drill-down + duration/count formatting via new tool-fallback-model. - composer: extract `text-utils`, drop liquid-glass overrides. - right-rail: split preview-pane into preview-console / preview-file. - runtime: incremental external-store runtime + runtime-readiness gate; onboarding store + tests; route-resume hook test. - regression tests for live tool reconciliation (parallel tools, id-less progress, preview-first rows, structured args/results).
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
chatMessageText,
|
||||
@@ -218,10 +219,321 @@ describe('upsertToolPart', () => {
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
|
||||
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([])
|
||||
})
|
||||
|
||||
it('keeps parallel same-name tools distinct without explicit ids', () => {
|
||||
const startedTokyo = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'tokyo weather',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedReykjavik = upsertToolPart(
|
||||
startedTokyo,
|
||||
{
|
||||
context: 'reykjavik weather',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completedTokyo = upsertToolPart(
|
||||
startedReykjavik,
|
||||
{
|
||||
context: 'tokyo weather',
|
||||
message: 'tokyo done',
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedBoth = upsertToolPart(
|
||||
completedTokyo,
|
||||
{
|
||||
context: 'reykjavik weather',
|
||||
message: 'reykjavik done',
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const webParts = completedBoth.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || ''))
|
||||
|
||||
const summaries = webParts.map(part => {
|
||||
if (!('result' in part) || !part.result || typeof part.result !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String((part.result as Record<string, unknown>).summary || '')
|
||||
})
|
||||
|
||||
expect(webParts).toHaveLength(2)
|
||||
expect(contexts).toEqual(['tokyo weather', 'reykjavik weather'])
|
||||
expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
|
||||
})
|
||||
|
||||
it('preserves query args when completion payload omits context', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'auckland weather today and tomorrow forecast',
|
||||
name: 'web_search',
|
||||
tool_id: 'search-1'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
duration_s: 1.1,
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches in 1.1s',
|
||||
tool_id: 'search-1'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const [part] = completed
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
|
||||
context: 'auckland weather today and tomorrow forecast'
|
||||
})
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
|
||||
summary: 'Did 5 searches in 1.1s'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not append phantom same-name tool rows for id-less progress updates', () => {
|
||||
const startedA = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'reykjavik weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedB = upsertToolPart(
|
||||
startedA,
|
||||
{
|
||||
context: 'kathmandu weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressed = upsertToolPart(
|
||||
startedB,
|
||||
{
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const webParts = progressed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
expect(webParts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('matches id-less live starts with later identified completions', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'asuncion paraguay weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
context: 'asuncion paraguay weather today and tomorrow forecast',
|
||||
duration_s: 1.1,
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches in 1.1s',
|
||||
tool_id: 'search-asuncion'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const webParts = completed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
expect(webParts).toHaveLength(1)
|
||||
expect(webParts[0].toolCallId).toBe('search-asuncion')
|
||||
expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' })
|
||||
})
|
||||
|
||||
it('matches id-less live starts with later identified progress updates', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
|
||||
name: 'web_search'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
|
||||
name: 'web_search',
|
||||
tool_id: 'search-reykjavik'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const webParts = progressed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
|
||||
expect(webParts).toHaveLength(1)
|
||||
expect(webParts[0].toolCallId).toBe('search-reykjavik')
|
||||
})
|
||||
|
||||
it('reconciles preview-first progress rows with later stable-id starts', () => {
|
||||
const progressA = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
name: 'web_search',
|
||||
preview: 'tokyo weather'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const progressB = upsertToolPart(
|
||||
progressA,
|
||||
{
|
||||
name: 'web_search',
|
||||
preview: 'reykjavik weather'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedA = upsertToolPart(
|
||||
progressB,
|
||||
{
|
||||
args: { query: 'tokyo weather' },
|
||||
name: 'web_search',
|
||||
tool_id: 'search-tokyo'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const startedB = upsertToolPart(
|
||||
startedA,
|
||||
{
|
||||
args: { query: 'reykjavik weather' },
|
||||
name: 'web_search',
|
||||
tool_id: 'search-reykjavik'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const completedA = upsertToolPart(
|
||||
startedB,
|
||||
{
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches',
|
||||
tool_id: 'search-tokyo'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const completedB = upsertToolPart(
|
||||
completedA,
|
||||
{
|
||||
name: 'web_search',
|
||||
summary: 'Did 5 searches',
|
||||
tool_id: 'search-reykjavik'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const webParts = completedB
|
||||
.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'web_search'
|
||||
)
|
||||
.map(part => ({
|
||||
id: part.toolCallId,
|
||||
query: String((part.args as Record<string, unknown>)?.query || ''),
|
||||
summary:
|
||||
part.result && typeof part.result === 'object'
|
||||
? String((part.result as Record<string, unknown>).summary || '')
|
||||
: ''
|
||||
}))
|
||||
|
||||
expect(webParts).toEqual([
|
||||
{ id: 'search-tokyo', query: 'tokyo weather', summary: 'Did 5 searches' },
|
||||
{ id: 'search-reykjavik', query: 'reykjavik weather', summary: 'Did 5 searches' }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses structured live tool args for titles before hydrate', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
args: { search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' },
|
||||
name: 'web_search',
|
||||
tool_id: 'search-bishkek'
|
||||
},
|
||||
'running'
|
||||
)
|
||||
|
||||
const [part] = started
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
|
||||
search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps structured live tool results before hydrate', () => {
|
||||
const completed = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
args: { query: 'suva weather' },
|
||||
name: 'web_search',
|
||||
result: { data: { web: [{ title: 'Suva forecast', url: 'https://example.test', description: 'Sunny' }] } },
|
||||
summary: 'Did 1 search in 0.5s',
|
||||
tool_id: 'search-suva'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const [part] = completed
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
|
||||
data: { web: [{ title: 'Suva forecast' }] },
|
||||
summary: 'Did 1 search in 0.5s'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,10 +23,16 @@ export type GatewayEventPayload = {
|
||||
rendered?: string
|
||||
status?: string
|
||||
message?: string
|
||||
id?: string
|
||||
name?: string
|
||||
tool_id?: string
|
||||
tool_call_id?: string
|
||||
args?: unknown
|
||||
arguments?: unknown
|
||||
context?: string
|
||||
input?: unknown
|
||||
preview?: string
|
||||
result?: unknown
|
||||
summary?: string
|
||||
error?: string | boolean
|
||||
inline_diff?: string
|
||||
@@ -209,7 +215,135 @@ export function hasToolPart(message: ChatMessage): boolean {
|
||||
}
|
||||
|
||||
function toolId(payload: GatewayEventPayload | undefined): string {
|
||||
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
|
||||
return payload?.tool_id || payload?.tool_call_id || payload?.id || ''
|
||||
}
|
||||
|
||||
let liveToolCounter = 0
|
||||
|
||||
function nextLiveToolId(name: string): string {
|
||||
liveToolCounter += 1
|
||||
|
||||
return `live-tool:${name}:${liveToolCounter}`
|
||||
}
|
||||
|
||||
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeToolMatchValue(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function collectToolMatchValues(query: string, context: string, preview: string): string[] {
|
||||
return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))]
|
||||
}
|
||||
|
||||
function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
|
||||
const payloadArgs = liveToolArgs(payload)
|
||||
const query = firstStringField(payloadArgs, ['search_term', 'query'])
|
||||
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
|
||||
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
|
||||
|
||||
return collectToolMatchValues(query, context, preview)
|
||||
}
|
||||
|
||||
function toolPartMatchValues(part: ChatMessagePart): string[] {
|
||||
if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const args = part.args as Record<string, unknown>
|
||||
const query = firstStringField(args, ['search_term', 'query'])
|
||||
const context = typeof args.context === 'string' ? args.context.trim() : ''
|
||||
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
|
||||
|
||||
return collectToolMatchValues(query, context, preview)
|
||||
}
|
||||
|
||||
function hasToolMatchOverlap(left: string[], right: string[]): boolean {
|
||||
if (!left.length || !right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rightSet = new Set(right)
|
||||
|
||||
return left.some(value => rightSet.has(value))
|
||||
}
|
||||
|
||||
function findToolPartIndex(
|
||||
parts: ChatMessagePart[],
|
||||
name: string,
|
||||
stableId: string,
|
||||
payload: GatewayEventPayload | undefined,
|
||||
phase: 'running' | 'complete'
|
||||
): number {
|
||||
const matchValues = toolPayloadMatchValues(payload)
|
||||
const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index]))
|
||||
|
||||
if (stableId) {
|
||||
const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId)
|
||||
|
||||
if (stableIndex >= 0) {
|
||||
return stableIndex
|
||||
}
|
||||
|
||||
// Some live streams start without an id, then complete with one. Fall
|
||||
// through to pending same-name/context matching so the completion updates
|
||||
// the synthetic live row instead of appending a duplicate completed row.
|
||||
if (phase === 'running' && !matchValues.length) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const pendingIndices = parts
|
||||
.map((part, index) => ({ part, index }))
|
||||
.filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined)
|
||||
.map(({ index }) => index)
|
||||
|
||||
if (pendingIndices.length === 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (matchValues.length) {
|
||||
const contextualIndex = pendingIndices.find(overlaps)
|
||||
|
||||
if (contextualIndex !== undefined) {
|
||||
return contextualIndex
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingIndices.length === 1) {
|
||||
const [singlePendingIndex] = pendingIndices
|
||||
|
||||
if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) {
|
||||
return stableId ? singlePendingIndex : -1
|
||||
}
|
||||
|
||||
return singlePendingIndex
|
||||
}
|
||||
|
||||
// Completion events without stable IDs frequently arrive after multiple
|
||||
// same-name starts (parallel tool calls). Resolve them oldest-first so we
|
||||
// don't collapse an entire burst into a single row.
|
||||
if (phase === 'complete') {
|
||||
return pendingIndices[0]
|
||||
}
|
||||
|
||||
if (stableId) {
|
||||
return pendingIndices[0]
|
||||
}
|
||||
|
||||
// For progress/running events with no stable id, update the most-recent
|
||||
// pending same-name tool instead of creating a phantom extra row.
|
||||
return pendingIndices.at(-1) ?? -1
|
||||
}
|
||||
|
||||
// Carry todo state across sparse progress payloads: if this todo event lacks
|
||||
@@ -221,27 +355,43 @@ function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]
|
||||
return next === null ? undefined : { todos: next }
|
||||
}
|
||||
|
||||
if (payload?.name !== 'todo') {return undefined}
|
||||
if (payload?.name !== 'todo') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const p of prev) {
|
||||
const carried = parseTodos(recordFromUnknown(p)?.todos)
|
||||
|
||||
if (carried !== null) {return { todos: carried }}
|
||||
if (carried !== null) {
|
||||
return { todos: carried }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
|
||||
const prev = parseMaybeJsonObject(prevArgs)
|
||||
const eventArgs = liveToolArgs(payload)
|
||||
|
||||
return {
|
||||
...prev,
|
||||
...eventArgs,
|
||||
...(payload?.context ? { context: payload.context } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
...carryTodos(payload, prevArgs)
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
|
||||
function toolResult(
|
||||
payload: GatewayEventPayload | undefined,
|
||||
prevResult?: unknown,
|
||||
prevArgs?: unknown
|
||||
): Record<string, unknown> {
|
||||
const parsedResult = parseMaybeJsonObject(payload?.result)
|
||||
|
||||
return {
|
||||
...parsedResult,
|
||||
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
|
||||
...(payload?.summary ? { summary: payload.summary } : {}),
|
||||
...(payload?.message ? { message: payload.message } : {}),
|
||||
@@ -257,19 +407,22 @@ export function upsertToolPart(
|
||||
payload: GatewayEventPayload | undefined,
|
||||
phase: 'running' | 'complete'
|
||||
): ChatMessagePart[] {
|
||||
const id = toolId(payload)
|
||||
const stableId = toolId(payload)
|
||||
const name = payload?.name || 'tool'
|
||||
const next = [...parts]
|
||||
|
||||
const index = next.findIndex(
|
||||
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
|
||||
)
|
||||
const index = findToolPartIndex(next, name, stableId, payload, phase)
|
||||
|
||||
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 id =
|
||||
stableId ||
|
||||
(prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
|
||||
nextLiveToolId(name)
|
||||
|
||||
const base = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: id,
|
||||
@@ -279,7 +432,9 @@ export function upsertToolPart(
|
||||
...(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
|
||||
@@ -319,6 +474,28 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
const direct = firstNonEmptyObject(payload?.args, payload?.arguments)
|
||||
const input = firstNonEmptyObject(payload?.input)
|
||||
const fn = recordFromUnknown(input.function)
|
||||
|
||||
const nested = firstNonEmptyObject(
|
||||
input.args,
|
||||
input.arguments,
|
||||
input.parameters,
|
||||
input.input,
|
||||
fn?.arguments,
|
||||
fn?.args,
|
||||
fn?.parameters
|
||||
)
|
||||
|
||||
return {
|
||||
...input,
|
||||
...nested,
|
||||
...direct
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredToolResult(content: unknown): unknown {
|
||||
if (content && typeof content === 'object') {
|
||||
return content
|
||||
|
||||
@@ -101,7 +101,10 @@ export function parseCommitHeader(raw: string): ParsedCommit {
|
||||
}
|
||||
|
||||
function tidySubject(subject: string): string {
|
||||
const cleaned = subject.replace(/\s+/g, ' ').replace(/[.;,\s]+$/, '').trim()
|
||||
const cleaned = subject
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.;,\s]+$/, '')
|
||||
.trim()
|
||||
|
||||
if (!cleaned) {
|
||||
return cleaned
|
||||
|
||||
@@ -63,7 +63,8 @@ describe('external link helpers', () => {
|
||||
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
|
||||
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
|
||||
|
||||
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
|
||||
const url =
|
||||
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
|
||||
|
||||
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
|
||||
|
||||
@@ -95,11 +96,7 @@ describe('external link helpers', () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
|
||||
|
||||
render(
|
||||
<ExternalLink href="https://example.com/path/to/resource">
|
||||
Example link
|
||||
</ExternalLink>
|
||||
)
|
||||
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
|
||||
@@ -108,11 +105,7 @@ describe('external link helpers', () => {
|
||||
it('shows a trailing external-link icon', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
render(
|
||||
<ExternalLink href="https://example.com/path/to/resource">
|
||||
Example link
|
||||
</ExternalLink>
|
||||
)
|
||||
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Example link' })
|
||||
expect(link.querySelector('svg')).toBeTruthy()
|
||||
@@ -125,9 +118,7 @@ describe('external link helpers', () => {
|
||||
const url =
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
|
||||
render(
|
||||
<LinkifiedText text={`Read ${url}`} />
|
||||
)
|
||||
render(<LinkifiedText text={`Read ${url}`} />)
|
||||
|
||||
const link = screen.getByTitle(url)
|
||||
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
|
||||
@@ -152,7 +143,8 @@ describe('external link helpers', () => {
|
||||
it('ignores error-like fetched titles and falls back to slug label', async () => {
|
||||
const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error')
|
||||
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
|
||||
const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
const url =
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
|
||||
render(<PrettyLink href={url} />)
|
||||
|
||||
@@ -168,6 +160,8 @@ describe('external link helpers', () => {
|
||||
render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toBe('https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,7 +204,14 @@ export function ExternalLinkIcon({ className }: { className?: string }) {
|
||||
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
|
||||
}
|
||||
|
||||
export function ExternalLink({ children, className, href, onClick, showExternalIcon = true, ...rest }: ExternalLinkProps) {
|
||||
export function ExternalLink({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
showExternalIcon = true,
|
||||
...rest
|
||||
}: ExternalLinkProps) {
|
||||
const target = normalizeExternalUrl(href)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
AssistantRuntimeImpl,
|
||||
BaseAssistantRuntimeCore,
|
||||
ExternalStoreThreadListRuntimeCore,
|
||||
ExternalStoreThreadRuntimeCore,
|
||||
hasUpcomingMessage
|
||||
} from '@assistant-ui/core/internal'
|
||||
import {
|
||||
type AssistantRuntime,
|
||||
type ExternalStoreAdapter,
|
||||
type ThreadMessage,
|
||||
useRuntimeAdapters
|
||||
} from '@assistant-ui/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const EMPTY_ARRAY = Object.freeze([])
|
||||
|
||||
const shallowEqual = (a: object, b: object): boolean => {
|
||||
const aKeys = Object.keys(a)
|
||||
|
||||
if (aKeys.length !== Object.keys(b).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
|
||||
|
||||
function syncRepositoryIncrementally(
|
||||
runtime: ExternalStoreThreadRuntimeCore,
|
||||
messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']>
|
||||
): readonly ThreadMessage[] {
|
||||
const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
|
||||
const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id))
|
||||
|
||||
for (const { message, parentId } of messageRepository.messages) {
|
||||
repository.addOrUpdateMessage(parentId, message)
|
||||
}
|
||||
|
||||
for (const { message } of repository.export().messages) {
|
||||
if (!incomingIds.has(message.id)) {
|
||||
repository.deleteMessage(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
const headId = messageRepository.headId ?? messageRepository.messages.at(-1)?.message.id ?? null
|
||||
|
||||
repository.resetHead(headId)
|
||||
|
||||
return repository.getMessages()
|
||||
}
|
||||
|
||||
class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
|
||||
override __internal_setAdapter(store: ExternalStoreAdapter): void {
|
||||
if (!store.messageRepository) {
|
||||
super.__internal_setAdapter(store)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const self = this as unknown as {
|
||||
_assistantOptimisticId: null | string
|
||||
_capabilities: object
|
||||
_messages: readonly ThreadMessage[]
|
||||
_notifyEventSubscribers: (event: string, payload: object) => void
|
||||
_notifySubscribers: () => void
|
||||
_store?: ExternalStoreAdapter
|
||||
}
|
||||
|
||||
if (self._store === store) {
|
||||
return
|
||||
}
|
||||
|
||||
const isRunning = store.isRunning ?? false
|
||||
this.isDisabled = store.isDisabled ?? false
|
||||
|
||||
const oldStore = self._store
|
||||
self._store = store
|
||||
|
||||
if (this.extras !== store.extras) {
|
||||
this.extras = store.extras
|
||||
}
|
||||
|
||||
const newSuggestions = store.suggestions ?? EMPTY_ARRAY
|
||||
|
||||
if (!shallowEqual(this.suggestions, newSuggestions)) {
|
||||
this.suggestions = newSuggestions
|
||||
}
|
||||
|
||||
const newCapabilities = {
|
||||
switchToBranch: store.setMessages !== undefined,
|
||||
switchBranchDuringRun: false,
|
||||
edit: store.onEdit !== undefined,
|
||||
reload: store.onReload !== undefined,
|
||||
cancel: store.onCancel !== undefined,
|
||||
speech: store.adapters?.speech !== undefined,
|
||||
dictation: store.adapters?.dictation !== undefined,
|
||||
voice: store.adapters?.voice !== undefined,
|
||||
unstable_copy: store.unstable_capabilities?.copy !== false,
|
||||
attachments: !!store.adapters?.attachments,
|
||||
feedback: !!store.adapters?.feedback,
|
||||
queue: false
|
||||
}
|
||||
|
||||
if (!shallowEqual(self._capabilities, newCapabilities)) {
|
||||
self._capabilities = newCapabilities
|
||||
}
|
||||
|
||||
if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
|
||||
self._notifySubscribers()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (self._assistantOptimisticId) {
|
||||
this.repository.deleteMessage(self._assistantOptimisticId)
|
||||
self._assistantOptimisticId = null
|
||||
}
|
||||
|
||||
const messages = syncRepositoryIncrementally(this, store.messageRepository)
|
||||
|
||||
if (messages.length > 0) {
|
||||
this.ensureInitialized()
|
||||
}
|
||||
|
||||
if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
|
||||
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
|
||||
}
|
||||
|
||||
if (hasUpcomingMessage(isRunning, messages)) {
|
||||
self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, {
|
||||
role: 'assistant',
|
||||
content: []
|
||||
})
|
||||
}
|
||||
|
||||
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
|
||||
self._messages = this.repository.getMessages()
|
||||
self._notifySubscribers()
|
||||
}
|
||||
}
|
||||
|
||||
class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
|
||||
threads: ExternalStoreThreadListRuntimeCore
|
||||
|
||||
constructor(adapter: ExternalStoreAdapter) {
|
||||
super()
|
||||
|
||||
this.threads = new ExternalStoreThreadListRuntimeCore(
|
||||
getThreadListAdapter(adapter),
|
||||
() => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
|
||||
)
|
||||
}
|
||||
|
||||
setAdapter(adapter: ExternalStoreAdapter): void {
|
||||
this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
|
||||
this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(
|
||||
store: ExternalStoreAdapter<T>
|
||||
): AssistantRuntime {
|
||||
const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
|
||||
|
||||
useEffect(() => {
|
||||
runtime.setAdapter(store as ExternalStoreAdapter)
|
||||
})
|
||||
|
||||
const { modelContext } = useRuntimeAdapters() ?? {}
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelContext) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return runtime.registerModelContextProvider(modelContext)
|
||||
}, [modelContext, runtime])
|
||||
|
||||
return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*
|
||||
*/
|
||||
function hasCloseFenceLine(body: string, marker: string): boolean {
|
||||
const lines = body.split('\n')
|
||||
|
||||
// Original regex required `\n` immediately before the close fence, so the
|
||||
// first line of `body` (which has no preceding newline within `body`)
|
||||
// cannot itself be the close fence.
|
||||
@@ -35,8 +36,15 @@ function hasCloseFenceLine(body: string, marker: string): boolean {
|
||||
const line = lines[i]
|
||||
let lo = 0
|
||||
let hi = line.length
|
||||
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) lo += 1
|
||||
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) hi -= 1
|
||||
|
||||
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) {
|
||||
lo += 1
|
||||
}
|
||||
|
||||
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) {
|
||||
hi -= 1
|
||||
}
|
||||
|
||||
if (line.slice(lo, hi) === marker) {
|
||||
return true
|
||||
}
|
||||
@@ -122,7 +130,9 @@ function normalizeVisibleProse(text: string): string {
|
||||
.map(part =>
|
||||
part.startsWith('`')
|
||||
? part
|
||||
: autoLinkRawUrls(part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, ''))
|
||||
: autoLinkRawUrls(
|
||||
part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
|
||||
)
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { interpretRuntimeReadiness } from './runtime-readiness'
|
||||
|
||||
describe('interpretRuntimeReadiness', () => {
|
||||
it('prefers runtime_check when both signals exist', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: { provider_configured: false },
|
||||
setupError: null,
|
||||
runtime: { ok: true },
|
||||
runtimeError: null
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
checksDisagree: true,
|
||||
ready: true,
|
||||
reason: null,
|
||||
source: 'runtime_check'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces runtime mismatch details when runtime_check fails', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: { provider_configured: true },
|
||||
setupError: null,
|
||||
runtime: { error: 'No provider can serve the selected model.', ok: false },
|
||||
runtimeError: null
|
||||
})
|
||||
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.source).toBe('runtime_check')
|
||||
expect(result.checksDisagree).toBe(true)
|
||||
expect(result.reason).toContain('No provider can serve the selected model.')
|
||||
expect(result.reason).toContain('setup.status reports configured credentials')
|
||||
})
|
||||
|
||||
it('falls back to setup.status when runtime_check has no boolean result', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: { provider_configured: true },
|
||||
setupError: null,
|
||||
runtime: null,
|
||||
runtimeError: 'runtime check RPC unavailable'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
checksDisagree: false,
|
||||
ready: true,
|
||||
reason: null,
|
||||
source: 'setup_status'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses explicit fallback when both checks are missing', () => {
|
||||
const result = interpretRuntimeReadiness({
|
||||
setup: null,
|
||||
setupError: 'setup.status timeout',
|
||||
runtime: null,
|
||||
runtimeError: 'setup.runtime_check timeout'
|
||||
})
|
||||
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.source).toBe('fallback')
|
||||
expect(result.reason).toBe('setup.runtime_check timeout')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
export interface SetupStatusSnapshot {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeCheckSnapshot {
|
||||
error?: string
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeReadinessSignals {
|
||||
setup: null | SetupStatusSnapshot
|
||||
setupError: null | string
|
||||
runtime: null | RuntimeCheckSnapshot
|
||||
runtimeError: null | string
|
||||
}
|
||||
|
||||
export interface RuntimeReadinessOptions {
|
||||
defaultReason?: string
|
||||
unknownReady?: boolean
|
||||
}
|
||||
|
||||
export interface RuntimeReadinessResult {
|
||||
checksDisagree: boolean
|
||||
ready: boolean
|
||||
reason: null | string
|
||||
source: 'fallback' | 'runtime_check' | 'setup_status'
|
||||
}
|
||||
|
||||
export type RuntimeReadinessRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
const DEFAULT_NOT_READY_REASON = 'Add a provider credential before sending your first message.'
|
||||
|
||||
function toErrorMessage(error: unknown): null | string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error === null || error === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function normalizeMessage(value: null | string | undefined): null | string {
|
||||
const next = value?.trim()
|
||||
|
||||
return next ? next : null
|
||||
}
|
||||
|
||||
async function requestWithFallback<T>(
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
method: string
|
||||
): Promise<{ error: null | string; value: null | T }> {
|
||||
try {
|
||||
return { error: null, value: await requestGateway<T>(method) }
|
||||
} catch (error) {
|
||||
return { error: toErrorMessage(error), value: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRuntimeReadinessSignals(
|
||||
requestGateway: RuntimeReadinessRequester
|
||||
): Promise<RuntimeReadinessSignals> {
|
||||
const [setup, runtime] = await Promise.all([
|
||||
requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'),
|
||||
requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check')
|
||||
])
|
||||
|
||||
return {
|
||||
setup: setup.value,
|
||||
setupError: setup.error,
|
||||
runtime: runtime.value,
|
||||
runtimeError: runtime.error
|
||||
}
|
||||
}
|
||||
|
||||
export function interpretRuntimeReadiness(
|
||||
signals: RuntimeReadinessSignals,
|
||||
options: RuntimeReadinessOptions = {}
|
||||
): RuntimeReadinessResult {
|
||||
const defaultReason = options.defaultReason ?? DEFAULT_NOT_READY_REASON
|
||||
const unknownReady = options.unknownReady ?? false
|
||||
|
||||
const setupConfigured =
|
||||
typeof signals.setup?.provider_configured === 'boolean' ? Boolean(signals.setup.provider_configured) : undefined
|
||||
|
||||
const runtimeOk = typeof signals.runtime?.ok === 'boolean' ? Boolean(signals.runtime.ok) : undefined
|
||||
const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError)
|
||||
const setupFailure = normalizeMessage(signals.setupError)
|
||||
|
||||
const checksDisagree =
|
||||
typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk
|
||||
|
||||
if (typeof runtimeOk === 'boolean') {
|
||||
if (runtimeOk) {
|
||||
return {
|
||||
checksDisagree,
|
||||
ready: true,
|
||||
reason: null,
|
||||
source: 'runtime_check'
|
||||
}
|
||||
}
|
||||
|
||||
let reason = runtimeFailure ?? defaultReason
|
||||
|
||||
if (checksDisagree && setupConfigured) {
|
||||
reason = `${reason} setup.status reports configured credentials, but runtime resolution still failed.`
|
||||
}
|
||||
|
||||
return {
|
||||
checksDisagree,
|
||||
ready: false,
|
||||
reason,
|
||||
source: 'runtime_check'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof setupConfigured === 'boolean') {
|
||||
return {
|
||||
checksDisagree: false,
|
||||
ready: setupConfigured,
|
||||
reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
|
||||
source: 'setup_status'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checksDisagree: false,
|
||||
ready: unknownReady,
|
||||
reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
|
||||
source: 'fallback'
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateRuntimeReadiness(
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
options: RuntimeReadinessOptions = {}
|
||||
): Promise<RuntimeReadinessResult> {
|
||||
const signals = await fetchRuntimeReadinessSignals(requestGateway)
|
||||
|
||||
return interpretRuntimeReadiness(signals, options)
|
||||
}
|
||||
@@ -13,7 +13,9 @@ const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]
|
||||
|
||||
function parseArray(value: unknown[]): TodoItem[] {
|
||||
return value.flatMap(item => {
|
||||
if (!isRecord(item) || !isStatus(item.status)) {return []}
|
||||
if (!isRecord(item) || !isStatus(item.status)) {
|
||||
return []
|
||||
}
|
||||
const id = String(item.id ?? '').trim()
|
||||
const content = String(item.content ?? '').trim()
|
||||
|
||||
@@ -22,15 +24,25 @@ function parseArray(value: unknown[]): TodoItem[] {
|
||||
}
|
||||
|
||||
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 (depth > 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ describe('formatToolResultSummary', () => {
|
||||
details: `prefix ${'x'.repeat(500)}`
|
||||
})
|
||||
|
||||
const detailsLine = summary
|
||||
.split('\n')
|
||||
.find(line => line.startsWith('- Details:'))
|
||||
const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:'))
|
||||
|
||||
expect(detailsLine).toBeTruthy()
|
||||
expect(detailsLine?.length).toBeLessThan(230)
|
||||
|
||||
@@ -2,7 +2,21 @@
|
||||
// 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 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'])
|
||||
@@ -14,17 +28,29 @@ const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' &
|
||||
function tryJson(value: string): unknown {
|
||||
const t = value.trim()
|
||||
|
||||
if (!t) {return ''}
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!/^[{[]|^"/.test(t)) {return value}
|
||||
if (!/^[{[]|^"/.test(t)) {
|
||||
return value
|
||||
}
|
||||
|
||||
try { return JSON.parse(t) } catch { 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(' ')
|
||||
k
|
||||
.split(/[_\-.]+/)
|
||||
.filter(Boolean)
|
||||
.map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`)
|
||||
.join(' ')
|
||||
|
||||
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
|
||||
|
||||
@@ -37,12 +63,16 @@ function clipInline(value: string, max = 180): string {
|
||||
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
|
||||
const t = value.trim()
|
||||
|
||||
if (!t) {return ''}
|
||||
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()}
|
||||
if (text.length > maxChars) {
|
||||
text = text.slice(0, maxChars - 1).trimEnd()
|
||||
}
|
||||
|
||||
return clipped && !text.endsWith('…') ? `${text}…` : text
|
||||
}
|
||||
@@ -51,7 +81,9 @@ 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()}
|
||||
if (typeof v === 'string' && v.trim()) {
|
||||
return v.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -68,25 +100,37 @@ const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes
|
||||
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 === 'string') {
|
||||
return clipInline(v)
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(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')}
|
||||
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 && status) {
|
||||
return `${clipInline(title, 110)} (${clipInline(status, 54)})`
|
||||
}
|
||||
|
||||
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
|
||||
if (title && message && title !== message) {
|
||||
return `${clipInline(title, 90)} - ${clipInline(message, 84)}`
|
||||
}
|
||||
|
||||
if (title) {return clipInline(title, 150)}
|
||||
if (title) {
|
||||
return clipInline(title, 150)
|
||||
}
|
||||
|
||||
const pairs = orderedKeys(Object.keys(record))
|
||||
.filter(k => !skipField(k, record[k]))
|
||||
@@ -104,15 +148,25 @@ function summarizeRecordInline(record: Json, depth: number): string {
|
||||
function summarizeListItem(item: unknown, depth: number): string {
|
||||
const v = norm(item)
|
||||
|
||||
if (typeof v === 'string') {return clipInline(v)}
|
||||
if (typeof v === 'string') {
|
||||
return clipInline(v)
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
if (v == null) {return ''}
|
||||
if (v == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
|
||||
if (Array.isArray(v)) {
|
||||
return pluralize(v.length, 'item')
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
|
||||
if (isRecord(v)) {
|
||||
return summarizeRecordInline(v, depth + 1)
|
||||
}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
@@ -121,32 +175,50 @@ function formatFieldValue(value: unknown, depth: number): string {
|
||||
const v = norm(value)
|
||||
const scalar = summarizeScalar(v)
|
||||
|
||||
if (scalar) {return scalar}
|
||||
if (scalar) {
|
||||
return scalar
|
||||
}
|
||||
|
||||
if (v == null) {return ''}
|
||||
if (v == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
if (!v.length) {return '0 items'}
|
||||
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(', '))}
|
||||
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)}
|
||||
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.'}
|
||||
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}`)
|
||||
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 (!lines.length) {
|
||||
return `Returned ${pluralize(value.length, 'item')}.`
|
||||
}
|
||||
|
||||
if (value.length > max) {
|
||||
const remaining = value.length - max
|
||||
@@ -159,13 +231,17 @@ function formatArraySummary(value: unknown[], depth: number): string {
|
||||
function formatRecordSummary(record: Json, depth: number): string {
|
||||
const keys = Object.keys(record)
|
||||
|
||||
if (!keys.length) {return 'Returned an empty object.'}
|
||||
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)}
|
||||
if (direct && meaningful.length <= 1) {
|
||||
return clipBlock(direct)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
|
||||
@@ -175,13 +251,19 @@ function formatRecordSummary(record: Json, depth: number): string {
|
||||
for (const k of candidates) {
|
||||
const v = formatFieldValue(record[k], depth + 1)
|
||||
|
||||
if (!v) {continue}
|
||||
if (!v) {
|
||||
continue
|
||||
}
|
||||
lines.push(`- ${titleCase(k)}: ${v}`)
|
||||
|
||||
if (lines.length >= max) {break}
|
||||
if (lines.length >= max) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
|
||||
if (!lines.length) {
|
||||
return `Returned object with ${pluralize(keys.length, 'field')}.`
|
||||
}
|
||||
|
||||
if (candidates.length > lines.length) {
|
||||
const remaining = candidates.length - lines.length
|
||||
@@ -192,18 +274,30 @@ function formatRecordSummary(record: Json, depth: number): string {
|
||||
}
|
||||
|
||||
function formatSummaryValue(value: unknown, depth: number): string {
|
||||
if (depth > 4) {return ''}
|
||||
if (depth > 4) {
|
||||
return ''
|
||||
}
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {return clipBlock(v)}
|
||||
if (typeof v === 'string') {
|
||||
return clipBlock(v)
|
||||
}
|
||||
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
if (v == null) {return ''}
|
||||
if (v == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
|
||||
if (Array.isArray(v)) {
|
||||
return formatArraySummary(v, depth + 1)
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
|
||||
if (isRecord(v)) {
|
||||
return formatRecordSummary(v, depth + 1)
|
||||
}
|
||||
|
||||
return clipInline(String(v))
|
||||
}
|
||||
@@ -232,17 +326,29 @@ function unwrapPayload(value: unknown): unknown {
|
||||
function hasMeaningfulErrorValue(value: unknown): boolean {
|
||||
const v = norm(value)
|
||||
|
||||
if (v == null) {return false}
|
||||
if (v == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
|
||||
if (typeof v === 'string') {
|
||||
return !NON_ERROR_TEXT.has(v.trim().toLowerCase())
|
||||
}
|
||||
|
||||
if (typeof v === 'boolean') {return v}
|
||||
if (typeof v === 'boolean') {
|
||||
return v
|
||||
}
|
||||
|
||||
if (typeof v === 'number') {return v !== 0}
|
||||
if (typeof v === 'number') {
|
||||
return v !== 0
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
|
||||
if (Array.isArray(v)) {
|
||||
return v.some(hasMeaningfulErrorValue)
|
||||
}
|
||||
|
||||
if (isRecord(v)) {return Object.keys(v).length > 0}
|
||||
if (isRecord(v)) {
|
||||
return Object.keys(v).length > 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -261,7 +367,9 @@ function hasErrorSignal(record: Json): boolean {
|
||||
function valueErrorText(value: unknown): string {
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
|
||||
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)
|
||||
@@ -270,24 +378,32 @@ function valueErrorText(value: unknown): string {
|
||||
if (isRecord(v)) {
|
||||
const direct = firstString(v, ERROR_MSG_KEYS)
|
||||
|
||||
if (direct) {return clipBlock(direct, 700, 12)}
|
||||
if (direct) {
|
||||
return clipBlock(direct, 700, 12)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
|
||||
if (depth > 5) {return ''}
|
||||
if (depth > 5) {
|
||||
return ''
|
||||
}
|
||||
const v = norm(value)
|
||||
|
||||
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
|
||||
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}
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -296,22 +412,30 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
|
||||
const record = v as Json
|
||||
|
||||
for (const k of ERROR_KEYS) {
|
||||
if (!hasMeaningfulErrorValue(record[k])) {continue}
|
||||
if (!hasMeaningfulErrorValue(record[k])) {
|
||||
continue
|
||||
}
|
||||
const text = valueErrorText(record[k])
|
||||
|
||||
if (text) {return text}
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrorSignal(record)) {
|
||||
const direct = firstString(record, ERROR_MSG_KEYS)
|
||||
|
||||
if (direct) {return clipBlock(direct, 700, 12)}
|
||||
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}
|
||||
if (nested) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* One-shot enter animation via the Web Animations API.
|
||||
*
|
||||
* Returns a callback ref. The animation fires exactly once when the element
|
||||
* first attaches to the DOM and never replays for an already-mounted node —
|
||||
* this is deliberate. CSS-transition + `@starting-style` is fragile here
|
||||
* because:
|
||||
* - Streaming deltas constantly invalidate ancestor state, which can
|
||||
* re-trigger transitions on unrelated descendants.
|
||||
* - `@starting-style` only covers DOM insertion / first-match, but any
|
||||
* style restart during the message lifecycle replays the transition.
|
||||
* - Some Chromium versions reset transitions when an attribute on an
|
||||
* ancestor toggles, even if the descendant's properties never change.
|
||||
*
|
||||
* `el.animate(...)` runs against the element directly and is independent of
|
||||
* CSS rule churn — it plays once, finishes, and is done. If the element
|
||||
* unmounts and re-mounts, the callback ref runs again and replays it
|
||||
* (correct behaviour).
|
||||
*
|
||||
* `enabled` is captured at mount-time only — flipping it later doesn't
|
||||
* suddenly play the animation on existing nodes.
|
||||
*/
|
||||
const playedAnimationKeys = new Set<string>()
|
||||
const playedAnimationOrder: string[] = []
|
||||
const MAX_TRACKED_KEYS = 2048
|
||||
|
||||
function hasPlayedAnimation(key: string): boolean {
|
||||
return playedAnimationKeys.has(key)
|
||||
}
|
||||
|
||||
function rememberPlayedAnimation(key: string): void {
|
||||
if (playedAnimationKeys.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
playedAnimationKeys.add(key)
|
||||
playedAnimationOrder.push(key)
|
||||
|
||||
if (playedAnimationOrder.length > MAX_TRACKED_KEYS) {
|
||||
const evicted = playedAnimationOrder.shift()
|
||||
|
||||
if (evicted) {
|
||||
playedAnimationKeys.delete(evicted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMicrotask(cb: () => void): void {
|
||||
if (typeof queueMicrotask === 'function') {
|
||||
queueMicrotask(cb)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.resolve().then(cb)
|
||||
}
|
||||
|
||||
export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: HTMLElement | null) => void {
|
||||
const enabledRef = useRef(enabled)
|
||||
const keyRef = useRef(animationKey)
|
||||
|
||||
enabledRef.current = enabled
|
||||
keyRef.current = animationKey
|
||||
|
||||
return useCallback((el: HTMLElement | null) => {
|
||||
if (!el || !enabledRef.current || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = keyRef.current
|
||||
|
||||
if (key && hasPlayedAnimation(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
el.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateY(0.5rem)' },
|
||||
{ opacity: 1, transform: 'translateY(0)' }
|
||||
],
|
||||
{ duration: 220, easing: 'linear', fill: 'both' }
|
||||
)
|
||||
|
||||
if (key) {
|
||||
// In React StrictMode the first mount can be immediately torn down.
|
||||
// Only persist "played" once the element survives to the microtask tick.
|
||||
scheduleMicrotask(() => {
|
||||
if (el.isConnected) {
|
||||
rememberPlayedAnimation(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
Reference in New Issue
Block a user