Skip to content

feat(abort): persist partial streamed response to history on Stop Generation#824

Closed
netrezver wants to merge 2 commits into
siteboon:mainfrom
netrezver:fix/persist-partial-response-on-abort
Closed

feat(abort): persist partial streamed response to history on Stop Generation#824
netrezver wants to merge 2 commits into
siteboon:mainfrom
netrezver:fix/persist-partial-response-on-abort

Conversation

@netrezver
Copy link
Copy Markdown

@netrezver netrezver commented Jun 4, 2026

Problem

When the user clicks Stop Generation mid-response, Claude's entire partial reply is silently discarded. On the next message Claude has no memory of what it had already generated and starts from scratch, causing context loss and repetition.

Root Cause

abortClaudeSDKSession calls session.instance.interrupt() without recording anything. The streamed text was visible on screen but never written to the SDK's JSONL history file at ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl.

Fix

  1. Track cwd per sessionaddSession now stores the working directory so the abort path can locate the correct history file.
  2. encodeProjectDir(cwd) — mirrors the SDK's own directory naming logic (/-).
  3. appendInterruptedAssistantEntry(sessionId, cwd, partialText) — writes a synthetic assistant entry with the partial text and [generation interrupted] suffix to the JSONL file before calling interrupt(). Idempotent: if the last entry already contains [generation interrupted] it skips the write.
  4. UI captures partial textuseChatComposerState reads accumulated streamed text via accumulatedStreamRef and sends it in the abort-session WebSocket message. Also optimistically clears isLoading / claudeStatus on abort so the UI doesn't stay stuck in a processing state.
  5. WebSocket service — threads partialResponse from the message through to abortClaudeSDKSession.

How to Reproduce

  1. Send a long prompt (e.g. "write me a detailed 1000-word essay on X").
  2. While Claude is streaming, click Stop Generation after a few sentences have appeared.
  3. Before this fix: send any follow-up message — Claude has no memory of the interrupted reply.
  4. After this fix: send any follow-up message — Claude sees its own partial reply in history (marked [generation interrupted]) and can continue or acknowledge it naturally.

Files Changed

File Change
server/claude-sdk.js Track cwd in sessions; add encodeProjectDir, appendInterruptedAssistantEntry; update abortClaudeSDKSession
server/modules/websocket/services/chat-websocket.service.ts Pass partialResponse from WS message to SDK abort
src/components/chat/hooks/useChatComposerState.ts Capture streamed text; send with abort; optimistic UI clear

Summary by CodeRabbit

  • New Features

    • Partial response preservation: assistant output is saved when a session is aborted so interrupted generations are retained.
  • Improvements

    • Enhanced per-session state tracking for more consistent session context.
    • Faster abort UX: UI loading/abort indicators clear immediately when aborting, improving responsiveness.

When the user clicks Stop Generation, accumulate the partial text streamed
so far and write a synthetic assistant entry to the SDK's JSONL history file
before interrupting. On the next turn Claude sees what it had already generated
and can continue without losing context.

Changes:
- server/claude-sdk.js: track cwd per session; add encodeProjectDir and
  appendInterruptedAssistantEntry helpers; pass partialResponse to abort
- server/modules/websocket/services/chat-websocket.service.ts: thread
  partialResponse from the abort-session WS message through to the SDK
- src/components/chat/hooks/useChatComposerState.ts: capture streamed text
  via accumulatedStreamRef; send it with abort-session; optimistically clear
  loading state on abort
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8a70a7b6-9d4e-48c2-8a4e-084b5e7365b8

📥 Commits

Reviewing files that changed from the base of the PR and between 39ca744 and 6d1dbad.

📒 Files selected for processing (1)
  • server/claude-sdk.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/claude-sdk.js

📝 Walkthrough

Walkthrough

The PR preserves partial assistant output on abort: backend stores per-session writer/cwd, adds helpers to append an interrupted assistant entry into the Claude project JSONL, and the frontend/WebSocket pass captured partial text into the abort call.

Changes

Abort with Partial Response Persistence

Layer / File(s) Summary
Session metadata extension
server/claude-sdk.js
addSession stores writer and cwd per session; queryClaudeSDK passes these values when registering sessions.
Persistence helpers and abort signature
server/claude-sdk.js
Adds encodeProjectDir and appendInterruptedAssistantEntry to compute project JSONL path and append a synthetic assistant entry; abortClaudeSDKSession(sessionId, partialResponse = '') persists non-empty partial responses before interrupting.
WebSocket handler routing
server/modules/websocket/services/chat-websocket.service.ts
abortClaudeSDKSession dependency accepts partialResponse; the abort-session handler forwards data.partialResponse (or '') when calling the abort dependency.
Frontend abort with partial capture
src/components/chat/hooks/useChatComposerState.ts
Hook adds optional accumulatedStreamRef?: MutableRefObject<string>, clears loading/abort UI state immediately on abort, and includes partialResponse from the ref in the abort-session message.

Sequence Diagram

sequenceDiagram
  participant UI as Frontend UI
  participant WS as WebSocket Handler
  participant SDK as Claude SDK
  participant JSONL as Project JSONL Storage

  UI->>UI: User clicks abort, ref contains partial
  UI->>WS: sendMessage({type: 'abort-session', partialResponse: '...'})
  WS->>SDK: abortClaudeSDKSession(sessionId, partialResponse)
  alt partialResponse is non-empty
    SDK->>JSONL: appendInterruptedAssistantEntry(sessionId, cwd, partial)
    JSONL->>JSONL: read last entry, extract context
    JSONL->>JSONL: append synthetic assistant entry with partial + marker
  end
  SDK->>SDK: session.instance.interrupt()
  SDK-->>WS: success
  WS-->>UI: abort complete
Loading

Possibly related PRs

  • siteboon/claudecodeui#462: Adds per-session writer storage in addSession, which this PR extends with cwd for JSONL persistence.
  • siteboon/claudecodeui#208: Introduced the initial Claude SDK/session tracking and frontend/backend wiring that this PR builds upon.

Suggested reviewers

  • viper151
  • blackmammoth

Poem

🐰 A rabbit's celebration

When Claude's words fall short mid-run,
I hop and catch the half-said sun.
From UI to JSONL they flow,
A trimmed “almost” we now stow,
Marked gently "[generation interrupted]"—and done.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: persisting partial streamed responses to history when generation is stopped, which is the primary objective of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
server/claude-sdk.js (1)

782-816: ⚖️ Poor tradeoff

Race condition: file may be modified between read and append.

Between reading the JSONL file (line 794) and appending the new entry (line 857), the SDK's subprocess may have written additional entries. This could result in:

  1. The parentUuid pointing to an outdated entry
  2. The idempotency check passing even though the SDK already wrote something

This is a time-of-check to time-of-use (TOCTOU) issue. However, since interrupt() is called after this function completes, and the SDK subprocess should be blocked waiting for the main process, the practical risk is low.

Consider using file locking or atomic read-modify-write if this becomes an issue in practice. For now, adding a comment documenting the assumption would help future maintainers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/claude-sdk.js` around lines 782 - 816, The
appendInterruptedAssistantEntry function reads the session JSONL (jsonlPath)
then later appends a new entry using the previously read parentUuid, which
creates a TOCTOU race if the SDK subprocess can write between read and append;
add a clear inline comment inside appendInterruptedAssistantEntry (near the
read/parse block and before the append logic) documenting the assumption that
the SDK subprocess is blocked and that reads are safe, and also note mitigation
options (file locking or atomic read-modify-write using tempfile+rename) with
references to jsonlPath, parentUuid, and the idempotency check so future
maintainers can apply a proper lock if needed.
src/components/chat/hooks/useChatComposerState.ts (1)

960-976: ⚖️ Poor tradeoff

Optimistic UI clearing may leave stale state if abort fails.

The UI state is cleared immediately (lines 961-963) before the abort message is sent. If the WebSocket send fails or the server-side abort fails, the UI will show a "not loading" state while the backend session may still be active.

However, the complete message from the server (sent at line 176-185 in chat-websocket.service.ts) will arrive regardless of abort success/failure and could be used to reconcile state. The current approach prioritizes responsiveness.

This is acceptable for now, but consider adding error handling or state reconciliation if users report UI inconsistencies after failed aborts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/hooks/useChatComposerState.ts` around lines 960 - 976,
The optimistic UI clears (setIsLoading, setCanAbortSession, setClaudeStatus)
before sending the abort message; modify the abort handler in the
useChatComposerState hook to attempt sendMessage(...) inside a try/catch (or
check sendMessage's returned promise/result) and, on send failure, restore the
previous UI state (revert setIsLoading/setCanAbortSession/setClaudeStatus to
their prior values) and/or set an explicit error flag; additionally, ensure the
global websocket "complete" server event handler (the server-side complete
message) is used to reconcile final state by updating these same setters when
that message arrives so UI and backend remain consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/claude-sdk.js`:
- Around line 782-816: The appendInterruptedAssistantEntry function reads the
session JSONL (jsonlPath) then later appends a new entry using the previously
read parentUuid, which creates a TOCTOU race if the SDK subprocess can write
between read and append; add a clear inline comment inside
appendInterruptedAssistantEntry (near the read/parse block and before the append
logic) documenting the assumption that the SDK subprocess is blocked and that
reads are safe, and also note mitigation options (file locking or atomic
read-modify-write using tempfile+rename) with references to jsonlPath,
parentUuid, and the idempotency check so future maintainers can apply a proper
lock if needed.

In `@src/components/chat/hooks/useChatComposerState.ts`:
- Around line 960-976: The optimistic UI clears (setIsLoading,
setCanAbortSession, setClaudeStatus) before sending the abort message; modify
the abort handler in the useChatComposerState hook to attempt sendMessage(...)
inside a try/catch (or check sendMessage's returned promise/result) and, on send
failure, restore the previous UI state (revert
setIsLoading/setCanAbortSession/setClaudeStatus to their prior values) and/or
set an explicit error flag; additionally, ensure the global websocket "complete"
server event handler (the server-side complete message) is used to reconcile
final state by updating these same setters when that message arrives so UI and
backend remain consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f70119ac-0d57-40b1-be97-3fa6bd660b4f

📥 Commits

Reviewing files that changed from the base of the PR and between d9e9df1 and 39ca744.

📒 Files selected for processing (3)
  • server/claude-sdk.js
  • server/modules/websocket/services/chat-websocket.service.ts
  • src/components/chat/hooks/useChatComposerState.ts

Copy link
Copy Markdown
Author

@netrezver netrezver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review!

TOCTOU race (server/claude-sdk.js appendInterruptedAssistantEntry):
Good catch on the theoretical race. In practice the invariant holds: interrupt() is called only after this function returns, so the SDK subprocess is not writing during our read→append window. Added an inline comment documenting that assumption and noting the mitigation path (atomic tempfile+rename or file lock) for future maintainers. See the follow-up commit.

Optimistic UI clearing (useChatComposerState.ts):
Intentional — the user clicked Stop, so clearing the spinner immediately feels right regardless of whether the server confirms. If the abort fails the server's complete event will arrive anyway and the session state will reconcile naturally. Adding try/catch with state rollback for a fire-and-forget abort message would add complexity without user-visible benefit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants