mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
feat: add permission decision options, tool allowlisting, and refactored chat UI components
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
import type { AgentState } from '@/types/api'
|
||||
import type { AgentEvent, ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission } from '@/chat/types'
|
||||
import { traceMessages, type TracedMessage } from '@/chat/tracer'
|
||||
|
||||
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) {
|
||||
// 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) {
|
||||
existing.tool.name = seed.name
|
||||
}
|
||||
existing.tool.input = seed.input
|
||||
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 function reduceChatBlocks(
|
||||
normalized: NormalizedMessage[],
|
||||
agentState: AgentState | null | undefined
|
||||
): { blocks: ChatBlock[]; hasReadyEvent: boolean } {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocks: dedupeAgentEvents(rootResult.blocks), hasReadyEvent }
|
||||
}
|
||||
Reference in New Issue
Block a user