Skip to content

fix(cloud-agent-sdk): keep failed sends scoped to the chat that sent them - #8387

Merged
iscekic merged 6 commits into
mainfrom
drain/failed-send-optimistic-cleanup
Oct 21, 2026
Merged

iscekic merged 6 commits into
mainfrom
drain/failed-send-optimistic-cleanup

Conversation

@iscekic

@iscekic iscekic commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Change

packages/cloud-agent-sdk/src/session-manager.ts: a send that fails after the user switched chats used to leave its optimistic user row in the transcript that received it, and to lose the prompt text, because the failure path returned as soon as the active session had changed.

The failure path now does the chat-scoped work first and only then applies the active-chat guard:

  • The optimistic row is removed from the storage that received it and from the mounted transcript when the user is back in the chat that sent — only while it is still the unconfirmed row. A confirmed record under the same id means the server accepted the send and only the response was lost: that record stays (read from the mounted transcript when the chat is still open, and from the chat's cached first page after a switch, because the session's storage is cleared on the way out), no draft is recorded for it, any draft an earlier failure left for the chat is consumed, and no failure state is shown, so the prompt is neither shown twice nor resent. The remoteOptimisticIds entry is dropped either way.
  • The pre-send session id gets the prompt recorded (failedPromptBySession, bounded to 20 sessions like the existing delivery-failure memory). switchSession seeds failedPromptAtom from that record, so returning to the chat shows the text to retry.
  • The chat's cached first page is repaired through the existing readCachedSnapshotPage / writeCachedSnapshotPage hooks, so a chat reopened before the send settles cannot repaint the row, and a later switch away cannot store it again. Without those hooks (mobile, tests) there is no cached page to repair.
  • An accepted send from a chat clears that chat's recorded draft, even when the user already switched away.
  • The failure state (failedPromptAtom, billingFailureAtom, the status indicator, config.onSendFailed) is keyed by the chat id, not the session object, so a chat reopened mid-send still gets its draft and error, while the chat the user is looking at never inherits another chat's failure.

Tests in session-manager.test.ts cover the mid-send switch on the real manager and jotai storage: the row is gone from the sending storage and its cached page, returning to the chat paints no such row, the draft is restored for the sending chat only, an accepted send consumes it, a chat reopened before the send settles drops the repainted row while keeping the draft, a confirmed record under the same id survives a lost response with no duplicate draft, and a prompt the server confirmed after a switch records no draft.

Verification

Package (this VM):

pnpm --filter @kilocode/cloud-agent-sdk exec jest    # 35 suites, 1728 tests pass
pnpm --filter @kilocode/cloud-agent-sdk run typecheck
pnpm exec oxlint <changed files>                     # 0 warnings, 0 errors

Each new test fails on the previous behaviour and passes with the change.

End to end on this VM against the local dev stack (pnpm dev:start app agents fake-llm): real Next.js app, real cloud-agent-next Worker, Durable Object and sandbox, deterministic fake model, browser driven through the app's own UI:

  1. Opened a chat, typed a prompt, and intercepted the cloudAgentNext.sendMessage request so it stayed pending. Observed: the prompt renders in the transcript and the composer is cleared.
  2. Switched to a different chat while that request was pending, then aborted it. Observed: the other chat keeps its own transcript and draft state.
  3. Returned to the chat that sent. Observed: zero occurrences of the prompt in the transcript and the composer holds the prompt again.
  4. Reloaded the page (fresh render, live transcript only). Observed: still zero occurrences, so the row never reached the server.
  5. Reopened the sending chat before aborting, the path the review raised: the row is repainted from the cached page while the request is pending; after the abort the transcript has zero occurrences of the prompt, the composer holds it, and another switch away and back keeps it that way.

Worker-side check during step 1: no file under services/cloud-agent-next/.wrangler/state/v3/do contains the prompt text while the request is held or after the abort, so the aborted send never became server history and the missing row is the correct end state.

Screenshots of steps 1–3 and of step 5 are attached in follow-up comments.

Proxy answers

The owner's proxy gave these answers in the owner's place.

  1. Question: Ghost cleanup: Measured: switching chats mid-send writes the optimistic row into the outgoing chat's cached first page, and returning to that chat paints it back as a ghost prompt (repro confirmed in the package test with the real cache hooks). How far should the cleanup go?
    Answer: Storage + owning chat's cached page (Recommended)
    Confidence: 0.80 · Precedent: Fix the root cause and the class of the issue on every affected surface: session 7f3470e0ba6f, "fix class of issue preventing progress"; the measured ghost repaint is the same defect on the cache surface.
  2. Question: Draft memory: The failed prompt is currently a single atom that switchSession clears. How should the draft be recorded for the session that owned the row?
    Answer: Per-session map, bounded to 20 sessions (Recommended)
    Confidence: 0.65 · Precedent: Performance reviewer asks what happens at 10x or 100x the data: session 55c9c80332a7, owner defined this reviewer. The bounded map follows the existing delivery-failure memory pattern.
  3. Question: Draft lifetime: When does the recorded draft stop being offered for that chat?
    Answer: On a successful send from that chat (Recommended)
    Confidence: 0.75 · Precedent: UX must be best-in-class: session 00eb2a1d3e4b, "design and UX MUST BE BEST-IN-CLASS". Proposing already-delivered text would be a defect.

