@@ -775,11 +775,21 @@ class ChatViewModel(
775775 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
776776 // indicator lit (and a later SessionIdle re-closes it).
777777 runEndedByIdle = false
778+ // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
779+ // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
780+ // re-send stays deduplicated server-side instead of starting a duplicate agent run.
781+ // Capture the match BEFORE clearing _failedDraft just below.
782+ val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
783+ failedIdempotencyKey
784+ } else {
785+ null
786+ }
778787 _failedDraft.value = null
779788 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
780− // while retryFailed() re-submits with the key it stashed on the original failure — so a
781− // retry after a lost response is deduplicated server-side (no duplicate agent run).
782− val key = idempotencyKey ?: java.util.UUID.randomUUID().toString()
789+ // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
790+ // key it stashed on the original failure — so a retry after a lost response is deduplicated
791+ // server-side (no duplicate agent run).
792+ val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
783793 // Clear the staged attachments optimistically so the composer empties immediately;
784794 // restore them if the send fails (mirrors the draft handling below).
785795 if (attachments.isNotEmpty()) _attachments.value = emptyList()
@@ -822,6 +832,9 @@ class ChatViewModel(
822832 _running.value = false
823833 }.onSuccess {
824834 suppressDraftPersist.set(false)
835+ // This attempt reached the server, so the stashed retry key is spent — drop it so a
836+ // later unrelated send can't reuse it (retryFailed clears it on success too).
837+ failedIdempotencyKey = null
825838 // Send succeeded — the attachments rode along, so clear their persisted copy
826839 // (or keep any the user staged after the optimistic clear).
827840 persistAttachments()
@@ -946,7 +959,17 @@ class ChatViewModel(
946959 // The text now lives in the queued chip, so clear the input field just as a
947960 // normal send would. No-op for the cancel case (blank text), which must leave
948961 // whatever the user has since typed untouched.
949− if (trimmed.isNotEmpty()) _draft.value = ""
962+ if (trimmed.isNotEmpty()) {
963+ _draft.value = ""
964+ // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
965+ // persisted synchronously above, so if the process is killed before the debounced
966+ // draft-persist fires, the same text would remain under BOTH the draft and follow-up
967+ // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
968+ // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
969+ // this store change as our own echo and doesn't write the cleared value back.
970+ lastPersistedDraft = ""
971+ container.draftStore.flushDraft(sessionId, "")
972+ }
950973 }
951974
952975 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up