mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: keep AskUserQuestion state aligned with CLI answers
The CLI transcript already records each AskUserQuestion once, but the desktop bridge dropped the structured toolUseResult answers from both history replay and live WebSocket tool results. That made answered question cards look unresolved and allowed stale cards to reappear during the gap before the next permission request. Constraint: Desktop should render the CLI question stream directly without inventing separate question lifecycle state. Rejected: Hide all previous AskUserQuestion cards after submit | would remove resolved answer history instead of preserving transcript context. Confidence: high Scope-risk: moderate Directive: Preserve toolUseResult metadata when adapting CLI transcript entries; AskUserQuestion answer state depends on the structured answers object, not the prose tool_result string. Tested: bun test src/server/__tests__/sessions.test.ts --timeout 30000 Tested: bun test src/server/__tests__/ws-memory-events.test.ts --timeout 30000 Tested: cd desktop && bun run test src/stores/chatStore.test.ts Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx Tested: bun run check:server Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: git diff --check Not-tested: Full bun run verify and live desktop browser smoke.
This commit is contained in:
parent
3f384a1d5f
commit
853e3db6ec
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user