feat(conversation): replicate WorkBuddy rewind-to-message action (TODO 05)
This commit is contained in:
1 parent
074523a501
commit
c7ab047d52
11 files changed
+293
-60
No files matched your search
@@ -494,11 +494,15 @@ async function testFeedbackAndRevert(bundle: TestBundle, state: TestState, works
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(rejectedId), undefined)
|
||||
|
||||
const readOnlyId = await invoke('session:create', 'read-only-revert') as { id: string }
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only'))
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only', { nativeEntryId: 'native-read-only-target' }))
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-assistant', 'assistant', 'read', {
|
||||
tools: [{ id: 'read-tool', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
await assert.rejects(invoke('chat:revert', readOnlyId.id, 'read-only-user'), /没有可恢复/)
|
||||
seedNativeHistory(state, readOnlyId.id, [['native-read-only-target', 'read only']])
|
||||
// 原版 rewind 的 no-version 变体:没有产物版本时只回退对话内容,产物保持不变。
|
||||
const readOnlyResult = await invoke('chat:revert', readOnlyId.id, 'read-only-user') as { messages: unknown[] }
|
||||
assert.deepEqual(readOnlyResult.messages, [], 'conversation-only rewind should truncate to before the message')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(readOnlyId.id), [], 'conversation-only rewind should persist the truncated prefix')
|
||||
|
||||
const unknownPath = join(workspace, 'unknown-write.txt')
|
||||
await writeFile(unknownPath, 'after', 'utf8')
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".cr-frame--right",
|
||||
".cr-toolbar",
|
||||
".cr-toolbar__time"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-research-task", "selector": ".conversation-item", "index": 2, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "loaded", "duration_ms": 1500, "frames": 1 },
|
||||
{ "kind": "hover", "step_id": "hover-user-0", "selector": ".cr-self-bubble", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "toolbar-0", "duration_ms": 350, "frames": 1 },
|
||||
{ "kind": "hover", "step_id": "hover-user-1", "selector": ".cr-self-bubble", "index": 1, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "toolbar-1", "duration_ms": 350, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".cr-message-list",
|
||||
".cr-frame--right",
|
||||
".cr-toolbar",
|
||||
".cr-toolbar__time",
|
||||
".cr-toolbar .cr-icon-button[data-action='copy']",
|
||||
".cr-toolbar .cr-icon-button[data-action='revert']",
|
||||
".cr-toolbar .cr-icon-button[data-action='edit']",
|
||||
".cr-tooltip",
|
||||
".wb-tooltip"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "viewport", "step_id": "tall-viewport", "width": 1336, "height": 2160, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "relayout", "duration_ms": 500, "frames": 1 },
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 1, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 350, "frames": 0 },
|
||||
{ "kind": "hover", "step_id": "hover-revert-button", "selector": ".cr-toolbar .cr-icon-button[data-action='revert']", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "tooltip-visible", "duration_ms": 700, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,8 @@ if (!process.versions.electron) {
|
||||
const sameYearDay = [2, 3, 4, 5].map((day) => new Date(currentYear, 0, day, 9, 5).getTime())
|
||||
.find((value) => new Date(value).toDateString() !== todayKey)
|
||||
const otherYear = new Date(currentYear - 2, 11, 31, 9, 5).getTime()
|
||||
const crossYearUserId = crypto.randomUUID()
|
||||
const sameYearUserId = crypto.randomUUID()
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
// 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。
|
||||
@@ -30,21 +32,21 @@ if (!process.versions.electron) {
|
||||
const editedFileAfter = '新内容:编辑后写入'
|
||||
await fs.writeFile(editedFilePath, editedFileAfter, 'utf8')
|
||||
const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
|
||||
{ id: crypto.randomUUID(), role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done' },
|
||||
{ id: crypto.randomUUID(), role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done' },
|
||||
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done' },
|
||||
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', tools: [
|
||||
{ id: crossYearUserId, role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done', requestId: 'req-cross-year' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done', requestId: 'req-cross-year' },
|
||||
{ id: sameYearUserId, role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done', requestId: 'req-same-year' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done', requestId: 'req-same-year' },
|
||||
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done', requestId: 'req-first' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done', requestId: 'req-first' },
|
||||
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done', requestId: 'req-second' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', requestId: 'req-second', tools: [
|
||||
{ id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } }
|
||||
] }
|
||||
] }, { id: crypto.randomUUID(), title: 'Edit Other', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [
|
||||
{ id: crypto.randomUUID(), role: 'user', content: '另一个会话', createdAt: now - 9000, status: 'done' }
|
||||
] }]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter,
|
||||
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ crossYearUserId, sameYearUserId, firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter,
|
||||
sameYearDay, otherYear, userTimes: [otherYear, sameYearDay, now - 4000, now - 2000] }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
@@ -74,6 +76,7 @@ if (!process.versions.electron) {
|
||||
let main, phase = 'boot'
|
||||
const checks = [], snapshots = {}, rendererErrors = []
|
||||
const sentPayloads = []
|
||||
const revertCalls = []
|
||||
let sendDelayMs = 0
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function read(source) {
|
||||
@@ -117,6 +120,13 @@ if (!process.versions.electron) {
|
||||
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
|
||||
await app.whenReady()
|
||||
// 记录编辑提交负载并返回合成分支:隔离环境没有可用模型,但提交链路必须真实经过 IPC。
|
||||
// 回退走真实渲染链路与真实 preload/IPC;主进程侧换成记录器,
|
||||
// 真实文件与历史语义由 npm run test:conversation-ipc 覆盖。
|
||||
ipcMain.removeHandler('chat:revert')
|
||||
ipcMain.handle('chat:revert', async (_event, sessionId, messageId) => {
|
||||
revertCalls.push({ sessionId, messageId })
|
||||
return { messages: [] }
|
||||
})
|
||||
ipcMain.removeHandler('chat:send')
|
||||
ipcMain.handle('chat:send', async (_event, payload) => {
|
||||
if (sendDelayMs) await pause(sendDelayMs)
|
||||
@@ -157,12 +167,13 @@ if (!process.versions.electron) {
|
||||
check('toolbar is hidden while not hovered', idle.opacity === '0' && idle.pointerEvents === 'none', true)
|
||||
check('toolbar time follows the 14/20 tertiary rule', idle.time.fontSize === '14px' && idle.time.lineHeight === '20px' && idle.time.color === 'rgba(0, 0, 0, 0.5)', true)
|
||||
check('toolbar time uses HH:mm for today', /^\d{2}:\d{2}$/.test(idle.time.text), true)
|
||||
// 该消息之后存在可回退的产物写入,因此原版会额外显示“恢复此版本”入口(ev_245ea174… 的无产物会话只有 copy + edit)。
|
||||
// 原版桌面端回退入口标签来自 rewind.entry.tooltip(ConversationTimelineImpl.revertLabel),
|
||||
// 该消息之后存在产物写入,因此弹窗走 artifacts 变体。
|
||||
check('toolbar keeps the original button set (copy + revert + edit, no resend)', idle.buttons.length, 3)
|
||||
check('button order is copy, revert, edit like the original', idle.buttons.map(item => item.action).join(','), 'copy,revert,edit')
|
||||
check('buttons are 24x24 with 14x14 icons and radius 6', idle.buttons.every(item => item.width === 24 && item.height === 24 && item.radius === '6px' && item.icon[0] === 14 && item.icon[1] === 14), true)
|
||||
check('copy/edit keep the 16x16 viewBox while revert keeps the recovered 14x14 one', idle.buttons.map(item => item.action + ':' + item.icon[2]).join(','), 'copy:0 0 16 16,revert:0 0 14 14,edit:0 0 16 16')
|
||||
check('buttons expose the original aria labels', idle.buttons.map(item => item.label).join(','), '复制,恢复此版本,编辑')
|
||||
check('buttons expose the recovered rewind tooltip', idle.buttons.map(item => item.label).join(','), '复制,将对话及产物回退到此消息前,编辑')
|
||||
|
||||
await step('toolbar-hover')
|
||||
const bubble = idle.buttons[0] && (await read(`(()=>{const box=${lastFrame}.querySelector('.cr-self-bubble').getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`))
|
||||
@@ -254,6 +265,9 @@ if (!process.versions.electron) {
|
||||
snapshots.submitting = submitting
|
||||
check('submitting disables cancel and send with aria-busy', submitting.cancel.disabled === true && submitting.send.disabled === true && submitting.send.busy === 'true', true)
|
||||
check('submitting shows the spinner inside the send button', submitting.send.spinner, true)
|
||||
// 原版 showDefaultRevert/showDefaultEdit = !isSessionActive:会话活动期间回退与编辑入口都不渲染。
|
||||
const busyEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))})).filter(item => item.messageId)`)
|
||||
check('active session hides rewind and edit entries like showDefaultRevert/showDefaultEdit', busyEntries.every(item => item.actions.join(',') === 'copy'), true)
|
||||
for (let index = 0; index < 60 && sentPayloads.length === 0; index++) await pause(50)
|
||||
const editPayload = sentPayloads.find(item => item.action === 'edit')
|
||||
check('edit submit reaches chat:send through IPC', !!editPayload, true)
|
||||
@@ -279,12 +293,57 @@ if (!process.versions.electron) {
|
||||
check('Enter submits the edited message like the original onEnterSend', sentPayloads.filter(item => item.action === 'edit').length, 2)
|
||||
check('second edit payload keeps the typed text', sentPayloads.filter(item => item.action === 'edit')[1].text, '回车提交')
|
||||
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after enter submit')
|
||||
|
||||
// 编辑提交会以桩返回替换内存消息;重新选中会话以从真实 sessions 存储恢复夹具,
|
||||
// 再验证回退入口(resolveRevertEntryVisible)、弹窗三态与确认链路。
|
||||
await step('rewind-reload-fixture')
|
||||
await read(`window.location.reload()`)
|
||||
await wait(`!!document.querySelector('#root')`, 'renderer reloaded')
|
||||
await pause(1500)
|
||||
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows after reload')
|
||||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||||
await wait(`document.querySelectorAll('.cr-frame--right').length === 4`, 'fixture restored')
|
||||
|
||||
await step('rewind-entry')
|
||||
const rewindEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))}))`)
|
||||
snapshots.rewindEntries = rewindEntries
|
||||
const entryOf = id => rewindEntries.find(item => item.messageId === id)?.actions ?? null
|
||||
check('first user message hides the rewind entry like resolveRevertEntryVisible', entryOf(expected.crossYearUserId), ['copy', 'edit'])
|
||||
check('later user messages render copy + rewind + edit', [expected.sameYearUserId, expected.firstUserId, expected.secondUserId].map(entryOf).map(actions => actions?.join(',')).join('|'),
|
||||
'copy,revert,edit|copy,revert,edit|copy,revert,edit')
|
||||
|
||||
const openRewind = async () => {
|
||||
await read(`(()=>{const frame=[...document.querySelectorAll('.cr-frame--right')].find(item => item.querySelector('.cr-toolbar')?.getAttribute('data-message-id') === ${JSON.stringify(expected.secondUserId)});const button=frame?.querySelector('.cr-toolbar .cr-icon-button[data-action="revert"]');if(!button)return false;button.click();return true})()`)
|
||||
await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog')
|
||||
}
|
||||
await step('rewind-dialog-cancel')
|
||||
await openRewind()
|
||||
const dialog = await read(`(()=>{const dialog=document.querySelector('.dialog[role="dialog"]');if(!dialog)return null;const rows=[...dialog.querySelectorAll('.rewind-artifacts__row')].map(row=>({name:row.querySelector('.rewind-artifacts__name')?.textContent??null,title:row.querySelector('.rewind-artifacts__name')?.getAttribute('title')??null,status:row.querySelector('.rewind-artifacts__status')?.textContent??null}));const actions=[...dialog.querySelectorAll('.dialog__actions button')];return {title:dialog.querySelector('.dialog__title')?.textContent??null,description:dialog.querySelector('p')?.textContent??null,aria:dialog.getAttribute('aria-label'),labels:actions.map(button=>button.textContent),rows}})()`)
|
||||
snapshots.rewindDialog = dialog
|
||||
check('rewind dialog uses the recovered artifacts variant title', dialog.title, '将对话及以下产物版本回退到此消息前?')
|
||||
check('rewind dialog carries the recovered description', dialog.description, '回退暂不支持部分类型文件')
|
||||
check('rewind dialog actions are 取消/回退', dialog.labels.join(','), '取消,回退')
|
||||
check('rewind dialog lists the pending artifact with its status', dialog.rows.length === 1 && dialog.rows[0].name === 'edited-artifact.txt' && dialog.rows[0].status === '回退至历史版本' && dialog.rows[0].title === expected.editedFilePath, true)
|
||||
await shot('rewind-dialog')
|
||||
await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`)
|
||||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed')
|
||||
check('cancel keeps the conversation and never reaches chat:revert', revertCalls.length, 0)
|
||||
|
||||
await step('rewind-confirm')
|
||||
await openRewind()
|
||||
await read(`(()=>{const actions=[...document.querySelectorAll('.dialog[role="dialog"] .dialog__actions button')];actions[actions.length-1].click();return true})()`)
|
||||
for (let index = 0; index < 60 && revertCalls.length === 0; index++) await pause(50)
|
||||
check('confirm reaches chat:revert with the session and message id', revertCalls.length === 1 && revertCalls[0].messageId === expected.secondUserId && typeof revertCalls[0].sessionId === 'string', true)
|
||||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed after confirm')
|
||||
check('rewind replaces the conversation with the rewound prefix', await read(`document.querySelectorAll('.cr-frame--right').length`), 0)
|
||||
check('no renderer error logs', rendererErrors, [])
|
||||
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record payloads because the sandbox has no model.',
|
||||
sourceEvidence: ['ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3', 'ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f',
|
||||
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe'],
|
||||
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe',
|
||||
'ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90(rewind.* zh-CN 文案)',
|
||||
'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb(ConversationTimelineImpl rewind 接线,脚本 SHA-256 5f35b748…)'],
|
||||
snapshots, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('USER_MESSAGE_EDIT_' + report.result + ' ' + directory)
|
||||
|
||||
Reference in new issue
Block a user