mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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.
553 lines
20 KiB
TypeScript
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 }
|
|
}
|