…them

A send that fails after the user switched chats now removes the optimistic
row it inserted from the transcript that received it, and records the prompt
as that chat's draft. Returning to the chat therefore shows the text to retry
and no unconfirmed row the server never received.

The record is keyed by the pre-send session id: the active chat's failed-prompt
state, billing state and send-failure callback stay untouched by the other
chat's failure. The cached first page of the chat that sent is repaired too, so
reopening it does not repaint the row, and an accepted send from a chat clears
that chat's recorded draft.
@iscekic iscekic added drain Work item from the findings drain janitor:features-workflows Janitor finding family: features-workflows labels Oct 21, 2026
@iscekic

iscekic commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Visual proof from the local dev stack (real web app, Worker, Durable Object and sandbox with the deterministic fake model; owned fake test account).

Flow exercised in one chat: type a prompt, hold the send request, switch to another chat while it is pending, abort the request, then return to the chat that sent.

  1. Pending send: the prompt renders in the transcript and the composer is cleared.
  2. The other chat after the aborted send: its transcript and draft state are unchanged.
  3. Returned to the chat that sent: zero occurrences of the prompt in the transcript, and the composer holds the prompt again for a retry.

A page reload after step 3 showed the same transcript, so the row never became server history.

Pending send rendered in the chat transcript with the composer cleared

Other chat after the aborted send, with its own transcript and draft intact

Returned to the sending chat: draft restored in the composer and no row for it in the transcript

@kilo-code-bot

kilo-code-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The revision moves delivery detection to the page cached on the way out of a chat, which is the only surface that still holds a pre-switch confirmation after switchSession destroys and clears the sending session, and the updated test now mirrors that clear; the two earlier findings are resolved.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Files Reviewed (2 files)
  • packages/cloud-agent-sdk/src/session-manager.ts - no new issues (pageShowsConfirmedUserMessage reads the cached first page for the switched-away case; the synthetic-row check keeps unconfirmed rows on the failure path; a confirmed row consumes the chat's draft and is no longer rewritten out of the cache)
  • packages/cloud-agent-sdk/src/session-manager.test.ts - no new issues (the switched-away test now records the confirmation before the switch, then clears the send-time storage explicitly to mirror createCloudAgentSession.destroy, and asserts the cache retains the row)

Verification not performed in this read-only review: I did not run the package jest suite, typecheck, or lint; findings are from reading the incremental diff (65e0aca..7573006) and the surrounding send, switch, and cache code, including apps/web/src/components/cloud-agent-next/session-transcript-cache.ts.

Previous Review Summaries (5 snapshots, latest commit 65e0aca)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 65e0aca)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The delivered detection added in this revision reads the send-time storage, which switchSession has already cleared via destroy(), so a server-confirmed prompt is still recorded as a draft and can be sent twice.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
packages/cloud-agent-sdk/src/session-manager.ts 3079 sendingRow is read from optimisticStorage, but switchSession destroys the previous session (session-manager.ts:2332) and createCloudAgentSession.destroy calls storage.clear() (session.ts:619), so after a switch that storage holds no messages. Delivery is then always misdetected as a failure: the prompt is recorded as the chat's draft and the confirmed row is removed from that chat's cached page, so returning offers the already-delivered prompt for a duplicate send.

SUGGESTION

File Line Issue
packages/cloud-agent-sdk/src/session-manager.test.ts 3228 The mock destroy is a bare jest.fn() that never clears the storage, so this test upserts a confirmation into a send-time storage that production has already cleared; making the mock mirror storage.clear() would make the test exercise the real path.
Files Reviewed (2 files)
  • packages/cloud-agent-sdk/src/session-manager.ts - 1 issue
  • packages/cloud-agent-sdk/src/session-manager.test.ts - 1 issue

Fix these issues in Kilo Cloud

Previous review (commit 7ba9ff4)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The confirmed-record guard only fires while the user is still on the sending chat, so a server-accepted send that settles after a switch is still restored as a draft for that chat and can be sent twice.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
packages/cloud-agent-sdk/src/session-manager.ts 3077 delivered requires stillOnSendingChat and only inspects the mounted storage, so a confirmed record left after a mid-send switch is recorded as a draft and removed from the cache; returning to the chat shows the delivered row plus the same prompt, enabling a duplicate send.

