mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
commit
28ddcd0afd
@ -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