diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 9a35305cb67..abbe933abff 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1046,6 +1046,107 @@ describe('useGeminiStream', () => { }); }); + it('should record tool responses in history when the model was switched due to a quota error', async () => { + // Regression test: returning early on a quota-triggered model switch + // without recording the responses leaves the already-recorded + // functionCall unpaired, which corrupts all subsequent requests. + const responseParts: Part[] = [ + { + functionResponse: { + name: 'testTool', + id: 'call1', + response: { output: 'tool result' }, + }, + }, + ]; + const completedToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-quota', + }, + status: CoreToolCallStatus.Success, + responseSubmittedToGemini: false, + response: { + callId: 'call1', + responseParts, + errorType: undefined, + }, + tool: { displayName: 'MockTool' }, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, + } as TrackedCompletedToolCall, + ]; + + const client = new MockedGeminiClientClass(mockConfig); + const mockConsumeUserHint = vi.fn(() => 'switch to the nprd database'); + + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + + mockUseToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [ + [], + mockScheduleToolCalls, + mockMarkToolsAsSubmitted, + vi.fn(), + mockCancelAllToolCalls, + 0, + ]; + }); + + await renderHookWithProviders(() => + useGeminiStream( + client, + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + true, // modelSwitchedFromQuotaError + () => {}, + () => {}, + () => {}, + 80, + 24, + false, + mockConsumeUserHint, + ), + ); + + await act(async () => { + if (capturedOnComplete) { + await new Promise((resolve) => setTimeout(resolve, 0)); + await capturedOnComplete(completedToolCalls); + } + }); + + await waitFor(() => { + expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']); + // The tool response must be paired with its functionCall in history, + // with no steering-hint text ahead of it... + expect(client.addHistory).toHaveBeenCalledWith({ + role: 'user', + parts: responseParts, + }); + // ...the turn must NOT auto-continue on the fallback model... + expect(mockSendMessageStream).not.toHaveBeenCalled(); + // ...and the pending hint is left for the next real submit. + expect(mockConsumeUserHint).not.toHaveBeenCalled(); + }); + }); + it('should NOT stop responding when only update_topic is called', async () => { const topicToolCalls: TrackedToolCall[] = [ { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 9458c6a4ff4..7965852dc1a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -2110,6 +2110,27 @@ export const useGeminiStream = ( (toolCall) => toolCall.response.responseParts, ); + const callIdsToMarkAsSubmitted = geminiTools.map( + (toolCall) => toolCall.request.callId, + ); + + markToolsAsSubmitted(callIdsToMarkAsSubmitted); + + // Don't continue if model was switched due to quota error, but still + // record the responses: the matching functionCall is already in history, + // and leaving it unpaired corrupts every subsequent request. Any pending + // steering hint is deliberately left unconsumed so it rides along with + // the next query the user actually submits. + if (modelSwitchedFromQuotaError) { + if (geminiClient && responsesToSend.length > 0) { + await geminiClient.addHistory({ + role: 'user', + parts: responsesToSend, + }); + } + return; + } + if (consumeUserHint) { const userHint = consumeUserHint(); if (userHint && userHint.trim().length > 0) { @@ -2120,21 +2141,10 @@ export const useGeminiStream = ( } } - const callIdsToMarkAsSubmitted = geminiTools.map( - (toolCall) => toolCall.request.callId, - ); - const prompt_ids = geminiTools.map( (toolCall) => toolCall.request.prompt_id, ); - markToolsAsSubmitted(callIdsToMarkAsSubmitted); - - // Don't continue if model was switched due to quota error - if (modelSwitchedFromQuotaError) { - return; - } - // eslint-disable-next-line @typescript-eslint/no-floating-promises submitQuery( responsesToSend, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 6443df5824e..73c94bd34ea 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -228,6 +228,16 @@ describe('GeminiChat', () => { // Disable 429 simulation for tests setSimulate429(false); + + // The mid-stream retry loop sleeps on a real timer (1s + 2s + 4s) between + // attempts, which exceeds the default 5s test timeout and silently killed + // every InvalidStreamError test before it reached its assertions. Run those + // delays instantly. + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn(); + return 0; + }) as unknown as typeof globalThis.setTimeout); + // Reset history for each test by creating a new instance chat = new GeminiChat(mockConfig); mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined); @@ -999,6 +1009,219 @@ describe('GeminiChat', () => { expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined(); }); + it('should not fuse the next user message into a preserved tool-response turn', async () => { + // Regression: when a stream fails mid tool-loop the tool response is + // deliberately preserved (see the test above), which leaves history + // ending on a user turn. The user's next message was then coalesced into + // that same turn as [functionResponse, text]. The model reads the + // trailing text as a continuation of the tool result and completes the + // sentence instead of answering it. + chat.agentHistory.push({ + id: 'model-turn-1', + content: { + role: 'model', + parts: [{ functionCall: { name: 'test_tool', args: {} } }], + }, + }); + + // 1. Tool response goes back, model returns nothing -> InvalidStreamError. + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + (async function* () { + yield { + candidates: [ + { content: { role: 'model', parts: [] }, finishReason: 'STOP' }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const failingStream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + [ + { + functionResponse: { + name: 'test_tool', + response: { success: true }, + }, + }, + ], + 'prompt-id-fusion-setup', + new AbortController().signal, + LlmRole.MAIN, + ); + await expect( + (async () => { + for await (const _ of failingStream) { + // consume + } + })(), + ).rejects.toThrow(InvalidStreamError); + + // 2. The user types a brand new instruction. + let capturedContents: Content[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (req) => { + capturedContents = req.contents as Content[]; + return (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'ok' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + }, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'are you done?', + 'prompt-id-fusion-check', + new AbortController().signal, + LlmRole.MAIN, + ); + for await (const _ of stream) { + // consume + } + + const fusedTurn = capturedContents.find( + (c) => + c.role === 'user' && + !!c.parts?.some((p) => !!p.functionResponse) && + !!c.parts?.some((p) => p.text?.includes('are you done?')), + ); + expect(fusedTurn).toBeUndefined(); + }); + + it('should not fuse the next user message into a cancelled tool response', async () => { + // Same defect reached by a different trigger: cancelling a tool call + // records its response via addHistory then returns without submitting, + // leaving history on an unanswered user turn just like a stream failure. + chat.agentHistory.push({ + id: 'model-turn-cancel', + content: { + role: 'model', + parts: [ + { functionCall: { id: 'c1', name: 'run_shell_command', args: {} } }, + ], + }, + }); + chat.addHistory({ + role: 'user', + parts: [ + { + functionResponse: { + id: 'c1', + name: 'run_shell_command', + response: { error: '[Operation Cancelled]' }, + }, + }, + ], + }); + + let capturedContents: Content[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (req) => { + capturedContents = req.contents as Content[]; + return (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'ok' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + }, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + "you're querying local database, I meant nprd", + 'prompt-id-cancel-fusion', + new AbortController().signal, + LlmRole.MAIN, + ); + for await (const _ of stream) { + // consume + } + + const fusedCancelTurn = capturedContents.find( + (c) => + c.role === 'user' && + !!c.parts?.some((p) => !!p.functionResponse) && + !!c.parts?.some((p) => p.text?.includes('I meant nprd')), + ); + expect(fusedCancelTurn).toBeUndefined(); + }); + + it('should close a dangling tool response restored from a resumed session', async () => { + // The guard runs when a new user message arrives rather than when the + // turn fails, so it does not depend on a placeholder having been + // persisted. A session resumed from disk that ends on an unanswered tool + // response is repaired on the next message just the same. + chat.setHistory([ + { role: 'user', parts: [{ text: 'run the tests' }] }, + { + role: 'model', + parts: [ + { functionCall: { id: 'c1', name: 'run_shell_command', args: {} } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'c1', + name: 'run_shell_command', + response: { output: 'ok' }, + }, + }, + ], + }, + ]); + + let capturedContents: Content[] = []; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async (req) => { + capturedContents = req.contents as Content[]; + return (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'ok' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + }, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'are you done?', + 'prompt-id-resumed-fusion', + new AbortController().signal, + LlmRole.MAIN, + ); + for await (const _ of stream) { + // consume + } + + const fusedResumedTurn = capturedContents.find( + (c) => + c.role === 'user' && + !!c.parts?.some((p) => !!p.functionResponse) && + !!c.parts?.some((p) => p.text?.includes('are you done?')), + ); + expect(fusedResumedTurn).toBeUndefined(); + }); + it('should preserve mixed multimodal function responses during rollback when InvalidStreamError is thrown (regression)', async () => { // 1. Setup history ending with a model turn containing functionCall chat.agentHistory.push({ @@ -3210,6 +3433,75 @@ describe('GeminiChat', () => { expect(turns[0].content.parts![0].text).toBe('Question 1'); expect(turns[0].content.parts![1].text).toBe('Question 2'); }); + + it('should inject a synthetic thoughtSignature onto a functionCall left signature-less after stripping a thought part that carried it (regression test for #28604)', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false); + vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro'); + + chat.setHistory([ + { role: 'user', parts: [{ text: 'activate the skill' }] }, + { + role: 'model', + parts: [ + { + text: 'internal monologue', + thought: true, + thoughtSignature: 'real-sig-from-api', + } as unknown as Part, + { + functionCall: { name: 'activate_skill', args: {} }, + }, + ], + }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'activate_skill', response: {} } }, + ], + }, + ]); + + const turns = chat.getHistoryTurns(true); + + const modelTurn = turns[1]; + expect(modelTurn.content.parts).toHaveLength(1); + expect(modelTurn.content.parts![0].functionCall?.name).toBe( + 'activate_skill', + ); + expect(modelTurn.content.parts![0].thoughtSignature).toBe( + SYNTHETIC_THOUGHT_SIGNATURE, + ); + }); + + it('should leave an existing thoughtSignature on a functionCall untouched when stripping thoughts', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false); + vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro'); + + chat.setHistory([ + { role: 'user', parts: [{ text: 'activate the skill' }] }, + { + role: 'model', + parts: [ + { + text: 'internal monologue', + thought: true, + thoughtSignature: 'real-sig-from-api', + } as unknown as Part, + { + functionCall: { name: 'activate_skill', args: {} }, + thoughtSignature: 'existing-sig-on-call', + }, + ], + }, + ]); + + const turns = chat.getHistoryTurns(true); + + const modelTurn = turns[1]; + expect(modelTurn.content.parts![0].thoughtSignature).toBe( + 'existing-sig-on-call', + ); + }); }); describe('ensureActiveLoopHasThoughtSignatures', () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 93ea20ee2ac..b561285635f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -108,6 +108,13 @@ const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = { export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator'; +/** + * Stands in for a model turn that never arrived because the stream failed + * after a tool response was already committed to history. + */ +export const INTERRUPTED_RESPONSE_PLACEHOLDER = + '[The previous response was interrupted before it completed.]'; + /** * Internal interface for parts that carry the magic 'callIndex' property * used during model response consolidation. @@ -408,6 +415,16 @@ export class GeminiChat { let userContent = createUserContent(message); const isOriginalFunctionResponse = isFunctionResponse(userContent); + + // A turn can end leaving history on an unanswered tool response: a stream + // error after the response was committed, or a cancelled tool call. Close + // it before recording a genuinely new user message, otherwise the two user + // turns are coalesced into one and the model continues the trailing text + // instead of answering it. + if (!isOriginalFunctionResponse) { + this.closeUnansweredToolResponseTurn(); + } + const { model } = this.context.config.modelConfigService.getResolvedConfig(modelConfigKey); @@ -683,6 +700,28 @@ export class GeminiChat { return streamWithRetries.call(this); } + /** + * Appends a closing model turn when history ends with an unanswered tool + * response, so the next user message stays a turn of its own. + */ + private closeUnansweredToolResponseTurn(): void { + const turns = this.agentHistory.get(); + const last = turns[turns.length - 1]; + if ( + last?.content.role !== 'user' || + !last.content.parts?.some((part) => !!part.functionResponse) + ) { + return; + } + this.agentHistory.push({ + id: randomUUID(), + content: { + role: 'model', + parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }], + }, + }); + } + private extractBinaryInjections( parts: Part[] | undefined, ): Part[] | undefined { @@ -1648,11 +1687,34 @@ export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { if (!hasThought) return turn; const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought); + + // The thoughtSignature the API requires on the first functionCall of a + // model turn is sometimes only carried by the thought part we just + // removed, not by the functionCall part itself. Without it, replaying + // this turn in a later request gets rejected with a 400 "missing + // thought_signature" error, so inject a synthetic one if needed. + let patchedFirstCall = false; + const finalParts = + turn.content.role === 'model' + ? nonThoughtParts.map((p) => { + if (!patchedFirstCall && p.functionCall) { + patchedFirstCall = true; + if (!p.thoughtSignature) { + return { + ...p, + thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE, + }; + } + } + return p; + }) + : nonThoughtParts; + return { ...turn, content: { ...turn.content, - parts: nonThoughtParts, + parts: finalParts, }, }; }) diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 133e9ffe4db..8a63e3e541e 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -308,6 +308,125 @@ describe('ChatRecordingService', () => { )) as ConversationRecord; expect(conversation.sessionId).toBe('old-session-id'); }); + + it('should fall back to the in-memory conversation when the file cannot be reloaded', async () => { + // Regression test for the `/compress` "Failed to load resumed session + // data from file" bug: when resuming with a filePath that cannot be + // loaded from disk, initialize must NOT throw. It should adopt the + // in-memory conversation it was handed and rewrite a clean file. + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const missingFile = path.join(chatsDir, 'missing-session.jsonl'); + expect(fs.existsSync(missingFile)).toBe(false); + + const inMemoryConversation = { + sessionId: 'resumed-session-id', + projectHash: 'resumed-project-hash', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [ + { + id: 'msg-1', + type: 'user', + timestamp: new Date().toISOString(), + content: 'hello from memory', + }, + ], + } as unknown as ConversationRecord; + + await expect( + chatRecordingService.initialize({ + filePath: missingFile, + conversation: inMemoryConversation, + }), + ).resolves.not.toThrow(); + + // The in-memory conversation is adopted. + expect(chatRecordingService.getConversation()?.sessionId).toBe( + 'resumed-session-id', + ); + + // A clean, loadable file is rewritten from the in-memory copy so future + // loads and appends succeed. + const reloaded = (await loadConversationRecord( + missingFile, + )) as ConversationRecord; + expect(reloaded).not.toBeNull(); + expect(reloaded.sessionId).toBe('resumed-session-id'); + expect(reloaded.projectHash).toBe('resumed-project-hash'); + expect(reloaded.messages).toHaveLength(1); + }); + + it('should preserve an unreadable session file instead of destroying it', async () => { + // The reload may have failed only transiently, so the original bytes + // must survive the recovery rewrite. + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionFile = path.join(chatsDir, 'unreadable.jsonl'); + + // No usable metadata line => loadConversationRecord() returns null. + const originalBytes = '{"not":"a valid metadata line"}\n'; + fs.writeFileSync(sessionFile, originalBytes); + + await chatRecordingService.initialize({ + filePath: sessionFile, + conversation: { + sessionId: 'recovered-session-id', + projectHash: 'recovered-project-hash', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [], + } as unknown as ConversationRecord, + }); + + // The rewritten file is loadable again... + const reloaded = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; + expect(reloaded.sessionId).toBe('recovered-session-id'); + + // ...and the original bytes were kept alongside it. + const preserved = fs + .readdirSync(chatsDir) + .filter((f) => f.startsWith('unreadable.jsonl.unreadable-')); + expect(preserved).toHaveLength(1); + expect(fs.readFileSync(path.join(chatsDir, preserved[0]), 'utf-8')).toBe( + originalBytes, + ); + }); + + it('should not leave a temp file behind when the rewrite fails', async () => { + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionFile = path.join(chatsDir, 'rewrite-fails.jsonl'); + + // Fail the rename that publishes the temp file, leaving it orphaned. + const realRename = fs.renameSync; + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(from).includes('.tmp-')) { + throw new Error('simulated rename failure'); + } + return realRename(from, to); + }); + + await expect( + chatRecordingService.initialize({ + filePath: sessionFile, + conversation: { + sessionId: 'temp-cleanup-session', + projectHash: 'temp-cleanup-hash', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [], + } as unknown as ConversationRecord, + }), + ).rejects.toThrow('simulated rename failure'); + + const leftovers = fs + .readdirSync(chatsDir) + .filter((f) => f.includes('.tmp-')); + expect(leftovers).toEqual([]); + }); }); describe('recordMessage', () => { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 18b977bf00e..186282eb1d0 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -462,7 +462,16 @@ export class ChatRecordingService { // Update the session ID in the existing file this.updateMetadata({ sessionId: this.sessionId }); } else { - throw new Error('Failed to load resumed session data from file'); + // The file could not be reloaded (missing, corrupt metadata, or an + // I/O error). Fall back to the in-memory conversation we were handed + // rather than failing the caller, and rewrite a clean file from it. + debugLogger.warn( + 'Failed to reload resumed session data from file; falling back ' + + 'to the in-memory conversation.', + ); + this.cachedConversation = resumedSessionData.conversation; + this.projectHash = this.cachedConversation.projectHash; + this.rewriteConversationFile(this.cachedConversation); } } else { // Create new session @@ -563,6 +572,73 @@ export class ChatRecordingService { } } + /** + * Rewrites the session file from an in-memory record. Any existing + * (unreadable) file is preserved alongside rather than destroyed, and the + * new file is written atomically (temp file + rename). + */ + private rewriteConversationFile(conversation: ConversationRecord): void { + if (!this.conversationFile) return; + + // Normalize legacy `.json` paths to the `.jsonl` format we write. + if (this.conversationFile.endsWith('.json')) { + this.conversationFile = this.conversationFile + 'l'; + } + + const { messages, memoryScratchpad, ...metadata } = conversation; + const lines: string[] = [JSON.stringify(metadata)]; + for (const msg of messages) { + lines.push(JSON.stringify(msg)); + } + if (memoryScratchpad) { + lines.push(JSON.stringify({ $set: { memoryScratchpad } })); + } + const content = lines.join('\n') + '\n'; + + try { + fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); + + // The existing file was unreadable, but it may have been only + // transiently so (a lock or I/O blip) rather than truly corrupt. Keep + // its bytes rather than destroying them. + if (fs.existsSync(this.conversationFile)) { + const backup = `${this.conversationFile}.unreadable-${Date.now()}`; + try { + fs.renameSync(this.conversationFile, backup); + debugLogger.warn( + `Preserved the unreadable session file at ${backup}.`, + ); + } catch (backupError) { + debugLogger.error( + 'Failed to preserve the unreadable session file.', + backupError, + ); + } + } + + const tempFile = `${this.conversationFile}.tmp-${process.pid}`; + try { + fs.writeFileSync(tempFile, content); + fs.renameSync(tempFile, this.conversationFile); + } catch (error) { + // The rename did not complete, so the temp file would be left behind. + try { + fs.unlinkSync(tempFile); + } catch { + // Ignore cleanup errors so the original failure still surfaces. + } + throw error; + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOSPC') { + this.conversationFile = null; + debugLogger.warn(ENOSPC_WARNING_MESSAGE); + } else { + throw error; + } + } + } + private updateMetadata(updates: Partial): void { if (!this.cachedConversation) return; Object.assign(this.cachedConversation, updates);