From 4a2f96d5dcc23474211501588893d02fe672b764 Mon Sep 17 00:00:00 2001 From: Trae User Date: Mon, 14 Sep 2026 18:04:12 +0800 Subject: [PATCH 1/3] fix: preserve ask feedback across transcript reloads --- .../lib/questionFeedbackPersistence.test.js | 230 ++++++++++++++++++ web/src/lib/runTests.js | 2 + web/src/lib/sessionTranscript.js | 40 ++- web/src/lib/sessionTranscript.test.js | 76 +++++- .../transcriptRendererArchitecture.test.js | 18 ++ 5 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/questionFeedbackPersistence.test.js diff --git a/web/src/lib/questionFeedbackPersistence.test.js b/web/src/lib/questionFeedbackPersistence.test.js new file mode 100644 index 00000000..206cc438 --- /dev/null +++ b/web/src/lib/questionFeedbackPersistence.test.js @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { + createTranscriptState, + loadTranscriptHistory, + reduceTranscriptEvent, +} from './sessionTranscript.js'; +import { projectCollapsedTranscriptItems } from './transcriptProjection.js'; +import { + questionFeedbackForItem, +} from './questionFeedback.js'; +import { reconcileLatestCompletedTurn } from './transcriptSelfHeal.js'; + +async function run(name, fn) { + try { + await fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +function persistedToolCall(id, name) { + return { + id, + type: 'function', + function: { name, arguments: '{"questions":[]}' }, + }; +} + +function persistedAskTurn({ + id, + kind, + question = 'Q?', + answer = 'A', +}) { + const cancelled = kind === 'cancel'; + return [ + { + id: `assistant-call-${id}`, + role: 'assistant', + tool_calls: [persistedToolCall(`call-${id}`, 'AskUserQuestion')], + ts: id * 10 + 1, + }, + { + id: `tool-result-${id}`, + role: 'tool', + tool_call_id: `call-${id}`, + content: cancelled + ? '[Error] User declined to answer questions.' + : 'User has answered your questions', + metadata: { + tool_success: !cancelled, + ask_user_question_result: { + cancelled, + items: cancelled ? [] : [{ question, answer, multi_select: false }], + }, + }, + ts: id * 10 + 2, + }, + ]; +} + +function loadMessages(messages) { + return loadTranscriptHistory(createTranscriptState({ title: 'feedback' }), { + messages, + events: [], + }).state; +} + +function renderedFeedbackSequence(items, options = {}) { + const sequence = []; + for (const item of projectCollapsedTranscriptItems(items, options)) { + if (item.kind === 'tool') { + sequence.push(`tool:${item.tool?.tool || ''}`); + } else if (item.kind === 'activity_summary') { + sequence.push(`activity:${item.mode || ''}`); + } else { + sequence.push(`${item.kind}:${item.role || ''}`); + } + const feedback = questionFeedbackForItem(item); + if (feedback) sequence.push(`card:${feedback.kind}`); + } + return sequence; +} + +function assertCardsImmediatelyFollowCalls(sequence, expectedKinds) { + const askIndexes = []; + const cardKinds = []; + sequence.forEach((entry, index) => { + if (entry === 'tool:AskUserQuestion') askIndexes.push(index); + if (entry.startsWith('card:')) cardKinds.push(entry.slice('card:'.length)); + }); + assert.deepEqual(cardKinds, expectedKinds); + assert.equal(askIndexes.length, expectedKinds.length); + askIndexes.forEach((index, callIndex) => { + assert.equal(sequence[index + 1], `card:${expectedKinds[callIndex]}`); + }); +} + +function liveAskState(kind) { + const cancelled = kind === 'cancel'; + const result = { + cancelled, + items: cancelled ? [] : [{ question: 'Q?', answer: 'A', multi_select: false }], + }; + const events = [ + { type: 'message', payload: { id: `user-${kind}`, role: 'user', content: 'Ask me' }, seq: 1 }, + { + type: 'tool_start', + payload: { tool: 'AskUserQuestion', tool_call_id: `call-${kind}` }, + seq: 2, + }, + { + type: 'tool_end', + payload: { + tool: 'AskUserQuestion', + tool_call_id: `call-${kind}`, + success: !cancelled, + metadata: { ask_user_question_result: result }, + }, + seq: 3, + }, + { + type: 'message', + payload: { id: `assistant-${kind}`, role: 'assistant', content: 'Thanks' }, + seq: 4, + }, + ]; + return events.reduce( + (state, event) => reduceTranscriptEvent(state, event).state, + createTranscriptState({ title: kind, isLive: true, loadState: 'loaded' }), + ); +} + +await run('持久化提交与取消重载后卡片紧跟各自 AskUserQuestion 调用', () => { + for (const kind of ['submit', 'cancel']) { + const state = loadMessages([ + { id: `user-${kind}`, role: 'user', content: 'Ask me', ts: 1 }, + ...persistedAskTurn({ id: kind === 'submit' ? 1 : 2, kind }), + { id: `assistant-${kind}`, role: 'assistant', content: 'Thanks', ts: 99 }, + ]); + const sequence = renderedFeedbackSequence(state.items); + assertCardsImmediatelyFollowCalls(sequence, [kind]); + } +}); + +await run('实时提交与取消在回合 self-heal 后仍保持调用与卡片相邻', () => { + for (const kind of ['submit', 'cancel']) { + const live = liveAskState(kind); + const liveSequence = renderedFeedbackSequence(live.items); + assertCardsImmediatelyFollowCalls(liveSequence, [kind]); + + const canonical = loadMessages([ + { id: `user-${kind}`, role: 'user', content: 'Ask me', ts: 1 }, + ...persistedAskTurn({ id: kind === 'submit' ? 3 : 4, kind }), + { id: `assistant-${kind}`, role: 'assistant', content: 'Thanks', ts: 99 }, + ]); + const healed = reconcileLatestCompletedTurn(live, canonical); + assert.equal(healed.replaced, true); + const healedSequence = renderedFeedbackSequence(healed.state.items); + assertCardsImmediatelyFollowCalls(healedSequence, [kind]); + } +}); + +await run('transcript_replace 使用相同历史规范化并保持取消卡相邻', () => { + const previous = liveAskState('submit'); + const replaced = reduceTranscriptEvent(previous, { + type: 'transcript_replace', + payload: { + messages: [ + { id: 'user-replace', role: 'user', content: 'Ask me', ts: 1 }, + ...persistedAskTurn({ id: 8, kind: 'cancel' }), + { id: 'assistant-replace', role: 'assistant', content: 'Thanks', ts: 99 }, + ], + }, + seq: previous.lastSeq + 1, + }).state; + + assertCardsImmediatelyFollowCalls(renderedFeedbackSequence(replaced.items), ['cancel']); +}); + +await run('历史规范化保留工具结果已有的显式 tool_name', () => { + const messages = [ + { id: 'user-explicit', role: 'user', content: 'Run tool', ts: 1 }, + { + id: 'assistant-explicit', + role: 'assistant', + tool_calls: [persistedToolCall('call-explicit', 'WrongFallbackName')], + ts: 2, + }, + { + id: 'tool-explicit', + role: 'tool', + tool_name: 'ExplicitToolName', + tool_call_id: 'call-explicit', + content: 'done', + metadata: { + tool_success: true, + ask_user_question_result: { + items: [{ question: 'Q?', answer: 'A' }], + }, + }, + ts: 3, + }, + ]; + + const state = loadMessages(messages); + const tool = state.items.find((item) => item.kind === 'tool'); + assert.equal(tool?.tool?.tool, 'ExplicitToolName'); + assert.equal(messages[2].tool, undefined, '历史规范化不得改写 API 原始消息'); +}); + +await run('继续对话和同轮多次提问后每次调用只保留一张相邻卡片', () => { + const state = loadMessages([ + { id: 'user-1', role: 'user', content: 'First turn', ts: 1 }, + ...persistedAskTurn({ id: 5, kind: 'submit', question: 'Q1?', answer: 'A1' }), + ...persistedAskTurn({ id: 6, kind: 'cancel' }), + { id: 'assistant-1', role: 'assistant', content: 'First done', ts: 70 }, + { id: 'user-2', role: 'user', content: 'Second turn', ts: 80 }, + ...persistedAskTurn({ id: 7, kind: 'submit', question: 'Q2?', answer: 'A2' }), + { id: 'assistant-2', role: 'assistant', content: 'Second done', ts: 99 }, + ]); + + const sequence = renderedFeedbackSequence(state.items); + assertCardsImmediatelyFollowCalls(sequence, ['submit', 'cancel', 'submit']); + assert.equal(sequence.filter((entry) => entry.startsWith('card:')).length, 3); +}); + +console.log('questionFeedbackPersistence tests passed'); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 3ca689c5..52a90905 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -1,4 +1,6 @@ import './questionPicker.test.js'; +import './questionFeedback.test.js'; +import './questionFeedbackPersistence.test.js'; import './pendingQuestions.test.js'; import './expertMenuPosition.test.js'; import './anchoredMenuPosition.test.js'; diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js index fbed505e..3459e371 100644 --- a/web/src/lib/sessionTranscript.js +++ b/web/src/lib/sessionTranscript.js @@ -324,14 +324,19 @@ function normalizePersistedToolSummary(metadata) { function normalizeAskUserQuestionResult(metadataOrResult) { const raw = metadataOrResult?.ask_user_question_result || metadataOrResult?.askUserQuestionResult || metadataOrResult; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + // cancelled=true 是「用户拒绝作答」的落盘标记。它没有 items,但同样要在 + // 消息流里恢复成 tool item,否则「已取消全部回答」卡无从锚定、无法持久展示。 + const cancelled = raw.cancelled === true; const items = Array.isArray(raw.items) ? raw.items : []; const normalized = items .filter((item) => item && typeof item === 'object' && !Array.isArray(item)) .map((item) => ({ question: String(item.question ?? item.q ?? ''), answer: String(item.answer ?? item.a ?? ''), + multiSelect: item.multi_select === true || item.multiSelect === true, })) .filter((item) => item.question || item.answer); + if (cancelled) return { cancelled: true, items: normalized }; return normalized.length > 0 ? { items: normalized } : null; } @@ -493,6 +498,26 @@ function normalizePersistedToolCall(raw, fallbackIndex) { }; } +// REST 历史把工具名放在 assistant.tool_calls,把结果放在后续 role:tool +// 消息。按消息顺序只保留尚未消费的调用,避免跨回合复用 call id 时串名。 +function rememberPersistedToolNames(message, namesByCallId) { + if (message?.role !== 'assistant') return; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (let i = 0; i < toolCalls.length; i += 1) { + const call = normalizePersistedToolCall(toolCalls[i], i); + if (call.toolCallId && call.name && !namesByCallId.has(call.toolCallId)) { + namesByCallId.set(call.toolCallId, call.name); + } + } +} + +function withPersistedToolName(message, namesByCallId) { + if (message?.role !== 'tool' || message.tool || message.tool_name) return message; + const toolCallId = String((message.tool_call_id ?? message.toolCallId ?? '') || '').trim(); + const toolName = toolCallId ? namesByCallId.get(toolCallId) : ''; + return toolName ? { ...message, tool: toolName } : message; +} + function persistedToolCallMessageId(message, messageIndex, call) { const parentId = String(message?.id || `message-${messageIndex}`); const suffix = call.toolCallId || `index-${call.toolIndex}`; @@ -577,7 +602,7 @@ function historyItemFromMessage(next, m, messageOrdinal = null) { isTaskComplete: false, isDone: true, success, - tool: m.tool || '', + tool: m.tool || m.tool_name || '', toolCallId: m.tool_call_id || m.toolCallId || '', toolIndex: m.tool_index ?? m.toolIndex ?? null, args: null, @@ -610,7 +635,7 @@ function historyItemFromMessage(next, m, messageOrdinal = null) { isTaskComplete: false, isDone: true, success: true, - tool: m.tool || '', + tool: m.tool || m.tool_name || '', toolCallId: m.tool_call_id || m.toolCallId || '', toolIndex: m.tool_index ?? m.toolIndex ?? null, args: null, @@ -674,8 +699,17 @@ function historyItemsFromMessage(next, m, messageIndex) { function historyItemsFromMessages(next, messages) { const items = []; + const toolNamesByCallId = new Map(); for (let i = 0; i < messages.length; i += 1) { - items.push(...historyItemsFromMessage(next, messages[i], i)); + const rawMessage = messages[i]; + const message = withPersistedToolName(rawMessage, toolNamesByCallId); + items.push(...historyItemsFromMessage(next, message, i)); + if (message?.role === 'tool') { + const toolCallId = String((message.tool_call_id ?? message.toolCallId ?? '') || '').trim(); + if (toolCallId) toolNamesByCallId.delete(toolCallId); + } else { + rememberPersistedToolNames(message, toolNamesByCallId); + } } return items; } diff --git a/web/src/lib/sessionTranscript.test.js b/web/src/lib/sessionTranscript.test.js index 9aa538b6..f2a64a2f 100644 --- a/web/src/lib/sessionTranscript.test.js +++ b/web/src/lib/sessionTranscript.test.js @@ -1094,7 +1094,7 @@ run('history load 将带 tool_hunks metadata 的 tool message 恢复为 tool ite assert.deepEqual(loaded.items[1].tool.hunks, [hunk]); }); -run('history load 后 Created tool result 可在工具名缺失时从安全 hunk 恢复源码', () => { +run('history load 后 Created tool result 从匹配调用恢复工具名并保留安全 hunk 源码', () => { const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { messages: [ { id: 'u1', role: 'user', content: 'create file', ts: 1 }, @@ -1137,7 +1137,7 @@ run('history load 后 Created tool result 可在工具名缺失时从安全 hunk const activity = projected.find((item) => item.kind === 'activity_summary'); const tool = activity?.collapsedItems?.find((item) => item.kind === 'tool')?.tool; assert.ok(tool); - assert.equal(tool.tool, ''); + assert.equal(tool.tool, 'file_write'); assert.equal(tool.args, null); assert.deepEqual(createdFileSource(tool), { path: 'src/index.html', @@ -1195,7 +1195,7 @@ run('history load 将 AskUserQuestion metadata 恢复为确认卡片工具项', assert.equal(loaded.items[1].kind, 'tool'); assert.equal(loaded.items[1].tool.toolCallId, 'call-ask'); assert.deepEqual(loaded.items[1].tool.askUserQuestionResult.items, [ - { question: '希望我直接修改还是先给出方案让你确认?', answer: '直接修改并补测试' }, + { question: '希望我直接修改还是先给出方案让你确认?', answer: '直接修改并补测试', multiSelect: false }, ]); }); @@ -1364,6 +1364,76 @@ run('history load 同名并行 persisted tool_calls 按 tool_call_id 匹配各 assert.equal(JSON.stringify(projected).includes('请求未记录'), false); }); +run('history load 跨回合复用 tool_call_id 时只绑定当前调用', () => { + const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { + messages: [ + { id: 'u1', role: 'user', content: 'run command', ts: 1 }, + { + id: 'a1', + role: 'assistant', + content: '', + tool_calls: [persistedToolCall('reused-call', 'shell_command', '{"command":"pwd"}', 0)], + ts: 2, + }, + { id: 't1', role: 'tool', content: 'C:/repo', tool_call_id: 'reused-call', ts: 3 }, + { id: 'u2', role: 'user', content: 'answer questions', ts: 4 }, + { + id: 'a2', + role: 'assistant', + content: '', + tool_calls: [persistedToolCall('reused-call', 'AskUserQuestion', '{}', 0)], + ts: 5, + }, + { + id: 't2', + role: 'tool', + content: 'answered', + tool_call_id: 'reused-call', + ts: 6, + metadata: { + ask_user_question_result: { items: [{ question: 'Q?', answer: 'A' }] }, + }, + }, + ], + events: [], + }).state; + + assert.deepEqual(loaded.items + .filter((item) => item.kind === 'tool' || item.role === 'tool') + .map((item) => item.tool?.tool || item.tool_name), [ + 'shell_command', + 'AskUserQuestion', + ]); +}); + +run('history load 对未来才出现的 tool_call 不进行猜测匹配', () => { + const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { + messages: [ + { + id: 't1', + role: 'tool', + content: 'answered', + tool_call_id: 'future-call', + ts: 1, + metadata: { + ask_user_question_result: { items: [{ question: 'Q?', answer: 'A' }] }, + }, + }, + { + id: 'a1', + role: 'assistant', + content: '', + tool_calls: [persistedToolCall('future-call', 'AskUserQuestion', '{}', 0)], + ts: 2, + }, + ], + events: [], + }).state; + + assert.equal(loaded.items[0].tool_name, undefined); + assert.equal(loaded.items[0].tool?.tool, ''); +}); + run('history load 对确实缺少请求的 tool result 保留请求未记录 fallback', () => { const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { messages: [ diff --git a/web/src/lib/transcriptRendererArchitecture.test.js b/web/src/lib/transcriptRendererArchitecture.test.js index 89c264fb..77201962 100644 --- a/web/src/lib/transcriptRendererArchitecture.test.js +++ b/web/src/lib/transcriptRendererArchitecture.test.js @@ -89,6 +89,24 @@ run('shared renderer is the only chat surface that dispatches projected item kin } }); +run('top-level feedback extension renders immediately after its transcript item', () => { + const chat = source('components/ChatView.jsx'); + const renderer = source('components/TranscriptItems.jsx'); + + assert.match(chat, /renderAfterItem=\{renderFeedbackAfterQuestion\}/); + assert.match(renderer, /const after = !nested \? renderAfterItem\?\.\(item\) : null;/); + + const mapStart = renderer.indexOf('return list.map((item, index) => {'); + const itemStart = renderer.indexOf('', itemStart); + const after = renderer.indexOf('{after}', itemEnd); + const fragmentEnd = renderer.indexOf('', after); + assert.ok(mapStart >= 0 && itemStart > mapStart); + assert.ok(itemEnd > itemStart && after > itemEnd); + assert.match(renderer.slice(itemEnd + 2, after), /^\s*$/); + assert.ok(fragmentEnd > after); +}); + run('sub-agent transcript uses full collapse projection with explicit read-only capabilities', () => { const panel = source('components/SubagentPanel.jsx'); const helper = source('lib/subagentTranscript.js'); From 4f5842040f1c283c08ed21cff28030ff8f6d3517 Mon Sep 17 00:00:00 2001 From: Trae User Date: Mon, 14 Sep 2026 19:01:11 +0800 Subject: [PATCH 2/3] fix: disambiguate hook mapping test construction --- tests/hooks/hook_runtime_test.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/hooks/hook_runtime_test.cpp b/tests/hooks/hook_runtime_test.cpp index ed1cd15d..ad264bca 100644 --- a/tests/hooks/hook_runtime_test.cpp +++ b/tests/hooks/hook_runtime_test.cpp @@ -68,7 +68,8 @@ TEST(HookRuntime, MatcherAliasesMapCodexNamesToAceCodeTools) { // 三套命名词汇并存时用户写的 matcher 静默失效。 TEST(HookRuntime, MatcherAcceptsModelFacingAliasWhenRewriteActive) { { - acecode::ScopedModelToolNameMappings scoped({{"file_write", "write"}}); + acecode::ScopedModelToolNameMappings scoped( + acecode::ToolProtocolNameMappings{{"file_write", "write"}}); EXPECT_TRUE(acecode::hook_matcher_matches( make_hook("h5", acecode::kCodexHookEventPreToolUse, "write"), acecode::kCodexHookEventPreToolUse, @@ -78,7 +79,7 @@ TEST(HookRuntime, MatcherAcceptsModelFacingAliasWhenRewriteActive) { } // 映射关闭时 "write" 不再是别名:canonical 值原样返回(matcher 本身仍可能 // 按正则子串命中 file_write,那是既有的正则语义,不在本用例范围内)。 - acecode::ScopedModelToolNameMappings none({}); + acecode::ScopedModelToolNameMappings none(acecode::ToolProtocolNameMappings{}); EXPECT_EQ(acecode::canonical_hook_match_value("write"), "write"); EXPECT_EQ(acecode::canonical_hook_match_value("Write"), "file_write"); } From 63b77f76f5edf713acb1a7cdae8b861d42c4961f Mon Sep 17 00:00:00 2001 From: Trae User Date: Mon, 14 Sep 2026 19:28:42 +0800 Subject: [PATCH 3/3] fix: support initializer lists for scoped mappings --- src/tool/tool_protocol_names.cpp | 5 +++++ src/tool/tool_protocol_names.hpp | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/tool/tool_protocol_names.cpp b/src/tool/tool_protocol_names.cpp index 7c1b8d1f..dcb46277 100644 --- a/src/tool/tool_protocol_names.cpp +++ b/src/tool/tool_protocol_names.cpp @@ -105,6 +105,11 @@ ScopedModelToolNameMappings::ScopedModelToolNameMappings( set_model_tool_name_mappings(std::move(mappings)); } +ScopedModelToolNameMappings::ScopedModelToolNameMappings( + std::initializer_list mappings) + : ScopedModelToolNameMappings( + ToolProtocolNameMappings(mappings.begin(), mappings.end())) {} + ScopedModelToolNameMappings::~ScopedModelToolNameMappings() { set_model_tool_name_mappings(previous_); } diff --git a/src/tool/tool_protocol_names.hpp b/src/tool/tool_protocol_names.hpp index e41e8a42..34feaa07 100644 --- a/src/tool/tool_protocol_names.hpp +++ b/src/tool/tool_protocol_names.hpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -38,6 +39,8 @@ bool set_model_tool_name_mappings(ToolProtocolNameMappings mappings, class ScopedModelToolNameMappings { public: explicit ScopedModelToolNameMappings(ToolProtocolNameMappings mappings); + ScopedModelToolNameMappings( + std::initializer_list mappings); ~ScopedModelToolNameMappings(); ScopedModelToolNameMappings(const ScopedModelToolNameMappings&) = delete; ScopedModelToolNameMappings& operator=(const ScopedModelToolNameMappings&) = delete;