@@ -775,11 +775,21 @@ class ChatViewModel(
775 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
775 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
776 // indicator lit (and a later SessionIdle re-closes it).
776 // indicator lit (and a later SessionIdle re-closes it).
777 runEndedByIdle = false
777 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
778 _failedDraft.value = null
787 _failedDraft.value = null
779 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
788 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
789+ // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
780− // while retryFailed() re-submits with the key it stashed on the original failure — so a
790+ // key it stashed on the original failure — so a retry after a lost response is deduplicated
781− // retry after a lost response is deduplicated server-side (no duplicate agent run).
791+ // server-side (no duplicate agent run).
782− val key = idempotencyKey ?: java.util.UUID.randomUUID().toString()
792+ val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
783 // Clear the staged attachments optimistically so the composer empties immediately;
793 // Clear the staged attachments optimistically so the composer empties immediately;
784 // restore them if the send fails (mirrors the draft handling below).
794 // restore them if the send fails (mirrors the draft handling below).
785 if (attachments.isNotEmpty()) _attachments.value = emptyList()
795 if (attachments.isNotEmpty()) _attachments.value = emptyList()
@@ -822,6 +832,9 @@ class ChatViewModel(
822 _running.value = false
832 _running.value = false
823 }.onSuccess {
833 }.onSuccess {
824 suppressDraftPersist.set(false)
834 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
825 // Send succeeded — the attachments rode along, so clear their persisted copy
838 // Send succeeded — the attachments rode along, so clear their persisted copy
826 // (or keep any the user staged after the optimistic clear).
839 // (or keep any the user staged after the optimistic clear).
827 persistAttachments()
840 persistAttachments()
@@ -946,7 +959,17 @@ class ChatViewModel(
946 // The text now lives in the queued chip, so clear the input field just as a
959 // The text now lives in the queued chip, so clear the input field just as a
947 // normal send would. No-op for the cancel case (blank text), which must leave
960 // normal send would. No-op for the cancel case (blank text), which must leave
948 // whatever the user has since typed untouched.
961 // whatever the user has since typed untouched.
949− if (trimmed.isNotEmpty()) _draft.value = ""
962+ if (trimmed.isNotEmpty()) {
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, "")
952 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
975 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up