feat(conversation): replicate in-place user message editing (TODO 03)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 06:31:37 +08:00
1 parent ed31a7c1b4
commit ee51208931
18 files changed
+992 -67

No files matched your search

+55
View File
@@ -355,6 +355,60 @@ async function testActionBranchFailure(bundle: TestBundle, state: TestState): Pr
'failed action branch should restore the prior marker')
}
async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
const invoke = invokeFor(state)
// 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。
const rolledPath = join(workspace, 'edit-rollback.txt')
const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after')
await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' })
const rolledMessages = await waitForAssistant(invoke, rolledSession)
assert.equal(await readFile(rolledPath, 'utf8'), 'before',
'edit submit must roll the artifact back to its version before the message')
assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement')
assert.equal(rolledMessages[0].content, 'edited prompt')
assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId')
// 分支失败:撤销文件回退,保持会话不变。
const failedPath = join(workspace, 'edit-rollback-branch-failure.txt')
const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after')
const failedBefore = await bundle.sessions.getMessages(failedSession)
state.failBranchIds.add('edit-rollback-failed-native')
await assert.rejects(invoke('chat:send', {
sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user'
}), /branch failure/)
state.failBranchIds.delete('edit-rollback-failed-native')
assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback')
assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore,
'failed edit must leave the persisted conversation untouched')
// 无产物改动:直接重开对话,不因缺少文件记录而报错。
const plainSession = await invoke('session:create', 'edit-plain') as { id: string }
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' }))
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply'))
seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']])
await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' })
const plainMessages = await waitForAssistant(invoke, plainSession.id)
assert.equal(plainMessages.length, 2)
assert.equal(plainMessages[0].content, 'edited plain')
// 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。
const blockedPath = join(workspace, 'edit-blocked.txt')
await writeFile(blockedPath, 'after', 'utf8')
const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string }
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' }))
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', {
tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }]
}))
seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']])
const blockedBefore = await bundle.sessions.getMessages(blockedSession.id)
await assert.rejects(invoke('chat:send', {
sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user'
}), /无法完整回滚/)
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
'rejected edit must leave history untouched')
}
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
const invoke = invokeFor(state)
for (const nativeAccepted of [false, true]) {
@@ -684,6 +738,7 @@ async function main(): Promise<void> {
await testConcurrency(bundle, state, invoke)
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
await testActionBranchFailure(bundle, state)
await testEditArtifactRollback(bundle, state, workspace)
await testInterruptedResend(bundle, state)
await testAttachments(bundle, state, tempRoot)
await testSpeech(bundle, state)