From 753b35fe047eed2a0fd04fd06ff7271d6a4e40fa Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 23:39:44 +1100 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20cancel=20active=20str?= =?UTF-8?q?eam=20on=20message=20edit=20to=20prevent=20history=20corruption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When editing a compacting message to change parameters (e.g., token count) while the first compaction is still streaming, the old stream would continue and corrupt the chat history by replacing everything with [truncated]. Root cause: handleCompletion would find the NEW compaction request in history and proceed to perform compaction with the OLD stream's partial summary. Fix: Cancel any active stream before processing edits. This ensures only one stream runs at a time and aligns with user intent (edit = discard old). Impact: 9 lines added to agentSession.ts sendMessage method --- src/node/services/agentSession.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index adbe96ee30..5f4f133b8e 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -271,6 +271,15 @@ export class AgentSession { } if (options?.editMessageId) { + // Interrupt an existing stream or compaction, if active + if (this.aiService.isStreaming(this.workspaceId)) { + // MUST use abandonPartial=true to prevent handleAbort from performing partial compaction + // with mismatched history (since we're about to truncate it) + const stopResult = await this.interruptStream(/* abandonPartial */ true); + if (!stopResult.success) { + return Err(createUnknownSendMessageError(stopResult.error)); + } + } const truncateResult = await this.historyService.truncateAfterMessage( this.workspaceId, options.editMessageId From 68c5f5d2b3b885bebe87b4f2f68ea5a8ce5accb9 Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 23:48:39 +1100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20delete=20partial=20in?= =?UTF-8?q?=20interruptStream=20when=20abandonPartial=3Dtrue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fix: interruptStream now deletes the partial when abandonPartial=true, mirroring the IPC handler pattern. Without this, the partial would be committed by streamWithHistory's unconditional commitToHistory call, reintroducing the cancelled assistant response into history. This completes the fix for editing compacting messages - now the cancelled output is truly discarded instead of being appended after truncation. --- src/node/services/agentSession.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 5f4f133b8e..b78141d8c6 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -376,6 +376,15 @@ export class AgentSession { return Err(stopResult.error); } + // Delete partial when abandoning to prevent commitToHistory from reintroducing it + // Mirrors the IPC handler pattern (ipcMain.ts:1108-1110) + if (abandonPartial) { + const deleteResult = await this.partialService.deletePartial(this.workspaceId); + if (!deleteResult.success) { + return Err(deleteResult.error); + } + } + return Ok(undefined); } From 73d8397622c2625cf8f4d11d7d0aae49e77d553d Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 23:50:35 +1100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20remove=20duplica?= =?UTF-8?q?te=20partial=20deletion=20from=20IPC=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interruptStream() now owns the complete interrupt flow including partial deletion. The IPC handler is now a thin wrapper that delegates fully. This eliminates duplication - previously both layers were deleting the partial when abandonPartial=true, making two calls to deletePartial() for IPC-triggered interrupts (though the second was idempotent). Single source of truth: AgentSession.interruptStream() handles everything. --- src/node/services/agentSession.ts | 1 - src/node/services/ipcMain.ts | 6 ------ 2 files changed, 7 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b78141d8c6..8f77395a70 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -377,7 +377,6 @@ export class AgentSession { } // Delete partial when abandoning to prevent commitToHistory from reintroducing it - // Mirrors the IPC handler pattern (ipcMain.ts:1108-1110) if (abandonPartial) { const deleteResult = await this.partialService.deletePartial(this.workspaceId); if (!deleteResult.success) { diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 519a55ee58..a9c22f8338 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1104,12 +1104,6 @@ export class IpcMain { return { success: false, error: stopResult.error }; } - // If abandonPartial is true, delete the partial instead of committing it - if (options?.abandonPartial) { - log.debug("Abandoning partial for workspace:", workspaceId); - await this.partialService.deletePartial(workspaceId); - } - return { success: true, data: undefined }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); From 45b55c5a1264609d3541f658555fd094a58b2681 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 00:02:38 +1100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20delete=20partial=20be?= =?UTF-8?q?fore=20stopStream=20to=20prevent=20abort=20handler=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fix: Reordered operations in interruptStream to delete partial.json BEFORE calling stopStream. This prevents the abort handler (which runs immediately when stopStream is called) from committing the partial back to history. Previous order (buggy): 1. stopStream → abort handler commits partial 2. delete partial (too late) New order (correct): 1. delete partial 2. stopStream → abort handler finds no partial to commit This completes the fix for editing mid-stream - now the cancelled content truly stays deleted instead of being reintroduced by the abort handler. --- src/node/services/agentSession.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8f77395a70..df98d9b477 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -371,12 +371,9 @@ export class AgentSession { return Ok(undefined); } - const stopResult = await this.aiService.stopStream(this.workspaceId, abandonPartial); - if (!stopResult.success) { - return Err(stopResult.error); - } - - // Delete partial when abandoning to prevent commitToHistory from reintroducing it + // Delete partial BEFORE stopping to prevent abort handler from committing it + // The abort handler in aiService.ts runs immediately when stopStream is called, + // so we must delete first to ensure it finds no partial to commit if (abandonPartial) { const deleteResult = await this.partialService.deletePartial(this.workspaceId); if (!deleteResult.success) { @@ -384,6 +381,11 @@ export class AgentSession { } } + const stopResult = await this.aiService.stopStream(this.workspaceId, abandonPartial); + if (!stopResult.success) { + return Err(stopResult.error); + } + return Ok(undefined); }