mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
fix(tui): stabilize live todo progress
This commit is contained in:
parent
1566f1eecc
commit
f5552f92e2
14 changed files with 256 additions and 86 deletions
60
ui-tui/src/__tests__/turnStore.test.ts
Normal file
60
ui-tui/src/__tests__/turnStore.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
appendTurnSegment,
|
||||
archiveDoneTodos,
|
||||
getTurnState,
|
||||
patchTurnState,
|
||||
resetTurnState,
|
||||
toggleTodoCollapsed
|
||||
} from '../app/turnStore.js'
|
||||
|
||||
describe('turnStore live progress helpers', () => {
|
||||
beforeEach(() => resetTurnState())
|
||||
|
||||
it('archives completed todos into a transcript trail and clears the live anchor', () => {
|
||||
patchTurnState({
|
||||
todos: [
|
||||
{ content: 'prep', id: 'prep', status: 'completed' },
|
||||
{ content: 'serve', id: 'serve', status: 'completed' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(archiveDoneTodos()).toEqual([
|
||||
{
|
||||
kind: 'trail',
|
||||
role: 'system',
|
||||
text: '',
|
||||
todos: [
|
||||
{ content: 'prep', id: 'prep', status: 'completed' },
|
||||
{ content: 'serve', id: 'serve', status: 'completed' }
|
||||
]
|
||||
}
|
||||
])
|
||||
expect(getTurnState().todos).toEqual([])
|
||||
})
|
||||
|
||||
it('does not archive active todos', () => {
|
||||
patchTurnState({ todos: [{ content: 'cook', id: 'cook', status: 'in_progress' }] })
|
||||
|
||||
expect(archiveDoneTodos()).toEqual([])
|
||||
expect(getTurnState().todos).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks collapsed state independently of todo content', () => {
|
||||
toggleTodoCollapsed()
|
||||
expect(getTurnState().todoCollapsed).toBe(true)
|
||||
|
||||
toggleTodoCollapsed()
|
||||
expect(getTurnState().todoCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('merges adjacent live tool shelves before rendering', () => {
|
||||
appendTurnSegment({ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] })
|
||||
appendTurnSegment({ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] })
|
||||
|
||||
expect(getTurnState().streamSegments).toEqual([
|
||||
{ kind: 'trail', role: 'system', text: '', tools: ['one ✓', 'two ✓'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
|||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||
import { patchOverlayState } from './overlayStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { archiveDoneTodos } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
|
||||
|
|
@ -538,6 +539,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (!wasInterrupted) {
|
||||
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
|
||||
msgs.forEach(appendMessage)
|
||||
archiveDoneTodos().forEach(appendMessage)
|
||||
|
||||
if (bellOnComplete && stdout?.isTTY) {
|
||||
stdout.write('\x07')
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from '../config/timing.js'
|
||||
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js'
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
buildToolTrailLine,
|
||||
|
|
@ -19,7 +20,7 @@ import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '
|
|||
|
||||
import { resetFlowOverlays } from './overlayStore.js'
|
||||
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||
import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const INTERRUPT_COOLDOWN_MS = 1500
|
||||
|
|
@ -42,20 +43,6 @@ const diffSegmentBody = (msg: Msg): null | string => {
|
|||
|
||||
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
|
||||
|
||||
const isToolOnly = (msg: Msg | undefined) =>
|
||||
Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length)
|
||||
|
||||
const mergeSequentialToolOnly = (segments: Msg[]) =>
|
||||
segments.reduce<Msg[]>((acc, msg) => {
|
||||
if (isToolOnly(msg) && isToolOnly(acc.at(-1))) {
|
||||
const prev = acc.at(-1)!
|
||||
|
||||
return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }]
|
||||
}
|
||||
|
||||
return [...acc, msg]
|
||||
}, [])
|
||||
|
||||
const isTodoStatus = (status: unknown): status is TodoItem['status'] =>
|
||||
status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled'
|
||||
|
||||
|
|
@ -281,17 +268,7 @@ class TurnController {
|
|||
}
|
||||
|
||||
private pushSegment(msg: Msg) {
|
||||
if (isToolOnly(msg) && isToolOnly(this.segmentMessages.at(-1)!)) {
|
||||
const prev = this.segmentMessages.at(-1)!
|
||||
this.segmentMessages = [
|
||||
...this.segmentMessages.slice(0, -1),
|
||||
{ ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }
|
||||
]
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.segmentMessages = [...this.segmentMessages, msg]
|
||||
this.segmentMessages = appendToolShelfMessage(this.segmentMessages, msg)
|
||||
}
|
||||
|
||||
flushStreamingSegment() {
|
||||
|
|
@ -347,16 +324,22 @@ class TurnController {
|
|||
}
|
||||
|
||||
private flushPendingToolsIntoLastSegment() {
|
||||
const last = this.segmentMessages[this.segmentMessages.length - 1]
|
||||
|
||||
if (!this.pendingSegmentTools.length || !isToolOnly(last)) {
|
||||
if (!this.pendingSegmentTools.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.segmentMessages = [
|
||||
...this.segmentMessages.slice(0, -1),
|
||||
{ ...last, tools: [...(last.tools ?? []), ...this.pendingSegmentTools] }
|
||||
]
|
||||
const next = appendToolShelfMessage(this.segmentMessages, {
|
||||
kind: 'trail',
|
||||
role: 'system',
|
||||
text: '',
|
||||
tools: this.pendingSegmentTools
|
||||
})
|
||||
|
||||
if (next.length === this.segmentMessages.length + 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.segmentMessages = next
|
||||
this.pendingSegmentTools = []
|
||||
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages })
|
||||
|
||||
|
|
@ -449,7 +432,7 @@ class TurnController {
|
|||
let tools = this.pendingSegmentTools
|
||||
const last = this.segmentMessages[this.segmentMessages.length - 1]
|
||||
|
||||
if (tools.length && isToolOnly(last)) {
|
||||
if (tools.length && isToolShelfMessage(last)) {
|
||||
this.segmentMessages = [
|
||||
...this.segmentMessages.slice(0, -1),
|
||||
{ ...last, tools: [...(last.tools ?? []), ...tools] }
|
||||
|
|
@ -465,13 +448,11 @@ class TurnController {
|
|||
// assistant narration stays put.
|
||||
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
|
||||
|
||||
const segments = mergeSequentialToolOnly(
|
||||
this.segmentMessages.filter(msg => {
|
||||
const body = diffSegmentBody(msg)
|
||||
const segments = this.segmentMessages.filter(msg => {
|
||||
const body = diffSegmentBody(msg)
|
||||
|
||||
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
|
||||
})
|
||||
)
|
||||
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
|
||||
})
|
||||
|
||||
const hasReasoningSegment =
|
||||
this.reasoningSegmentIndex !== null || segments.some(msg => Boolean(msg.thinking?.trim()))
|
||||
|
|
@ -490,6 +471,8 @@ class TurnController {
|
|||
|
||||
const finalMessages = hasDetails(finalDetails) ? [...segments, finalDetails] : [...segments]
|
||||
|
||||
finalMessages.push(...archiveDoneTodos())
|
||||
|
||||
if (finalText) {
|
||||
finalMessages.push({ role: 'assistant', text: finalText })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { atom } from 'nanostores'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { appendToolShelfMessage, isTodoDone } from '../lib/liveProgress.js'
|
||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
|
||||
|
||||
const buildTurnState = (): TurnState => ({
|
||||
|
|
@ -14,6 +15,7 @@ const buildTurnState = (): TurnState => ({
|
|||
streamSegments: [],
|
||||
streaming: '',
|
||||
subagents: [],
|
||||
todoCollapsed: false,
|
||||
todos: [],
|
||||
toolTokens: 0,
|
||||
tools: [],
|
||||
|
|
@ -36,6 +38,25 @@ export const useTurnSelector = <T>(selector: (state: TurnState) => T): T =>
|
|||
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
|
||||
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
|
||||
|
||||
export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed }))
|
||||
|
||||
export const archiveDoneTodos = () => {
|
||||
const state = $turnState.get()
|
||||
|
||||
if (!isTodoDone(state.todos)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const msg: Msg = { kind: 'trail', role: 'system', text: '', todos: state.todos }
|
||||
|
||||
patchTurnState({ todoCollapsed: false, todos: [] })
|
||||
|
||||
return [msg]
|
||||
}
|
||||
|
||||
export const appendTurnSegment = (msg: Msg) =>
|
||||
patchTurnState(state => ({ ...state, streamSegments: appendToolShelfMessage(state.streamSegments, msg) }))
|
||||
|
||||
export const resetTurnState = () => $turnState.set(buildTurnState())
|
||||
|
||||
export interface TurnState {
|
||||
|
|
@ -49,6 +70,7 @@ export interface TurnState {
|
|||
streamSegments: Msg[]
|
||||
streaming: string
|
||||
subagents: SubagentProgress[]
|
||||
todoCollapsed: boolean
|
||||
todos: TodoItem[]
|
||||
toolTokens: number
|
||||
tools: ActiveTool[]
|
||||
|
|
|
|||
|
|
@ -28,10 +28,6 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
<LiveTodoPanel />
|
||||
</Box>
|
||||
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
|
|
@ -73,6 +69,10 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
</Box>
|
||||
</ScrollBox>
|
||||
|
||||
<Box flexDirection="column" flexShrink={0} paddingX={1}>
|
||||
<LiveTodoPanel />
|
||||
</Box>
|
||||
|
||||
<NoSelect flexShrink={0} marginLeft={1}>
|
||||
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
||||
</NoSelect>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { Theme } from '../theme.js'
|
|||
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||
|
||||
import { Md } from './markdown.js'
|
||||
import { TodoPanel } from './todoPanel.js'
|
||||
import { ToolTrail } from './thinking.js'
|
||||
|
||||
export const MessageLine = memo(function MessageLine({
|
||||
|
|
@ -35,6 +36,10 @@ export const MessageLine = memo(function MessageLine({
|
|||
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
|
||||
const thinking = msg.thinking?.trim() ?? ''
|
||||
|
||||
if (msg.kind === 'trail' && msg.todos?.length) {
|
||||
return <TodoPanel t={t} todos={msg.todos} />
|
||||
}
|
||||
|
||||
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {
|
||||
return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
|
||||
<Box flexDirection="column">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { memo } from 'react'
|
||||
|
||||
import type { AppLayoutProgressProps } from '../app/interfaces.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||
|
||||
|
|
@ -105,8 +105,9 @@ export const StreamingAssistant = memo(function StreamingAssistant({
|
|||
export const LiveTodoPanel = memo(function LiveTodoPanel() {
|
||||
const ui = useStore($uiState)
|
||||
const todos = useTurnSelector(state => state.todos)
|
||||
const collapsed = useTurnSelector(state => state.todoCollapsed)
|
||||
|
||||
return <TodoPanel t={ui.theme} todos={todos} />
|
||||
return <TodoPanel collapsed={collapsed} onToggle={toggleTodoCollapsed} t={ui.theme} todos={todos} />
|
||||
})
|
||||
|
||||
interface StreamingAssistantProps {
|
||||
|
|
|
|||
|
|
@ -11,35 +11,52 @@ const rowColor = (t: Theme, status: TodoItem['status']) => {
|
|||
return tone === 'active' ? t.color.cornsilk : tone === 'body' ? t.color.statusFg : t.color.dim
|
||||
}
|
||||
|
||||
export const TodoPanel = memo(function TodoPanel({ t, todos }: { t: Theme; todos: TodoItem[] }) {
|
||||
export const TodoPanel = memo(function TodoPanel({
|
||||
collapsed = false,
|
||||
onToggle,
|
||||
t,
|
||||
todos
|
||||
}: {
|
||||
collapsed?: boolean
|
||||
onToggle?: () => void
|
||||
t: Theme
|
||||
todos: TodoItem[]
|
||||
}) {
|
||||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const done = todos.filter(todo => todo.status === 'completed').length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.amber}>▾ </Text>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
Todo
|
||||
</Text>{' '}
|
||||
<Text color={t.color.statusFg} dim>
|
||||
({todos.filter(todo => todo.status === 'completed').length}/{todos.length})
|
||||
<Box onClick={onToggle}>
|
||||
<Text color={t.color.dim}>
|
||||
<Text color={t.color.amber}>{collapsed ? '▸ ' : '▾ '}</Text>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
Todo
|
||||
</Text>{' '}
|
||||
<Text color={t.color.statusFg} dim>
|
||||
({done}/{todos.length})
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{todos.map(todo => {
|
||||
const tone = todoTone(todo.status)
|
||||
const color = rowColor(t, todo.status)
|
||||
|
||||
return (
|
||||
<Text color={color} dim={tone === 'dim'} key={todo.id}>
|
||||
<Text color={color}>{todoGlyph(todo.status)} </Text>
|
||||
{todo.content}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{!collapsed && (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{todos.map(todo => {
|
||||
const tone = todoTone(todo.status)
|
||||
const color = rowColor(t, todo.status)
|
||||
|
||||
return (
|
||||
<Text color={color} dim={tone === 'dim'} key={todo.id}>
|
||||
<Text color={color}>{todoGlyph(todo.status)} </Text>
|
||||
{todo.content}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const liveTailOrder = () => ['todo', 'scroll-history', 'assistant'] as const
|
||||
export const liveTailOrder = () => ['scroll-history', 'assistant', 'live-todo'] as const
|
||||
|
|
|
|||
48
ui-tui/src/lib/liveProgress.test.ts
Normal file
48
ui-tui/src/lib/liveProgress.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { appendToolShelfMessage, isTodoDone } from './liveProgress.js'
|
||||
|
||||
describe('isTodoDone', () => {
|
||||
it('only treats non-empty all-completed/cancelled lists as done', () => {
|
||||
expect(isTodoDone([])).toBe(false)
|
||||
expect(isTodoDone([{ content: 'x', id: 'x', status: 'completed' }])).toBe(true)
|
||||
expect(isTodoDone([{ content: 'x', id: 'x', status: 'in_progress' }])).toBe(false)
|
||||
expect(
|
||||
isTodoDone([
|
||||
{ content: 'x', id: 'x', status: 'completed' },
|
||||
{ content: 'y', id: 'y', status: 'cancelled' }
|
||||
])
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToolShelfMessage', () => {
|
||||
it('merges adjacent tool shelves into one contextual shelf', () => {
|
||||
const merged = appendToolShelfMessage([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }], {
|
||||
kind: 'trail',
|
||||
role: 'system',
|
||||
text: '',
|
||||
tools: ['two ✓']
|
||||
})
|
||||
|
||||
expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓', 'two ✓'] }])
|
||||
})
|
||||
|
||||
it('adds tools to the nearest contextual thinking shelf', () => {
|
||||
const merged = appendToolShelfMessage(
|
||||
[{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }],
|
||||
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
|
||||
)
|
||||
|
||||
expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }])
|
||||
})
|
||||
|
||||
it('starts a new shelf across assistant text boundaries', () => {
|
||||
const merged = appendToolShelfMessage(
|
||||
[{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }],
|
||||
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
|
||||
)
|
||||
|
||||
expect(merged).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
34
ui-tui/src/lib/liveProgress.ts
Normal file
34
ui-tui/src/lib/liveProgress.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Msg, TodoItem } from '../types.js'
|
||||
|
||||
export const isTodoDone = (todos: readonly TodoItem[]) =>
|
||||
todos.length > 0 && todos.every(todo => todo.status === 'completed' || todo.status === 'cancelled')
|
||||
|
||||
export const isToolShelfMessage = (msg: Msg | undefined) =>
|
||||
Boolean(msg?.kind === 'trail' && !msg.text && !msg.thinking?.trim() && msg.tools?.length)
|
||||
|
||||
const canHoldToolShelf = (msg: Msg | undefined) =>
|
||||
Boolean(msg?.kind === 'trail' && !msg.text && (msg.thinking?.trim() || msg.tools?.length))
|
||||
|
||||
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
|
||||
if (!isToolShelfMessage(msg)) {
|
||||
return [...prev, msg]
|
||||
}
|
||||
|
||||
for (let index = prev.length - 1; index >= 0; index--) {
|
||||
const candidate = prev[index]
|
||||
|
||||
if (canHoldToolShelf(candidate)) {
|
||||
const next = [...prev]
|
||||
|
||||
next[index] = { ...candidate!, tools: [...(candidate!.tools ?? []), ...(msg.tools ?? [])] }
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
if (candidate?.kind !== 'trail' || candidate.text) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [...prev, msg]
|
||||
}
|
||||
|
|
@ -4,20 +4,26 @@ import { appendTranscriptMessage } from './messages.js'
|
|||
|
||||
describe('appendTranscriptMessage', () => {
|
||||
it('merges adjacent tool-only shelves into one transcript row', () => {
|
||||
const out = appendTranscriptMessage(
|
||||
[{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓'] }],
|
||||
{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("two") ✓'] }
|
||||
)
|
||||
const out = appendTranscriptMessage([{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓'] }], {
|
||||
kind: 'trail',
|
||||
role: 'system',
|
||||
text: '',
|
||||
tools: ['Terminal("two") ✓']
|
||||
})
|
||||
|
||||
expect(out).toEqual([{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] }])
|
||||
expect(out).toEqual([
|
||||
{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not merge tool shelves across thinking text', () => {
|
||||
it('merges tool shelves into the nearest thinking shelf', () => {
|
||||
const out = appendTranscriptMessage(
|
||||
[{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓'] }],
|
||||
{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("two") ✓'] }
|
||||
)
|
||||
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out).toEqual([
|
||||
{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import type { Msg, Role } from '../types.js'
|
||||
|
||||
const isToolShelf = (msg: Msg | undefined) =>
|
||||
Boolean(msg?.kind === 'trail' && !msg.text && !msg.thinking?.trim() && msg.tools?.length)
|
||||
import { appendToolShelfMessage } from './liveProgress.js'
|
||||
|
||||
export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => {
|
||||
if (isToolShelf(msg) && isToolShelf(prev.at(-1))) {
|
||||
const last = prev.at(-1)!
|
||||
|
||||
return [...prev.slice(0, -1), { ...last, tools: [...(last.tools ?? []), ...(msg.tools ?? [])] }]
|
||||
}
|
||||
|
||||
return [...prev, msg]
|
||||
}
|
||||
export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => appendToolShelfMessage(prev, msg)
|
||||
|
||||
export const upsert = (prev: Msg[], role: Role, text: string): Msg[] =>
|
||||
prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export interface Msg {
|
|||
thinkingTokens?: number
|
||||
toolTokens?: number
|
||||
tools?: string[]
|
||||
todos?: TodoItem[]
|
||||
}
|
||||
|
||||
export type Role = 'assistant' | 'system' | 'tool' | 'user'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue