Files
hapi/web/src/chat/reducer.ts
T
weishu 9cc13b51be feat: add todo tracking and custom tool views
Adds todo tracking infrastructure with TodoWrite tool integration to extract
and persist todos in sessions. Refactors ToolCard component to support
custom tool view rendering and displays todo progress in SessionList.
2025-12-17 11:45:17 +08:00

553 lines
20 KiB
TypeScript

import type { AgentState } from '@/types/api'
import type { AgentEvent, ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types'
import { traceMessages, type TracedMessage } from '@/chat/tracer'
// Calculate context size from usage data
function calculateContextSize(usage: UsageData): number {
return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens
}
function parseClaudeUsageLimit(text: string): number | null {
const match = text.match(/^Claude AI usage limit reached\|(\d+)$/)
if (!match) return null
const timestamp = Number.parseInt(match[1], 10)
if (!Number.isFinite(timestamp)) return null
return timestamp
}
function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null {
if (msg.isSidechain) return null
if (msg.role !== 'agent') return null
for (const content of msg.content) {
if (content.type === 'text') {
const limitReached = parseClaudeUsageLimit(content.text)
if (limitReached !== null) {
return { type: 'limit-reached', endsAt: limitReached }
}
}
}
return null
}
function extractTitleFromChangeTitleInput(input: unknown): string | null {
if (!input || typeof input !== 'object') return null
const title = (input as { title?: unknown }).title
return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null
}
function collectTitleChanges(messages: NormalizedMessage[]): Map<string, string> {
const map = new Map<string, string>()
for (const msg of messages) {
if (msg.role !== 'agent') continue
for (const content of msg.content) {
if (content.type !== 'tool-call') continue
if (content.name !== 'mcp__happy__change_title') continue
const title = extractTitleFromChangeTitleInput(content.input)
if (!title) continue
map.set(content.id, title)
}
}
return map
}
function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] {
const result: ChatBlock[] = []
let prevEventKey: string | null = null
let prevTitleChangedTo: string | null = null
for (const block of blocks) {
if (block.kind !== 'agent-event') {
result.push(block)
prevEventKey = null
prevTitleChangedTo = null
continue
}
const event = block.event as { type: string; [key: string]: unknown }
if (event.type === 'title-changed' && typeof event.title === 'string') {
const title = event.title.trim()
const key = `title-changed:${title}`
if (key === prevEventKey) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = title
continue
}
if (event.type === 'message' && typeof event.message === 'string') {
const message = event.message.trim()
const key = `message:${message}`
if (key === prevEventKey) {
continue
}
if (prevTitleChangedTo && message === prevTitleChangedTo) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = null
continue
}
let key: string
try {
key = `event:${JSON.stringify(event)}`
} catch {
key = `event:${String(event.type)}`
}
if (key === prevEventKey) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = null
}
return result
}
type PermissionEntry = {
toolName: string
input: unknown
permission: ToolPermission
}
function getPermissions(agentState: AgentState | null | undefined): Map<string, PermissionEntry> {
const map = new Map<string, PermissionEntry>()
const completed = agentState?.completedRequests ?? null
if (completed) {
for (const [id, entry] of Object.entries(completed)) {
map.set(id, {
toolName: entry.tool,
input: entry.arguments,
permission: {
id,
status: entry.status,
reason: entry.reason ?? undefined,
mode: entry.mode ?? undefined,
decision: entry.decision ?? undefined,
allowedTools: entry.allowTools,
createdAt: entry.createdAt ?? null,
completedAt: entry.completedAt ?? null
}
})
}
}
const requests = agentState?.requests ?? null
if (requests) {
for (const [id, request] of Object.entries(requests)) {
if (map.has(id)) continue
map.set(id, {
toolName: request.tool,
input: request.arguments,
permission: {
id,
status: 'pending',
createdAt: request.createdAt ?? null
}
})
}
}
return map
}
function ensureToolBlock(
blocks: ChatBlock[],
toolBlocksById: Map<string, ToolCallBlock>,
id: string,
seed: {
createdAt: number
localId: string | null
meta?: unknown
name: string
input: unknown
description: string | null
permission?: ToolPermission
}
): ToolCallBlock {
const existing = toolBlocksById.get(id)
if (existing) {
const isPlaceholderToolName = (name: string): boolean => {
const normalized = name.trim().toLowerCase()
return normalized === '' || normalized === 'tool' || normalized === 'unknown'
}
// Preserve earliest createdAt for stable ordering.
if (seed.createdAt < existing.createdAt) {
existing.createdAt = seed.createdAt
existing.tool.createdAt = seed.createdAt
}
if (seed.permission) {
existing.tool.permission = { ...existing.tool.permission, ...seed.permission }
if (existing.tool.state === 'running' && seed.permission.status === 'pending') {
existing.tool.state = 'pending'
}
}
if (seed.name && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name))) {
existing.tool.name = seed.name
}
if (seed.input !== null && seed.input !== undefined) {
existing.tool.input = seed.input
}
if (seed.description !== null) {
existing.tool.description = seed.description
}
return existing
}
const initialState: ChatToolCall['state'] = seed.permission?.status === 'pending'
? 'pending'
: seed.permission?.status === 'denied' || seed.permission?.status === 'canceled'
? 'error'
: 'running'
const tool: ChatToolCall = {
id,
name: seed.name,
state: initialState,
input: seed.input,
createdAt: seed.createdAt,
startedAt: initialState === 'running' ? seed.createdAt : null,
completedAt: null,
description: seed.description,
permission: seed.permission
}
const block: ToolCallBlock = {
kind: 'tool-call',
id,
localId: seed.localId,
createdAt: seed.createdAt,
tool,
children: [],
meta: seed.meta
}
toolBlocksById.set(id, block)
blocks.push(block)
return block
}
function collectToolIdsFromMessages(messages: NormalizedMessage[]): Set<string> {
const ids = new Set<string>()
for (const msg of messages) {
if (msg.role !== 'agent') continue
for (const content of msg.content) {
if (content.type === 'tool-call') {
ids.add(content.id)
} else if (content.type === 'tool-result') {
ids.add(content.tool_use_id)
}
}
}
return ids
}
function reduceTimeline(
messages: TracedMessage[],
context: {
permissionsById: Map<string, PermissionEntry>
groups: Map<string, TracedMessage[]>
consumedGroupIds: Set<string>
titleChangesByToolUseId: Map<string, string>
emittedTitleChangeToolUseIds: Set<string>
}
): { blocks: ChatBlock[]; toolBlocksById: Map<string, ToolCallBlock>; hasReadyEvent: boolean } {
const blocks: ChatBlock[] = []
const toolBlocksById = new Map<string, ToolCallBlock>()
let hasReadyEvent = false
for (const msg of messages) {
if (msg.role === 'event') {
if (msg.content.type === 'ready') {
hasReadyEvent = true
continue
}
blocks.push({
kind: 'agent-event',
id: msg.id,
createdAt: msg.createdAt,
event: msg.content,
meta: msg.meta
})
continue
}
const event = parseMessageAsEvent(msg)
if (event) {
blocks.push({
kind: 'agent-event',
id: msg.id,
createdAt: msg.createdAt,
event,
meta: msg.meta
})
continue
}
if (msg.role === 'user') {
blocks.push({
kind: 'user-text',
id: msg.id,
localId: msg.localId,
createdAt: msg.createdAt,
text: msg.content.text,
status: msg.status,
originalText: msg.originalText,
meta: msg.meta
})
continue
}
if (msg.role === 'agent') {
for (let idx = 0; idx < msg.content.length; idx += 1) {
const c = msg.content[idx]
if (c.type === 'text') {
blocks.push({
kind: 'agent-text',
id: `${msg.id}:${idx}`,
localId: msg.localId,
createdAt: msg.createdAt,
text: c.text,
meta: msg.meta
})
continue
}
if (c.type === 'summary') {
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'message', message: c.summary },
meta: msg.meta
})
continue
}
if (c.type === 'tool-call') {
if (c.name === 'mcp__happy__change_title') {
const title = context.titleChangesByToolUseId.get(c.id) ?? extractTitleFromChangeTitleInput(c.input)
if (title && !context.emittedTitleChangeToolUseIds.has(c.id)) {
context.emittedTitleChangeToolUseIds.add(c.id)
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'title-changed', title },
meta: msg.meta
})
}
continue
}
const permission = context.permissionsById.get(c.id)?.permission
const block = ensureToolBlock(blocks, toolBlocksById, c.id, {
createdAt: msg.createdAt,
localId: msg.localId,
meta: msg.meta,
name: c.name,
input: c.input,
description: c.description,
permission
})
if (block.tool.state === 'pending') {
block.tool.state = 'running'
block.tool.startedAt = msg.createdAt
}
if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) {
const sidechain = context.groups.get(msg.id) ?? null
if (sidechain && sidechain.length > 0) {
context.consumedGroupIds.add(msg.id)
const child = reduceTimeline(sidechain, context)
hasReadyEvent = hasReadyEvent || child.hasReadyEvent
block.children = child.blocks
}
}
continue
}
if (c.type === 'tool-result') {
const title = context.titleChangesByToolUseId.get(c.tool_use_id) ?? null
if (title) {
if (!context.emittedTitleChangeToolUseIds.has(c.tool_use_id)) {
context.emittedTitleChangeToolUseIds.add(c.tool_use_id)
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'title-changed', title },
meta: msg.meta
})
}
continue
}
const permissionEntry = context.permissionsById.get(c.tool_use_id)
const permissionFromResult = c.permissions ? ({
id: c.tool_use_id,
status: c.permissions.result === 'approved' ? 'approved' : 'denied',
date: c.permissions.date,
mode: c.permissions.mode,
allowedTools: c.permissions.allowedTools,
decision: c.permissions.decision
} satisfies ToolPermission) : undefined
const permission = (() => {
if (permissionFromResult && permissionEntry?.permission) {
return {
...permissionEntry.permission,
...permissionFromResult,
allowedTools: permissionFromResult.allowedTools ?? permissionEntry.permission.allowedTools,
decision: permissionFromResult.decision ?? permissionEntry.permission.decision
} satisfies ToolPermission
}
return permissionFromResult ?? permissionEntry?.permission
})()
const block = ensureToolBlock(blocks, toolBlocksById, c.tool_use_id, {
createdAt: msg.createdAt,
localId: msg.localId,
meta: msg.meta,
name: permissionEntry?.toolName ?? 'Tool',
input: permissionEntry?.input ?? null,
description: null,
permission
})
block.tool.result = c.content
block.tool.completedAt = msg.createdAt
block.tool.state = c.is_error ? 'error' : 'completed'
continue
}
if (c.type === 'sidechain') {
blocks.push({
kind: 'user-text',
id: `${msg.id}:${idx}`,
localId: null,
createdAt: msg.createdAt,
text: c.prompt
})
}
}
}
}
return { blocks, toolBlocksById, hasReadyEvent }
}
export type LatestUsage = {
inputTokens: number
outputTokens: number
cacheCreation: number
cacheRead: number
contextSize: number
timestamp: number
}
export function reduceChatBlocks(
normalized: NormalizedMessage[],
agentState: AgentState | null | undefined
): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null } {
const permissionsById = getPermissions(agentState)
const toolIdsInMessages = collectToolIdsFromMessages(normalized)
const titleChangesByToolUseId = collectTitleChanges(normalized)
const traced = traceMessages(normalized)
const groups = new Map<string, TracedMessage[]>()
const root: TracedMessage[] = []
for (const msg of traced) {
if (msg.sidechainId) {
const existing = groups.get(msg.sidechainId) ?? []
existing.push(msg)
groups.set(msg.sidechainId, existing)
} else {
root.push(msg)
}
}
const consumedGroupIds = new Set<string>()
const emittedTitleChangeToolUseIds = new Set<string>()
const reducerContext = { permissionsById, groups, consumedGroupIds, titleChangesByToolUseId, emittedTitleChangeToolUseIds }
const rootResult = reduceTimeline(root, reducerContext)
let hasReadyEvent = rootResult.hasReadyEvent
// If a group couldn't be attached to a Task tool call (e.g. legacy shapes), keep it visible.
for (const [taskMessageId, sidechainMessages] of groups) {
if (consumedGroupIds.has(taskMessageId)) continue
if (sidechainMessages.length === 0) continue
const child = reduceTimeline(sidechainMessages, reducerContext)
hasReadyEvent = hasReadyEvent || child.hasReadyEvent
rootResult.blocks.push({
kind: 'agent-event',
id: `sidechain:${taskMessageId}`,
createdAt: sidechainMessages[0].createdAt,
event: { type: 'message', message: 'Task sidechain' }
})
rootResult.blocks.push(...child.blocks)
}
// Only create permission-only tool cards when there is no tool call/result in the transcript.
for (const [id, entry] of permissionsById) {
if (toolIdsInMessages.has(id)) continue
if (rootResult.toolBlocksById.has(id)) continue
const createdAt = entry.permission.createdAt ?? Date.now()
const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, {
createdAt,
localId: null,
name: entry.toolName,
input: entry.input,
description: null,
permission: entry.permission
})
if (entry.permission.status === 'approved') {
block.tool.state = 'completed'
block.tool.completedAt = entry.permission.completedAt ?? createdAt
if (block.tool.result === undefined) {
block.tool.result = 'Approved'
}
} else if (entry.permission.status === 'denied' || entry.permission.status === 'canceled') {
block.tool.state = 'error'
block.tool.completedAt = entry.permission.completedAt ?? createdAt
if (block.tool.result === undefined && entry.permission.reason) {
block.tool.result = { error: entry.permission.reason }
}
}
}
// Calculate latest usage from messages (find the most recent message with usage data)
let latestUsage: LatestUsage | null = null
for (let i = normalized.length - 1; i >= 0; i--) {
const msg = normalized[i]
if (msg.usage) {
latestUsage = {
inputTokens: msg.usage.input_tokens,
outputTokens: msg.usage.output_tokens,
cacheCreation: msg.usage.cache_creation_input_tokens ?? 0,
cacheRead: msg.usage.cache_read_input_tokens ?? 0,
contextSize: calculateContextSize(msg.usage),
timestamp: msg.createdAt
}
break
}
}
return { blocks: dedupeAgentEvents(rootResult.blocks), hasReadyEvent, latestUsage }
}