feat: add permission decision options, tool allowlisting, and refactored chat UI components

This commit is contained in:
weishu
2025-12-17 07:57:37 +08:00
parent c2b886896b
commit 0fa05dc205
17 changed files with 2689 additions and 86 deletions
+512
View File
@@ -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 }
}