Keep local main from replaying answered AskUserQuestion prompts

Merge the worktree fix that preserves CLI AskUserQuestion toolUseResult metadata across history replay and live WebSocket translation, then filters stale unresolved cards when no pending permission is active.

Constraint: main had advanced past the worktree base, so this requires a non-fast-forward local merge.
Confidence: high
Scope-risk: moderate
Directive: Keep AskUserQuestion rendering driven by CLI transcript metadata instead of desktop-only lifecycle inference.
Tested: git merge-tree 3f384a1d5f5f48f0c7eb848b2dfacb4527fd5059 main 853e3db6ecbeb9a39f333c65aa7eddc32d613d7b
Tested: worktree commit already passed server focused tests, desktop focused tests, check:server, desktop lint, desktop build, and git diff --check.
Not-tested: Re-running full verification after the merge commit.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 15:58:57 +08:00
commit 28ddcd0afd
9 changed files with 221 additions and 2 deletions

View File

@ -251,6 +251,53 @@ describe('MessageList nested tool calls', () => {
)).toEqual(['answered-tool', 'active-tool'])
})
it('keeps only the latest unresolved AskUserQuestion when no pending permission is active', () => {
const messages: UIMessage[] = [
{
id: 'first-ask',
type: 'tool_use',
toolName: 'AskUserQuestion',
toolUseId: 'first-tool',
input: {
questions: [
{
question: 'First question?',
options: [{ label: 'A' }, { label: 'B' }],
},
],
},
timestamp: 1,
},
{
id: 'second-ask',
type: 'tool_use',
toolName: 'AskUserQuestion',
toolUseId: 'second-tool',
input: {
questions: [
{
question: 'Second question?',
options: [{ label: 'A' }, { label: 'B' }],
},
],
},
timestamp: 2,
},
]
const { renderItems } = buildRenderModel(messages, null)
expect(renderItems).toHaveLength(1)
expect(renderItems[0]).toMatchObject({
kind: 'message',
message: {
type: 'tool_use',
toolName: 'AskUserQuestion',
toolUseId: 'second-tool',
},
})
})
it('renders goal events as visible status cards', () => {
useChatStore.setState({
sessions: {

View File

@ -453,6 +453,7 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo
const childToolCallsByParent = new Map<string, ToolCall[]>()
const toolUseIds = new Set<string>()
const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map<string, number>()
let lastUnresolvedAskUserQuestionIndex: number | null = null
let pendingToolCalls: ToolCall[] = []
const flushGroup = () => {
@ -490,6 +491,7 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo
!toolResultMap.has(msg.toolUseId)
) {
lastUnresolvedAskUserQuestionIndexByToolUseId.set(msg.toolUseId, index)
lastUnresolvedAskUserQuestionIndex = index
}
})
@ -527,6 +529,14 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo
) {
continue
}
if (
!isResolved &&
!activeAskUserQuestionToolUseId &&
lastUnresolvedAskUserQuestionIndex !== null &&
messages[lastUnresolvedAskUserQuestionIndex] !== msg
) {
continue
}
flushGroup()
items.push({ kind: 'message', message: msg })
} else {

View File

@ -223,6 +223,63 @@ describe('chatStore history mapping', () => {
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
})
it('maps AskUserQuestion transcript answers from toolUseResult metadata', () => {
const messages: MessageEntry[] = [
{
id: 'assistant-ask',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{
type: 'tool_use',
name: 'AskUserQuestion',
id: 'ask-1',
input: {
questions: [
{
question: 'Pick one?',
options: [{ label: 'A' }, { label: 'B' }],
},
],
},
},
],
},
{
id: 'user-answer',
type: 'tool_result',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'ask-1',
content: 'User has answered your questions: "Pick one?"="A". You can now continue with the user\'s answers in mind.',
},
],
toolUseResult: {
questions: [
{
question: 'Pick one?',
options: [{ label: 'A' }, { label: 'B' }],
},
],
answers: { 'Pick one?': 'A' },
},
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toHaveLength(2)
expect(mapped[1]).toMatchObject({
type: 'tool_result',
toolUseId: 'ask-1',
content: {
answers: { 'Pick one?': 'A' },
},
})
})
it('maps compact boundary and summary history without hiding pre-compact messages', () => {
const messages: MessageEntry[] = [
{

View File

@ -1663,6 +1663,16 @@ function readRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>
}
function normalizeHistoryToolResultContent(content: unknown, toolUseResult: unknown): unknown {
const result = readRecord(toolUseResult)
const answers = readRecord(result?.answers)
if (!result || !answers || !Array.isArray(result.questions)) return content
return {
questions: result.questions,
answers,
}
}
function parseJsonRecord(value: unknown): Record<string, unknown> | null {
const record = readRecord(value)
if (record) return record
@ -2313,7 +2323,15 @@ export function mapHistoryMessagesToUiMessages(
}
else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
else if (block.type === 'tool_result') uiMessages.push({
id: nextId(),
type: 'tool_result',
toolUseId: block.tool_use_id ?? '',
content: normalizeHistoryToolResultContent(block.content, msg.toolUseResult),
isError: !!block.is_error,
timestamp,
parentToolUseId: msg.parentToolUseId,
})
}
if (textParts.length > 0 || attachments.length > 0) {
const parsed = extractLeadingFileReferences(textParts.join('\n'))

View File

@ -16,6 +16,7 @@ export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
toolUseResult?: unknown
timestamp: string
model?: string
parentUuid?: string

View File

@ -643,6 +643,42 @@ describe('SessionService', () => {
expect(messages).toHaveLength(2)
})
it('preserves structured toolUseResult metadata for AskUserQuestion answers', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
{
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'ask-1',
content: 'User has answered your questions: "Pick one?"="A". You can now continue with the user\'s answers in mind.',
},
],
},
toolUseResult: {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
answers: { 'Pick one?': 'A' },
},
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:01.000Z',
},
])
const messages = await service.getSessionMessages(sessionId)
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
type: 'tool_result',
toolUseResult: {
answers: { 'Pick one?': 'A' },
},
})
})
it('should append subagent tool calls under their parent agent tool result', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-project'

View File

@ -39,6 +39,39 @@ describe('WebSocket memory events', () => {
})
})
describe('WebSocket AskUserQuestion events', () => {
it('forwards structured AskUserQuestion answers from CLI toolUseResult metadata', () => {
expect(translateCliMessage({
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'ask-1',
content: 'User has answered your questions: "Pick one?"="A". You can now continue with the user\'s answers in mind.',
},
],
},
toolUseResult: {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
answers: { 'Pick one?': 'A' },
},
}, 'session-1')).toEqual([
{
type: 'tool_result',
toolUseId: 'ask-1',
content: {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
answers: { 'Pick one?': 'A' },
},
isError: false,
parentToolUseId: undefined,
},
])
})
})
describe('WebSocket compact events', () => {
it('forwards CLI compacting status to the desktop client', () => {
expect(translateCliMessage({

View File

@ -78,6 +78,7 @@ export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
toolUseResult?: unknown
timestamp: string
model?: string
parentUuid?: string
@ -425,6 +426,7 @@ export class SessionService {
id: entry.uuid || crypto.randomUUID(),
type,
content: msg.content,
...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}),
timestamp: entry.timestamp || new Date().toISOString(),
model: msg.model,
parentUuid: entry.parentUuid ?? undefined,

View File

@ -849,6 +849,21 @@ function extractAssistantText(cliMsg: any): string {
return textBlock?.text || ''
}
function readObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function normalizeAskUserQuestionToolResult(content: unknown, toolUseResult: unknown): unknown {
const result = readObject(toolUseResult)
const answers = readObject(result?.answers)
if (!result || !answers || !Array.isArray(result.questions)) return content
return {
questions: result.questions,
answers,
}
}
function isDuplicateOfLastApiError(
lastApiError: SessionStreamState['lastApiError'],
resultMessage: string,
@ -1044,7 +1059,7 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
messages.push({
type: 'tool_result',
toolUseId: block.tool_use_id,
content: block.content,
content: normalizeAskUserQuestionToolResult(block.content, cliMsg.toolUseResult),
isError: !!block.is_error,
parentToolUseId,
})