mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): stop pinning resolved permission cards to the bottom of the chat (#974)
agentState keeps an answered permission request in completedRequests. When its tool_use message is not in the loaded window, the permission-only synthesis appended a card to the end of the timeline — and there is no chronological re-sort, so the card stays pinned above the composer as a stale "answered" card that never moves to its place in history. With several answered asks this piles up at the bottom of the chat. Synthesize a card only for a *pending* request (the case that needs an answerable card when its message hasn't loaded). A resolved request is history and renders only via its own message when that message is in the window.
This commit is contained in:
@@ -3,7 +3,7 @@ import { reduceChatBlocks } from './reducer'
|
||||
import { normalizeDecryptedMessage } from './normalize'
|
||||
import type { NormalizedMessage } from './types'
|
||||
import type { DecryptedMessage } from '@/types/api'
|
||||
import type { ThreadGoal, ThreadGoalStatus } from '@/types/api'
|
||||
import type { AgentState, ThreadGoal, ThreadGoalStatus } from '@/types/api'
|
||||
|
||||
function userMessage(id: string, text: string, createdAt: number): NormalizedMessage {
|
||||
return {
|
||||
@@ -279,4 +279,42 @@ describe('reduceChatBlocks', () => {
|
||||
tokensUsed: 8016
|
||||
})
|
||||
})
|
||||
|
||||
it('does not pin a resolved request as a bottom card when its message is not in the window', () => {
|
||||
// agentState keeps completedRequests after an ask is answered. With no
|
||||
// tool_use message loaded for it, the permission-only synthesis used to
|
||||
// append an "answered" card at the end of the timeline (no re-sort),
|
||||
// pinning it above the composer forever.
|
||||
const messages = [userMessage('u1', 'hello', 1_700_000_000_000)]
|
||||
const agentState = {
|
||||
requests: {},
|
||||
completedRequests: {
|
||||
'ask-done': {
|
||||
tool: 'AskUserQuestion',
|
||||
arguments: { questions: [] },
|
||||
status: 'approved',
|
||||
createdAt: 1_700_000_000_500,
|
||||
completedAt: 1_700_000_000_600
|
||||
}
|
||||
}
|
||||
} as unknown as AgentState
|
||||
|
||||
const reduced = reduceChatBlocks(messages, agentState)
|
||||
expect(reduced.blocks.some(b => b.kind === 'tool-call' && b.id === 'ask-done')).toBe(false)
|
||||
})
|
||||
|
||||
it('still synthesizes a card for a pending request with no message in the window', () => {
|
||||
const messages = [userMessage('u1', 'hello', 1_700_000_000_000)]
|
||||
const agentState = {
|
||||
requests: {
|
||||
'ask-pending': { tool: 'AskUserQuestion', arguments: { questions: [] }, createdAt: 1_700_000_000_500 }
|
||||
},
|
||||
completedRequests: {}
|
||||
} as unknown as AgentState
|
||||
|
||||
const reduced = reduceChatBlocks(messages, agentState)
|
||||
const block = reduced.blocks.find(b => b.kind === 'tool-call' && b.id === 'ask-pending')
|
||||
expect(block).toBeDefined()
|
||||
expect(block?.kind === 'tool-call' ? block.tool.permission?.status : null).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
+13
-18
@@ -118,14 +118,23 @@ export function reduceChatBlocks(
|
||||
const rootResult = reduceTimeline(root, reducerContext)
|
||||
let hasReadyEvent = rootResult.hasReadyEvent
|
||||
|
||||
// Only create permission-only tool cards when there is no tool call/result in the transcript.
|
||||
// Also skip if the permission is older than the oldest message in the current view,
|
||||
// to avoid mixing old tool cards with newer messages when paginating.
|
||||
// Synthesize a tool card only for a *pending* permission that has no tool
|
||||
// call/result in the transcript — so the user can still answer it when its
|
||||
// tool_use message hasn't loaded. A resolved request (approved/denied/
|
||||
// canceled) is history: agentState keeps it in completedRequests, but
|
||||
// synthesizing it here appends a card to the end of the timeline (there is
|
||||
// no chronological re-sort), pinning a stale "answered" card above the
|
||||
// composer forever. Resolved requests render only via their own message,
|
||||
// when it is in the window.
|
||||
// Also skip if the permission is older than the oldest message in the
|
||||
// current view, to avoid mixing old tool cards with newer messages when
|
||||
// paginating.
|
||||
const oldestMessageTime = normalized.length > 0
|
||||
? Math.min(...normalized.map(m => m.createdAt))
|
||||
: null
|
||||
|
||||
for (const [id, entry] of permissionsById) {
|
||||
if (entry.permission.status !== 'pending') continue
|
||||
if (toolIdsInMessages.has(id)) continue
|
||||
if (rootResult.toolBlocksById.has(id)) continue
|
||||
|
||||
@@ -137,7 +146,7 @@ export function reduceChatBlocks(
|
||||
continue
|
||||
}
|
||||
|
||||
const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, {
|
||||
ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, {
|
||||
createdAt,
|
||||
localId: null,
|
||||
name: entry.toolName,
|
||||
@@ -145,20 +154,6 @@ export function reduceChatBlocks(
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user