SUGGESTION

File Line Issue
packages/cloud-agent-sdk/src/session-manager.ts 3090 The delivered branch returns without consuming failedPromptBySession/failedPromptAtom as the success path does, so a draft recorded by an earlier failure can be re-offered after a delivered send.
Files Reviewed (2 files)
  • packages/cloud-agent-sdk/src/session-manager.ts - 2 issues
  • packages/cloud-agent-sdk/src/session-manager.test.ts - no new issues

Fix these issues in Kilo Cloud

Previous review (commit fe09a62)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The new guard correctly preserves a confirmed record in a reopened transcript, but the failure path still restores that already-accepted prompt as a draft, inviting a duplicate send.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
packages/cloud-agent-sdk/src/session-manager.ts 3078 A confirmed record is kept in the transcript, yet the same send is still recorded as a failed draft (recordFailedPrompt, failedPromptAtom, failure indicator) and dropped from the cached page, so the user can resend a prompt the server already accepted.
Files Reviewed (2 files)
  • packages/cloud-agent-sdk/src/session-manager.ts - 1 issue
  • packages/cloud-agent-sdk/src/session-manager.test.ts - no new issues

Fix these issues in Kilo Cloud

Previous review (commit be8389c)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The follow-up commit fixes the previously reported ghost row on a reopened chat: cleanup now targets the mounted storage, the cached page is always repaired, and the draft/error writes key on the chat id instead of the session object; the new test covers the switch-away-then-back path.

Files Reviewed (2 files)
  • packages/cloud-agent-sdk/src/session-manager.ts
  • packages/cloud-agent-sdk/src/session-manager.test.ts

Previous review (commit a2695e9)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The failed-send cleanup is scoped by the session's object identity, so a chat reopened before the send settles still shows the ghost optimistic row and receives no failure state.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
packages/cloud-agent-sdk/src/session-manager.ts 3073 Cleanup targets the storage captured at send time and stillOnSendingSession compares the session object, so switching away and back before the send rejects leaves the optimistic row in the mounted transcript and skips the draft/error writes; the next switch away re-caches the row.
Files Reviewed (2 files)
  • packages/cloud-agent-sdk/src/session-manager.ts - 1 issue
  • packages/cloud-agent-sdk/src/session-manager.test.ts - no issues

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4.1-flash-1017 · Input: 79.8K · Output: 21.3K · Cached: 1.2M

Review guidance: REVIEW.md from base branch main

A chat reopened while its send is still pending repaints the optimistic row
from the page cached on the way out. The failure path now also clears the
mounted transcript when it belongs to the chat that sent, keeps the prompt as
that chat's draft, and repairs the cached page so a later switch away cannot
store the row again.

The failure state is keyed by the chat id rather than the session object, so a
reopened chat still shows the retry and the error; other chats are unaffected.
@iscekic

iscekic commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Visual proof for the reopened-chat path raised in review, from the same local dev stack and fake test account.

Flow: type a prompt, hold the send, switch to another chat and come back while it is still pending, then abort.

  1. Back in the sending chat while the send is pending: the row is repainted from the cached page and the composer is empty.
  2. After the abort: zero occurrences of the prompt in the transcript, and the composer holds it again as the chat's draft.
  3. Another switch away and back keeps both: no row in the transcript, draft still in the composer.

Sending chat reopened while the send is pending: row repainted from the cached page

Same chat after the aborted send: draft restored in the composer and no row for it in the transcript

…lands

A send whose response is lost after the server accepted it can leave the
confirmed record in a reopened transcript under the client's message id. The
failure path now removes only the unconfirmed row from the mounted transcript,
matching how a retried delivery failure is handled, so server history stays
visible.
When a confirmed record for the client's message id is already in the chat,
the server accepted the prompt and only the response was lost. The failure path
now leaves that record in the transcript and records no draft, so the same
prompt is not shown twice and cannot be sent again.
…witch

The confirmed record for a prompt lands in the storage that sent it, so it is
visible even when the user has already switched away. The failure path now
reads that record too: a delivered prompt records no draft, and any draft a
previous failure left for the chat is consumed, so returning to the chat does
not offer a prompt the server already has.
Leaving a chat caches its page and then destroys the session, which clears the
storage the prompt was sent from, so the confirmation is gone from that storage
by the time the failure lands. The failure path now reads the chat's cached
first page for the confirmed record of the message id, so a prompt the server
accepted is not recorded as a draft to send again.
@iscekic iscekic added the merge:bot Bot-owned review; merge when ready once readiness passes and a review approves. label Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drain Work item from the findings drain janitor:features-workflows Janitor finding family: features-workflows merge:bot Bot-owned review; merge when ready once readiness passes and a review approves.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants