fix(ai): limit editing to latest turn

Keep compression summaries before the in-flight question and let the Node runtime validate and atomically replace only the terminal question-answer round.
This commit is contained in:
digua committed 2026-08-26 21:48:10 +08:00
1 parent f35e41ca70
commit cc240c85f5
15 files changed
+192 -138

No files matched your search

@@ -135,8 +135,8 @@ test('message round replacement route rolls back every edit when the replacement
})
const chat = manager.createAIChat('session-1', 'Editable chat', 'general_cn')
const firstTurn = manager.addMessagePair(chat.id, { content: 'question' }, { content: 'old answer' })
manager.addMessagePair(chat.id, { content: 'follow up' }, { content: 'follow answer' })
manager.addMessagePair(chat.id, { content: 'question' }, { content: 'old answer' })
const latestTurn = manager.addMessagePair(chat.id, { content: 'follow up' }, { content: 'follow answer' })
const messagesBeforeFailure = manager.getMessages(chat.id)
manager.executeAiSQL(`
@@ -150,11 +150,10 @@ test('message round replacement route rolls back every edit when the replacement
const response = await app.inject({
method: 'POST',
url: `/_web/ai/chats/${chat.id}/replace-message-round`,
url: `/_web/ai/chats/${chat.id}/replace-latest-message-round`,
payload: {
userMessageId: firstTurn.userMessage.id,
userMessageId: latestTurn.userMessage.id,
userContent: 'must roll back',
oldAssistantMessageId: firstTurn.assistantMessage.id,
assistantMessage: { content: 'must not persist' },
},
})
@@ -75,15 +75,14 @@ export function registerAiChatRoutes(server: FastifyInstance, ctx: AiChatRouteCo
Body: {
userMessageId: string
userContent: string
oldAssistantMessageId?: string
assistantMessage: {
content: string
contentBlocks?: ContentBlock[]
tokenUsage?: TokenUsageData
}
}
}>('/_web/ai/chats/:id/replace-message-round', async (request) => {
return cm.replaceMessageRound(request.params.id, request.body)
}>('/_web/ai/chats/:id/replace-latest-message-round', async (request) => {
return cm.replaceLatestMessageRound(request.params.id, request.body)
})
server.post<{
@@ -482,7 +482,7 @@ describe('AIChatManager message persistence and editing', () => {
}
})
it('replaces an edited message round atomically', () => {
it('replaces only the latest message round atomically', () => {
const dir = createTempDir()
try {
const manager = createManager(dir)
@@ -490,21 +490,29 @@ describe('AIChatManager message persistence and editing', () => {
const firstTurn = manager.addMessagePair(chat.id, { content: 'question' }, { content: 'old answer' })
const secondTurn = manager.addMessagePair(chat.id, { content: 'follow up' }, { content: 'follow answer' })
const replacement = manager.replaceMessageRound(chat.id, {
userMessageId: firstTurn.userMessage.id,
userContent: 'edited question',
oldAssistantMessageId: firstTurn.assistantMessage.id,
assert.throws(
() =>
manager.replaceLatestMessageRound(chat.id, {
userMessageId: firstTurn.userMessage.id,
userContent: 'must not edit history',
assistantMessage: { content: 'must not persist' },
}),
/Only the latest user message can be edited/
)
const replacement = manager.replaceLatestMessageRound(chat.id, {
userMessageId: secondTurn.userMessage.id,
userContent: 'edited follow up',
assistantMessage: { content: 'new answer' },
})
const replacedMessages = manager.getMessages(chat.id)
assert.deepEqual(
replacedMessages.map((message) => message.content),
['edited question', 'new answer', 'follow up', 'follow answer']
['question', 'old answer', 'edited follow up', 'new answer']
)
assert.equal(replacement.parentId, firstTurn.userMessage.id)
assert.equal(replacedMessages[2]?.parentId, replacement.id)
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, secondTurn.assistantMessage.id)
assert.equal(replacement.parentId, secondTurn.userMessage.id)
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, replacement.id)
const messagesBeforeFailure = manager.getMessages(chat.id)
manager.executeAiSQL(`
@@ -518,16 +526,15 @@ describe('AIChatManager message persistence and editing', () => {
assert.throws(
() =>
manager.replaceMessageRound(chat.id, {
userMessageId: firstTurn.userMessage.id,
manager.replaceLatestMessageRound(chat.id, {
userMessageId: secondTurn.userMessage.id,
userContent: 'must roll back',
oldAssistantMessageId: replacement.id,
assistantMessage: { content: 'must not persist' },
}),
/injected replacement failure/
)
assert.deepEqual(manager.getMessages(chat.id), messagesBeforeFailure)
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, secondTurn.assistantMessage.id)
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, replacement.id)
manager.close()
} finally {
cleanup(dir)
+17 -18
View File
@@ -700,38 +700,37 @@ export class AIChatManager {
}))()
}
/** 原子替换当前问答轮,避免新回复插入失败后留下已修改问题或已删除旧回复。 */
replaceMessageRound(
/** Atomically replaces the latest user/assistant round on the active path. */
replaceLatestMessageRound(
aiChatId: string,
input: {
userMessageId: string
userContent: string
oldAssistantMessageId?: string
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
}
): AIMessage {
const db = this.getDb()
return db.transaction(() => {
const userMessage = this.getMessageRow(input.userMessageId)
if (!userMessage || userMessage.aiChatId !== aiChatId || userMessage.role !== 'user') {
throw new Error('User message not found in AI chat')
const activePath = this.getActivePathRows(aiChatId)
const latestUserIndex = activePath.findLastIndex((message) => message.role === 'user')
const userMessage = activePath[latestUserIndex]
if (!userMessage || userMessage.id !== input.userMessageId) {
throw new Error('Only the latest user message can be edited')
}
if (input.oldAssistantMessageId) {
const oldAssistantMessage = this.getMessageRow(input.oldAssistantMessageId)
if (
!oldAssistantMessage ||
oldAssistantMessage.aiChatId !== aiChatId ||
oldAssistantMessage.role !== 'assistant' ||
oldAssistantMessage.parentId !== input.userMessageId
) {
throw new Error('Assistant message not found after user message')
}
const followingMessages = activePath.slice(latestUserIndex + 1)
if (
followingMessages.length > 1 ||
(followingMessages.length === 1 &&
(followingMessages[0]?.role !== 'assistant' || followingMessages[0].parentId !== input.userMessageId))
) {
throw new Error('Latest user message is not a replaceable round')
}
const oldAssistantMessage = followingMessages[0]
this.updateMessageContent(input.userMessageId, input.userContent)
if (input.oldAssistantMessageId) {
this.deleteAndRelinkMessage(aiChatId, input.oldAssistantMessageId)
if (oldAssistantMessage) {
this.deleteAndRelinkMessage(aiChatId, oldAssistantMessage.id)
}
return this.insertMessageAfter(
+14 -5
View File
@@ -204,6 +204,17 @@ const assistantBackupName = computed(() => {
// QA 对
const qaPairs = computed(() => groupMessagesToQAPairs(messages.value))
const latestEditableUserMessageId = computed(() => {
const lastUserIndex = messages.value.findLastIndex((message) => message.role === 'user')
if (lastUserIndex < 0) return null
const followingMessages = messages.value.slice(lastUserIndex + 1)
if (followingMessages.length === 0) return messages.value[lastUserIndex]?.id ?? null
if (followingMessages.length === 1 && followingMessages[0]?.role === 'assistant') {
return messages.value[lastUserIndex]?.id ?? null
}
return null
})
const progressiveHistory = useProgressiveChatHistory(qaPairs, currentAIChatId, chatScroll.messagesContainer)
const { visiblePairs, hasOlderPairs, loadOlderPairs } = progressiveHistory
@@ -372,10 +383,8 @@ async function handleSend(payload: {
conversationListRef.value?.refresh()
}
async function handleEditMessage(payload: { messageId: string; content: string; overwriteSubsequent?: boolean }) {
const result = await editMessageAndRegenerate(payload.messageId, payload.content, {
overwriteSubsequent: payload.overwriteSubsequent,
})
async function handleEditMessage(payload: { messageId: string; content: string }) {
const result = await editMessageAndRegenerate(payload.messageId, payload.content)
if (!result.success) {
if (result.reason === 'busy') {
showRunningTaskToast()
@@ -584,7 +593,7 @@ watch(
:timestamp="pair.user.timestamp"
:is-streaming="pair.user.isStreaming"
:content-blocks="pair.user.contentBlocks"
:editable="!isAIThinking"
:editable="!isAIThinking && pair.user.id === latestEditableUserMessageId"
@edit="handleEditMessage"
/>
<!-- AI 回复 -->
+3 -14
View File
@@ -56,7 +56,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
edit: [payload: { messageId: string; content: string; overwriteSubsequent?: boolean }]
edit: [payload: { messageId: string; content: string }]
fork: [messageId: string]
}>()
@@ -82,7 +82,6 @@ const editContent = ref(props.content)
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
const canEdit = computed(() => isUser.value && props.editable && !props.isStreaming && !!props.messageId)
const canFork = computed(() => !isUser.value && !isSummary.value && !props.isStreaming && !!props.messageId)
const overwriteSubsequent = ref(false)
// 创建 markdown-it 实例
const md = new MarkdownIt({
@@ -176,7 +175,6 @@ async function startEditing() {
function cancelEditing() {
isEditing.value = false
editContent.value = props.content
overwriteSubsequent.value = false
}
function submitEditing() {
@@ -187,8 +185,7 @@ function submitEditing() {
return
}
isEditing.value = false
emit('edit', { messageId: props.messageId, content, overwriteSubsequent: overwriteSubsequent.value })
overwriteSubsequent.value = false
emit('edit', { messageId: props.messageId, content })
}
function getDisplayText(text: string): string {
@@ -833,15 +830,7 @@ async function handleCopyMarkdown() {
@keydown.ctrl.enter.prevent="submitEditing"
@keydown.meta.enter.prevent="submitEditing"
/>
<div class="mt-2 flex items-center justify-between">
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<input
v-model="overwriteSubsequent"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-gray-600"
/>
{{ t('ai.chat.message.edit.overwriteSubsequent') }}
</label>
<div class="mt-2 flex justify-end">
<div class="flex gap-2">
<UButton size="xs" variant="ghost" color="gray" @click="cancelEditing">
{{ t('common.cancel') }}
+2 -2
View File
@@ -69,8 +69,8 @@ export function useAIChat(
agentStatus: toRef(state, 'agentStatus'),
selectedAssistantId: toRef(state, 'selectedAssistantId'),
sendMessage: (content: string, options?: SendMessageOptions) => aiChatStore.sendMessage(chatKey, content, options),
editMessageAndRegenerate: (messageId: string, content: string, options?: { overwriteSubsequent?: boolean }) =>
aiChatStore.editMessageAndRegenerate(chatKey, messageId, content, options),
editMessageAndRegenerate: (messageId: string, content: string) =>
aiChatStore.editMessageAndRegenerate(chatKey, messageId, content),
loadAIChat: (aiChatId: string) => aiChatStore.loadAIChat(chatKey, aiChatId),
startNewAIChat: (welcomeMessage?: string) => aiChatStore.startNewAIChat(chatKey, welcomeMessage),
loadMoreSourceMessages: () => aiChatStore.loadMoreSourceMessages(),
+1 -2
View File
@@ -66,8 +66,7 @@
},
"edit": {
"tooltip": "Edit and regenerate",
"submit": "Send",
"overwriteSubsequent": "Overwrite all subsequent messages"
"submit": "Send"
},
"fork": {
"tooltip": "Fork conversation from here"
+1 -2
View File
@@ -66,8 +66,7 @@
},
"edit": {
"tooltip": "編集して再生成",
"submit": "送信",
"overwriteSubsequent": "以降のすべての会話を上書き"
"submit": "送信"
},
"fork": {
"tooltip": "ここから会話をフォーク"
+1 -2
View File
@@ -66,8 +66,7 @@
},
"edit": {
"tooltip": "编辑并重新生成",
"submit": "发送",
"overwriteSubsequent": "覆盖后续所有对话"
"submit": "发送"
},
"fork": {
"tooltip": "从此处复刻对话"
+1 -2
View File
@@ -66,8 +66,7 @@
},
"edit": {
"tooltip": "編輯並重新生成",
"submit": "送出",
"overwriteSubsequent": "覆蓋後續所有對話"
"submit": "送出"
},
"fork": {
"tooltip": "從此處複製對話"
+2 -3
View File
@@ -103,16 +103,15 @@ export class FetchAIAdapter implements AIAdapter {
})
}
async replaceMessageRound(
async replaceLatestMessageRound(
aiChatId: string,
input: {
userMessageId: string
userContent: string
oldAssistantMessageId?: string
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
}
): Promise<AIMessage> {
return post<AIMessage>(`/ai/chats/${aiChatId}/replace-message-round`, input)
return post<AIMessage>(`/ai/chats/${aiChatId}/replace-latest-message-round`, input)
}
async addMessage(
+1 -2
View File
@@ -146,12 +146,11 @@ export interface AIAdapter {
userMessage: { content: string; entityRefs?: AIEntityRef[] },
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
): Promise<{ userMessage: AIMessage; assistantMessage: AIMessage }>
replaceMessageRound(
replaceLatestMessageRound(
aiChatId: string,
input: {
userMessageId: string
userContent: string
oldAssistantMessageId?: string
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
}
): Promise<AIMessage>
+12 -50
View File
@@ -1349,7 +1349,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
content: chunk.compressionResult.summaryContent,
timestamp: chunk.compressionResult.timestamp,
}
const insertIdx = Math.max(0, targetBuffer.messages.length - 1)
// Compression is persisted before the in-flight user/assistant pair.
const insertIdx = Math.max(0, targetBuffer.messages.length - 2)
targetBuffer.messages.splice(insertIdx, 0, summaryMsg)
aiMessageIndex++
}
@@ -1642,8 +1643,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
async function editMessageAndRegenerate(
chatKey: string,
messageId: string,
newContent: string,
options?: { overwriteSubsequent?: boolean }
newContent: string
): Promise<SendMessageResult> {
const state = getSessionState(chatKey)
const content = newContent.trim()
@@ -1653,7 +1653,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
const overwriteAll = options?.overwriteSubsequent ?? false
const targetBuffer = getOrCreateBuffer(state, state.currentAIChatId, state.selectedAssistantId)
const editIndex = targetBuffer.messages.findIndex((message) => message.id === messageId)
const originalMessage = targetBuffer.messages[editIndex]
@@ -1664,42 +1663,18 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
return { success: false, reason: 'empty' }
}
if (overwriteAll) {
return editAndOverwriteAll(chatKey, state, targetBuffer, editIndex, originalMessage, content)
}
return editCurrentRoundOnly(chatKey, state, targetBuffer, editIndex, originalMessage, content)
}
async function editAndOverwriteAll(
chatKey: string,
state: AIChatSessionState,
targetBuffer: AIChatBuffer,
editIndex: number,
originalMessage: ChatMessage,
content: string
): Promise<SendMessageResult> {
try {
const hasConfig = await useLLMService().hasConfig()
if (!hasConfig) {
return { success: false, reason: 'no_config' }
}
if (state.isAborted) {
return { success: false, reason: 'aborted' }
}
if (state.isAIThinking || activeTask.value) {
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
await useAIService().deleteMessagesFrom(state.currentAIChatId!, originalMessage.id)
targetBuffer.messages.splice(editIndex, targetBuffer.messages.length - editIndex)
return sendMessage(chatKey, content)
} catch (error) {
console.error('[AI] edit and overwrite failed:', error)
const latestUserIndex = targetBuffer.messages.findLastIndex((message) => message.role === 'user')
const followingMessages = targetBuffer.messages.slice(editIndex + 1)
const hasReplaceableTail =
editIndex === latestUserIndex &&
(followingMessages.length === 0 || (followingMessages.length === 1 && followingMessages[0]?.role === 'assistant'))
if (!hasReplaceableTail) {
return { success: false, reason: 'error' }
}
return editLatestRound(chatKey, state, targetBuffer, editIndex, originalMessage, content)
}
async function editCurrentRoundOnly(
async function editLatestRound(
chatKey: string,
state: AIChatSessionState,
targetBuffer: AIChatBuffer,
@@ -1727,9 +1702,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const oldAiResponse = targetBuffer.messages[editIndex + 1]
const hasOldAiResponse = oldAiResponse?.role === 'assistant'
const subsequentMessages = hasOldAiResponse
? targetBuffer.messages.slice(editIndex + 2)
: targetBuffer.messages.slice(editIndex + 1)
const aiPlaceholder: ChatMessage = {
id: generateId('ai'),
@@ -1759,7 +1731,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
if (hasOldAiResponse) {
targetBuffer.messages.splice(editIndex + 1, 0, oldAiResponse)
}
targetBuffer.messages.push(...subsequentMessages)
}
const {
@@ -2001,10 +1972,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
const serializableContentBlocks = toSerializableContentBlocks(targetBuffer.messages[aiMessageIndex].contentBlocks)
const savedAiMsg = await useAIService().replaceMessageRound(state.currentAIChatId!, {
const savedAiMsg = await useAIService().replaceLatestMessageRound(state.currentAIChatId!, {
userMessageId: originalMessage.id,
userContent: content,
oldAssistantMessageId: hasOldAiResponse ? oldAiResponse.id : undefined,
assistantMessage: {
content: targetBuffer.messages[aiMessageIndex].content,
contentBlocks: serializableContentBlocks,
@@ -2023,14 +1993,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
content,
}
const nextMsgIndex = aiMessageIndex + 1
if (nextMsgIndex < targetBuffer.messages.length) {
targetBuffer.messages[nextMsgIndex] = {
...targetBuffer.messages[nextMsgIndex],
parentId: savedAiMsg.id,
}
}
targetBuffer.sessionTokenUsage = toTokenUsage(await useAIService().getAIChatTokenUsage(state.currentAIChatId!))
state.sessionTokenUsage = { ...targetBuffer.sessionTokenUsage }
return { success: true }
+111 -15
View File
@@ -10,7 +10,8 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
let singleMessageWrites = 0
let messageRoundReplacements = 0
let legacyEditWrites = 0
let replacementInput: Parameters<AIAdapter['replaceMessageRound']>[1] | undefined
let agentRuns = 0
const replacementInputs: Array<Parameters<AIAdapter['replaceLatestMessageRound']>[1]> = []
const persistenceError = new Error('injected persistence failure')
const aiService = {
createAIChat: async (sessionId: string, title: string, assistantId: string) => ({
@@ -22,9 +23,31 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
createdAt: 1,
updatedAt: 1,
}),
addMessagePair: async () => {
addMessagePair: async (
aiChatId: string,
userMessage: { content: string },
assistantMessage: { content: string }
) => {
messagePairWrites++
throw persistenceError
if (userMessage.content === 'atomic question') throw persistenceError
return {
userMessage: {
id: 'compressed-user',
aiChatId,
role: 'user' as const,
content: userMessage.content,
timestamp: 3,
parentId: 'summary-1',
},
assistantMessage: {
id: 'compressed-assistant',
aiChatId,
role: 'assistant' as const,
content: assistantMessage.content,
timestamp: 4,
parentId: 'compressed-user',
},
}
},
addMessage: async (aiChatId: string, role: 'user' | 'assistant', content: string) => {
singleMessageWrites++
@@ -41,13 +64,29 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
updatedAt: 1,
}),
getMessages: async (aiChatId: string) => [
{ id: 'edit-user', aiChatId, role: 'user' as const, content: 'original question', timestamp: 1 },
{ id: 'historical-user', aiChatId, role: 'user' as const, content: 'historical question', timestamp: 1 },
{
id: 'historical-assistant',
aiChatId,
role: 'assistant' as const,
content: 'historical answer',
timestamp: 2,
parentId: 'historical-user',
},
{
id: 'edit-user',
aiChatId,
role: 'user' as const,
content: 'original question',
timestamp: 3,
parentId: 'historical-assistant',
},
{
id: 'edit-assistant',
aiChatId,
role: 'assistant' as const,
content: 'original answer',
timestamp: 2,
timestamp: 4,
parentId: 'edit-user',
},
],
@@ -58,9 +97,12 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
cacheReadTokens: 0,
cacheWriteTokens: 0,
}),
replaceMessageRound: async (aiChatId: string, input: Parameters<AIAdapter['replaceMessageRound']>[1]) => {
replaceLatestMessageRound: async (
aiChatId: string,
input: Parameters<AIAdapter['replaceLatestMessageRound']>[1]
) => {
messageRoundReplacements++
replacementInput = input
replacementInputs.push(input)
return {
id: 'replacement-assistant',
aiChatId,
@@ -130,7 +172,19 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
t.mock.module('@/services/ai-stream/service', {
namedExports: {
useAgentStreamService: () => ({
runStream: (_params: unknown, onChunk?: (chunk: AgentStreamChunk) => void) => {
runStream: (params: unknown, onChunk?: (chunk: AgentStreamChunk) => void) => {
agentRuns++
if ((params as { userMessage?: string }).userMessage === 'compressed question') {
onChunk?.({
type: 'compression_done',
compressionResult: {
summaryContent: 'compressed context',
tokensBefore: 100,
tokensAfter: 20,
timestamp: 2,
},
})
}
onChunk?.({ type: 'content', content: 'model answer' })
onChunk?.({ type: 'done', isFinished: true })
return {
@@ -172,16 +226,58 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
})
assert.equal(await store.loadAIChat(editChatKey, 'chat-edit'), true)
const historicalEditResult = await store.editMessageAndRegenerate(
editChatKey,
'historical-user',
'must not edit history'
)
assert.deepEqual(historicalEditResult, { success: false, reason: 'error' })
assert.equal(agentRuns, 1)
assert.equal(messageRoundReplacements, 0)
const editResult = await store.editMessageAndRegenerate(editChatKey, 'edit-user', 'edited question')
assert.deepEqual(editResult, { success: true })
assert.equal(messageRoundReplacements, 1)
assert.equal(legacyEditWrites, 0)
assert.ok(replacementInput)
assert.equal(replacementInput.userMessageId, 'edit-user')
assert.equal(replacementInput.userContent, 'edited question')
assert.equal(replacementInput.oldAssistantMessageId, 'edit-assistant')
assert.equal(replacementInput.assistantMessage.content, 'model answer')
assert.equal(replacementInput.assistantMessage.contentBlocks?.length, 1)
assert.equal(replacementInput.assistantMessage.contentBlocks?.[0]?.type, 'text')
const editInput = replacementInputs[0]
assert.ok(editInput)
assert.equal(editInput.userMessageId, 'edit-user')
assert.equal(editInput.userContent, 'edited question')
assert.equal(editInput.assistantMessage.content, 'model answer')
assert.equal(editInput.assistantMessage.contentBlocks?.length, 1)
assert.equal(editInput.assistantMessage.contentBlocks?.[0]?.type, 'text')
const { chatKey: compressionChatKey, state: compressionState } = store.ensureSessionState({
sessionId: 'session-3',
sessionName: 'Compressed session',
chatType: 'private',
locale: 'zh-CN',
})
store.selectAssistantForSession(compressionChatKey, 'general_cn')
const compressedResult = await store.sendMessage(compressionChatKey, 'compressed question')
assert.deepEqual(compressedResult, { success: true })
assert.deepEqual(
compressionState.messages.map((message) => message.role),
['summary', 'user', 'assistant']
)
assert.equal(compressionState.messages[1]?.id, 'compressed-user')
assert.equal(compressionState.messages[2]?.id, 'compressed-assistant')
const compressedEditResult = await store.editMessageAndRegenerate(
compressionChatKey,
'compressed-user',
'edited compressed question'
)
assert.deepEqual(compressedEditResult, { success: true })
assert.equal(messageRoundReplacements, 2)
assert.equal(replacementInputs[1]?.userMessageId, 'compressed-user')
assert.equal(replacementInputs[1]?.userContent, 'edited compressed question')
assert.deepEqual(
compressionState.messages.map((message) => message.role),
['summary', 'user', 'assistant']
)
})