From 4e841b7558eeaa2291957d2c06353fe376ee8260 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:10:33 +0000 Subject: [PATCH] fix(native-chat): name and mark the expired attachment when a send is refused (#37552) * fix(chat): name the attachment that expired when a send is refused The claim step knew which uploads were gone but threw a bare error, so the refusal could only say "This attachment expired". It now carries each expired upload's id and file name: every client names them, and desktop marks those image chips. Older hosts and clients keep today's generic words. * test(chat): read expired-attachment details through the wire reader --- mobile/src/i18n/locales/de.json | 2 + mobile/src/i18n/locales/en.json | 2 + mobile/src/i18n/locales/es.json | 2 + mobile/src/i18n/locales/fr.json | 2 + mobile/src/i18n/locales/ja.json | 2 + mobile/src/i18n/locales/ko.json | 2 + mobile/src/i18n/locales/pt.json | 2 + mobile/src/i18n/locales/ru.json | 2 + mobile/src/i18n/locales/tr.json | 2 + mobile/src/i18n/locales/zh-Hant.json | 2 + mobile/src/i18n/locales/zh.json | 2 + .../agent-session-send-failure-copy.ts | 2 + .../agent-session-write-notice-copy.test.ts | 19 ++++ .../agent-session-write-notice-copy.ts | 17 ++-- .../agent-session-attachment-claims.test.ts | 26 ++++++ .../agent-session-attachment-claims.ts | 23 ++++- .../agent-session-attachment-references.ts | 70 +++++++++++++-- .../journal-storage-schema.ts | 1 + ...agent-session-attachment-admission.test.ts | 28 ++++++ .../structured-agent-session-queued-edit.ts | 2 +- ...tructured-agent-session-queued-messages.ts | 2 +- .../structured-agent-session-thread-goal.ts | 2 +- .../structured-agent-session-turns.ts | 21 +++-- .../NativeChatFileReferencePill.tsx | 2 +- ...t-session-failure-attachment-words-text.ts | 6 ++ .../agent-session-write-notice-text.ts | 20 +++-- .../native-chat/native-chat-image-paste.ts | 6 -- ...ed-agent-session-message-hand-back.test.ts | 16 ++++ ...uctured-agent-session-message-hand-back.ts | 36 +++++--- ...tured-agent-session-message-sender.test.ts | 16 ++-- ...structured-agent-session-message-sender.ts | 10 +-- .../structured-agent-session-send-runtime.ts | 34 ++++++-- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/de.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/pt.json | 2 + src/renderer/src/i18n/locales/ru.json | 2 + src/renderer/src/i18n/locales/tr.json | 2 + src/renderer/src/i18n/locales/zh-Hant.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + .../agent-session-attachments.ts | 7 ++ .../agent-session-attachment-failure-words.ts | 18 +++- .../agent-session-expired-attachments.ts | 51 +++++++++++ .../failures/agent-session-failure-copy.ts | 3 + .../agent-session-legacy-refusal-fields.ts | 86 +++++++++++++++++++ .../failures/agent-session-refusal-details.ts | 86 +++---------------- .../agent-session-refusal-notice.test.ts | 51 +++++++++++ .../failures/agent-session-refusal-notice.ts | 17 +++- .../failures/agent-session-wire-refusals.ts | 4 +- .../failures/agent-session-write-failure.ts | 5 +- .../agent-session-write-notice-copy.ts | 2 + .../native-chat/native-chat-image-label.ts | 6 ++ 56 files changed, 598 insertions(+), 143 deletions(-) create mode 100644 mobile/src/session/agent-session-write-notice-copy.test.ts create mode 100644 src/shared/agent-session/failures/agent-session-expired-attachments.ts create mode 100644 src/shared/agent-session/failures/agent-session-legacy-refusal-fields.ts diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 4f89b563259..1292da04427 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -1838,6 +1838,7 @@ "attachmentAtMost": "{{agent}} akzeptiert höchstens {{limit}} Bilder pro Nachricht, daher wurde diese Nachricht nicht gesendet.", "attachmentEmpty": "Ein Bild in dieser Nachricht ist leer, daher wurde die Nachricht nicht gesendet.", "attachmentInvalid": "Ein Anhang dieser Nachricht kann nicht an den Agenten gesendet werden.", + "attachmentsExpired": "Abgelaufen: {{names}}.", "attachmentLargerThan": "Ein Bild in dieser Nachricht ist größer als {{size}} MB, daher wurde die Nachricht nicht gesendet.", "attachmentNoSource": "Für ein Bild in dieser Nachricht fehlt die zu sendende Datei, daher wurde die Nachricht nicht gesendet.", "attachmentNotAFile": "Ein Bild in dieser Nachricht ist keine Datei, daher wurde die Nachricht nicht gesendet.", @@ -2422,6 +2423,7 @@ "queueTooLarge": "In der Warteschlange befindet sich zu viel Text.", "quitTerminalAgent": "Diesen Agenten beenden, um den Chat hier fortzusetzen.", "reattachFile": "Den Anhang entfernen und erneut anhängen.", + "reattachFiles": "Die Anhänge entfernen und erneut anhängen.", "recordFailed": "Orca konnte dies nicht im Chatverlauf speichern.", "recordUnreadable": "Orca konnte den gespeicherten Zustand dieses Chats nicht lesen.", "reopenChat": "Den Chat erneut öffnen, um dies nochmals zu prüfen.", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 8925fe7dba6..05aeec83087 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -2490,6 +2490,7 @@ "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.", "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.", "attachmentInvalid": "An attachment on this message can't be sent to the agent.", + "attachmentsExpired": "{{names}} expired.", "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.", "emptyMessage": "This message is empty, so it was not sent.", "commandUnavailable": "This command isn't available in {{agent}} chats, so it was not sent.", @@ -2588,6 +2589,7 @@ "recordFailed": "Orca couldn't save this to the chat's history.", "attachmentExpired": "This attachment expired.", "reattachFile": "Remove it and attach it again.", + "reattachFiles": "Remove them and attach them again.", "conversationCleared": "This conversation has been cleared.", "openCurrentConversation": "Open the current conversation to continue.", "clearUnfinished": "The last /clear didn't finish.", diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index c6078e3b66a..5cb8f1eeb13 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1903,6 +1903,7 @@ "attachmentAtMost": "{{agent}} acepta como máximo {{limit}} imágenes por mensaje, así que este mensaje no se envió.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", "attachmentInvalid": "Un archivo adjunto de este mensaje no se puede enviar al agente.", + "attachmentsExpired": "Caducado: {{names}}.", "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}} MB, así que el mensaje no se envió.", "attachmentNoSource": "Una imagen de este mensaje no tiene ningún archivo que enviar, así que el mensaje no se envió.", "attachmentNotAFile": "Una imagen de este mensaje no es un archivo, así que el mensaje no se envió.", @@ -2489,6 +2490,7 @@ "queueTooLarge": "Hay demasiado texto esperando en la cola.", "quitTerminalAgent": "Cierre ese agente para continuar el chat aquí.", "reattachFile": "Quítelo y vuelva a adjuntarlo.", + "reattachFiles": "Quítelos y vuelva a adjuntarlos.", "recordFailed": "Orca no pudo guardar esto en el historial del chat.", "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", "reopenChat": "Vuelva a abrir el chat para comprobarlo de nuevo.", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index fb2c0c28518..f42fa240c1b 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -1903,6 +1903,7 @@ "attachmentAtMost": "{{agent}} accepte au maximum {{limit}} images par message, ce message n'a donc pas été envoyé.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", "attachmentInvalid": "Une pièce jointe de ce message ne peut pas être envoyée à l'agent.", + "attachmentsExpired": "Expiré : {{names}}.", "attachmentLargerThan": "Une image de ce message dépasse {{size}} Mo, le message n'a donc pas été envoyé.", "attachmentNoSource": "Une image de ce message n'a aucun fichier à envoyer, le message n'a donc pas été envoyé.", "attachmentNotAFile": "Une image de ce message n'est pas un fichier, le message n'a donc pas été envoyé.", @@ -2489,6 +2490,7 @@ "queueTooLarge": "Trop de texte est en attente dans la file.", "quitTerminalAgent": "Quittez cet agent pour continuer le chat ici.", "reattachFile": "Retirez-la et joignez-la à nouveau.", + "reattachFiles": "Retirez-les et joignez-les à nouveau.", "recordFailed": "Orca n'a pas pu enregistrer ceci dans l'historique du chat.", "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", "reopenChat": "Rouvrez le chat pour vérifier à nouveau.", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 904f09be2e5..0f86c97b984 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1834,6 +1834,7 @@ "attachmentAtMost": "{{agent}} が 1 つのメッセージで受け付ける画像は最大 {{limit}} 枚のため、このメッセージは送信されませんでした。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", "attachmentInvalid": "このメッセージの添付ファイルは Agent に送信できません。", + "attachmentsExpired": "{{names}} は期限切れです。", "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。", "attachmentNoSource": "このメッセージの画像に送信するファイルがないため、メッセージは送信されませんでした。", "attachmentNotAFile": "このメッセージの画像がファイルではないため、メッセージは送信されませんでした。", @@ -2418,6 +2419,7 @@ "queueTooLarge": "キューで待機中のテキストが多すぎます。", "quitTerminalAgent": "ここでチャットを続けるには、その Agent を終了してください。", "reattachFile": "削除してから、もう一度添付してください。", + "reattachFiles": "削除してから、もう一度添付してください。", "recordFailed": "Orca はこれをチャットの履歴に保存できませんでした。", "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", "reopenChat": "もう一度確認するには、チャットを開き直してください。", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 8eaeb68326b..99d39517bc0 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1834,6 +1834,7 @@ "attachmentAtMost": "{{agent}}은(는) 메시지 하나에 이미지를 최대 {{limit}}개까지 받을 수 있어 이 메시지가 전송되지 않았습니다.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", "attachmentInvalid": "이 메시지의 첨부 파일은 에이전트에 보낼 수 없습니다.", + "attachmentsExpired": "{{names}}이(가) 만료되었습니다.", "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.", "attachmentNoSource": "이 메시지의 이미지에 보낼 파일이 없어 메시지가 전송되지 않았습니다.", "attachmentNotAFile": "이 메시지의 이미지가 파일이 아니어서 메시지가 전송되지 않았습니다.", @@ -2418,6 +2419,7 @@ "queueTooLarge": "대기열에 쌓인 텍스트가 너무 많습니다.", "quitTerminalAgent": "여기에서 채팅을 계속하려면 해당 에이전트를 종료하세요.", "reattachFile": "제거한 후 다시 첨부하세요.", + "reattachFiles": "제거한 후 다시 첨부하세요.", "recordFailed": "Orca가 이 내용을 채팅 기록에 저장하지 못했습니다.", "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", "reopenChat": "다시 확인하려면 채팅을 다시 여세요.", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index 90ee3fe79d9..b86fc2de182 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -1903,6 +1903,7 @@ "attachmentAtMost": "{{agent}} aceita no máximo {{limit}} imagens por mensagem, por isso esta mensagem não foi enviada.", "attachmentEmpty": "Uma imagem nesta mensagem está vazia, por isso a mensagem não foi enviada.", "attachmentInvalid": "Um anexo nesta mensagem não pode ser enviado ao agente.", + "attachmentsExpired": "Expirado: {{names}}.", "attachmentLargerThan": "Uma imagem nesta mensagem tem mais de {{size}} MB, por isso a mensagem não foi enviada.", "attachmentNoSource": "Uma imagem nesta mensagem não tem um arquivo para enviar, por isso a mensagem não foi enviada.", "attachmentNotAFile": "Uma imagem nesta mensagem não é um arquivo, por isso a mensagem não foi enviada.", @@ -2489,6 +2490,7 @@ "queueTooLarge": "Há texto demais aguardando na fila.", "quitTerminalAgent": "Encerre esse agente para continuar o chat aqui.", "reattachFile": "Remova-o e anexe-o novamente.", + "reattachFiles": "Remova-os e anexe-os novamente.", "recordFailed": "O Orca não conseguiu salvar isto no histórico do chat.", "recordUnreadable": "O Orca não conseguiu ler o estado salvo deste chat.", "reopenChat": "Reabra o chat para verificar novamente.", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index e6e60a526da..0664fbaa435 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -1968,6 +1968,7 @@ "attachmentAtMost": "{{agent}} принимает не больше {{limit}} изображений в сообщении, поэтому оно не отправлено.", "attachmentEmpty": "В сообщении пустое изображение, поэтому сообщение не отправлено.", "attachmentInvalid": "Вложение в этом сообщении нельзя отправить агенту.", + "attachmentsExpired": "Срок хранения истёк: {{names}}.", "attachmentLargerThan": "Размер изображения в сообщении превышает {{size}} МБ, поэтому сообщение не отправлено.", "attachmentNoSource": "Для одного из изображений нет исходного файла, поэтому сообщение не отправлено.", "attachmentNotAFile": "Одно из изображений в сообщении не является файлом, поэтому сообщение не отправлено.", @@ -2556,6 +2557,7 @@ "queueTooLarge": "В очереди слишком много текста.", "quitTerminalAgent": "Завершите работу этого агента, чтобы продолжить чат здесь.", "reattachFile": "Удалите его и прикрепите снова.", + "reattachFiles": "Удалите их и прикрепите снова.", "recordFailed": "Orca не удалось сохранить это в истории чата.", "recordUnreadable": "Orca не удалось прочитать сохранённое состояние этого чата.", "reopenChat": "Откройте чат заново для повторной проверки.", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index e66eaa44032..2c93fc79541 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -1838,6 +1838,7 @@ "attachmentAtMost": "{{agent}} tek mesajda en fazla {{limit}} görsel kabul ettiği için bu mesaj gönderilmedi.", "attachmentEmpty": "Bu mesajdaki bir görsel boş olduğu için mesaj gönderilmedi.", "attachmentInvalid": "Bu mesajdaki bir ek ajana gönderilemiyor.", + "attachmentsExpired": "Süresi doldu: {{names}}.", "attachmentLargerThan": "Bu mesajdaki bir görsel {{size}} MB sınırını aştığı için mesaj gönderilmedi.", "attachmentNoSource": "Bu mesajdaki bir görselin gönderilecek dosyası bulunmadığı için mesaj gönderilmedi.", "attachmentNotAFile": "Bu mesajdaki görsellerden biri dosya olmadığı için mesaj gönderilmedi.", @@ -2422,6 +2423,7 @@ "queueTooLarge": "Kuyrukta çok fazla metin bekliyor.", "quitTerminalAgent": "Sohbete burada devam etmek için o ajandan çıkın.", "reattachFile": "Kaldırıp yeniden ekleyin.", + "reattachFiles": "Kaldırıp yeniden ekleyin.", "recordFailed": "Orca bunu sohbet geçmişine kaydedemedi.", "recordUnreadable": "Orca'da bu sohbetin kayıtlı durumu okunamadı.", "reopenChat": "Yeniden kontrol etmek için sohbeti tekrar açın.", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index bd22ce3a922..a301d1cb98c 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -1834,6 +1834,7 @@ "attachmentAtMost": "{{agent}} 每則訊息最多接受 {{limit}} 張圖片,因此此訊息未傳送。", "attachmentEmpty": "此訊息中的一張圖片為空,因此訊息未傳送。", "attachmentInvalid": "此訊息中的附件無法傳送給代理程式。", + "attachmentsExpired": "{{names}} 已過期。", "attachmentLargerThan": "此訊息中的一張圖片超過 {{size}} MB,因此訊息未傳送。", "attachmentNoSource": "此訊息中的一張圖片沒有可傳送的檔案,因此訊息未傳送。", "attachmentNotAFile": "此訊息中的一張圖片不是檔案,因此訊息未傳送。", @@ -2418,6 +2419,7 @@ "queueTooLarge": "佇列中等待的文字過多。", "quitTerminalAgent": "請結束該代理程式,以便在此處繼續聊天。", "reattachFile": "請將其移除後重新附加。", + "reattachFiles": "請將它們移除後重新附加。", "recordFailed": "Orca 無法將此內容儲存到聊天歷史記錄中。", "recordUnreadable": "Orca 無法讀取此聊天的已儲存狀態。", "reopenChat": "請重新開啟聊天以再次檢查。", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index b09de5f9621..21fbe88459f 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1834,6 +1834,7 @@ "attachmentAtMost": "{{agent}} 每条消息最多接受 {{limit}} 张图片,因此此消息未发送。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", "attachmentInvalid": "此消息中的附件无法发送给智能体。", + "attachmentsExpired": "{{names}} 已过期。", "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。", "attachmentNoSource": "此消息中的一张图片没有可发送的文件,因此消息未发送。", "attachmentNotAFile": "此消息中的一张图片不是文件,因此消息未发送。", @@ -2418,6 +2419,7 @@ "queueTooLarge": "队列中等待的文本过多。", "quitTerminalAgent": "请退出该智能体,以便在此处继续聊天。", "reattachFile": "请将其移除后重新附加。", + "reattachFiles": "请将它们移除后重新附加。", "recordFailed": "Orca 无法将此内容保存到聊天历史记录中。", "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", "reopenChat": "请重新打开聊天以再次检查。", diff --git a/mobile/src/session/agent-session-send-failure-copy.ts b/mobile/src/session/agent-session-send-failure-copy.ts index 88d4feaa09d..63a8a724dbb 100644 --- a/mobile/src/session/agent-session-send-failure-copy.ts +++ b/mobile/src/session/agent-session-send-failure-copy.ts @@ -109,6 +109,8 @@ export const SEND_FAILURE_COPY = { "An attachment on this message can't be sent to the agent.", values ), + attachmentsExpired: (values) => + translate('session.failure.attachmentsExpired', '{{names}} expired.', values), attachmentUnreadable: (values) => translate( 'session.failure.attachmentUnreadable', diff --git a/mobile/src/session/agent-session-write-notice-copy.test.ts b/mobile/src/session/agent-session-write-notice-copy.test.ts new file mode 100644 index 00000000000..5788ba290c2 --- /dev/null +++ b/mobile/src/session/agent-session-write-notice-copy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionWriteNoticeParts } from '../../../src/shared/agent-session/failures/agent-session-refusal-notice' +import { agentSessionRefusalFailure } from '../../../src/shared/agent-session/failures/agent-session-write-failure' +import { mobileAgentSessionWriteNoticeText } from './agent-session-write-notice-copy' + +describe('mobileAgentSessionWriteNoticeText', () => { + it('names the attachment an expired refusal lists, as its card labels it', () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { + reason: 'attachmentExpired', + expiredAttachments: [{ uploadId: 'upload-1', name: 'notes.pdf' }] + } + }) + expect( + mobileAgentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, 'composer-send')) + ).toBe('notes.pdf expired. Your message was not sent. Remove it and attach it again.') + }) +}) diff --git a/mobile/src/session/agent-session-write-notice-copy.ts b/mobile/src/session/agent-session-write-notice-copy.ts index 01c09d0a084..ffab10462a5 100644 --- a/mobile/src/session/agent-session-write-notice-copy.ts +++ b/mobile/src/session/agent-session-write-notice-copy.ts @@ -1,3 +1,4 @@ +import { expiredAttachmentsSentence } from '../../../src/shared/agent-session/failures/agent-session-attachment-failure-words' import { agentSessionFailureSentence } from '../../../src/shared/agent-session/failures/agent-session-failure-words' import type { AgentSessionWriteNoticePart, @@ -95,6 +96,8 @@ const SENTENCES: Record string> = { translate('session.writeNotice.attachmentExpired', 'This attachment expired.'), reattachFile: () => translate('session.writeNotice.reattachFile', 'Remove it and attach it again.'), + reattachFiles: () => + translate('session.writeNotice.reattachFiles', 'Remove them and attach them again.'), conversationCleared: () => translate('session.writeNotice.conversationCleared', 'This conversation has been cleared.'), openCurrentConversation: () => @@ -249,12 +252,14 @@ export function mobileAgentSessionWriteNoticeText( ? SENTENCES[part]() : 'text' in part ? part.text - : agentSessionFailureSentence( - part.failure, - part.surface, - part.context, - sayMobileAgentSessionFailure - ) + : 'expiredAttachmentNames' in part + ? expiredAttachmentsSentence(part.expiredAttachmentNames, sayMobileAgentSessionFailure) + : agentSessionFailureSentence( + part.failure, + part.surface, + part.context, + sayMobileAgentSessionFailure + ) ) .join(' ') } diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts index 60d80417eb0..df2d8c5356f 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -15,6 +15,7 @@ import { markUnclaimedUploadForSweep } from './agent-session-attachment-claims' import { + agentSessionAttachmentReferenceNames, agentSessionAttachmentReferences, agentSessionAttachmentStoreRoot } from './agent-session-attachment-references' @@ -44,6 +45,31 @@ describe('agentSessionAttachmentReferences', () => { expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) }) + it.each([ + [ + 'an image path, whose spaces are part of it', + { type: 'image-ref', path: 'my shot.png' }, + 'my shot.png' + ], + ['a quoted reference', { type: 'text', text: 'read @"$/my notes.txt" please' }, 'my notes.txt'], + [ + 'a bare reference, at the next space', + { type: 'text', text: 'read @$/notes.txt please' }, + 'notes.txt' + ], + ['a reference with no file name, by its id', { type: 'text', text: 'see $ please' }, UPLOAD_A] + ] as const)('names %s', (_case, block, name) => { + const stored = `${root}/${UPLOAD_A}` + const body = message( + block.type === 'text' + ? { ...block, text: block.text.replace('$', stored) } + : { ...block, path: `${stored}/${block.path}` } + ) + expect(agentSessionAttachmentReferenceNames(root, body, [UPLOAD_A], 'linux')).toEqual([ + { uploadId: UPLOAD_A, name } + ]) + }) + it("leaves a mention of any other store path as text: another server's, a ~ form, a bare name", () => { const mentions = [ `@/home/other/agent-session-attachments/${UPLOAD_A}/a.txt`, diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts index d688bee71fb..d3ff4fbae76 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -5,8 +5,13 @@ import { join } from 'node:path' import { isDefinitiveAbsence } from '../../../shared/definitive-filesystem-absence' import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session/journal/agent-session-journal-types' +import { + AGENT_SESSION_EXPIRED_ATTACHMENTS_MAX, + type AgentSessionExpiredAttachment +} from '../../../shared/agent-session/failures/agent-session-expired-attachments' import { AGENT_SESSION_ATTACHMENT_PART_FILE, + agentSessionAttachmentReferenceNames, agentSessionAttachmentReferences, agentSessionAttachmentStoreRoot } from './agent-session-attachment-references' @@ -30,9 +35,12 @@ CREATE TABLE IF NOT EXISTS agent_session_attachment_sweeps ( } export class AgentSessionAttachmentExpiredError extends Error { - constructor() { + readonly expired: readonly AgentSessionExpiredAttachment[] + + constructor(expired: readonly AgentSessionExpiredAttachment[]) { super('A referenced chat attachment is no longer stored on this host.') this.name = 'AgentSessionAttachmentExpiredError' + this.expired = expired } } @@ -103,8 +111,17 @@ export function claimAgentSessionAttachmentsInTransaction( ? agentSessionAttachmentReferences(root, claim.previous) : new Set() const required = claim.required ? [...uploadIds].filter((id) => !kept.has(id)) : [] - if (required.some((uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId))) { - throw new AgentSessionAttachmentExpiredError() + const expired = required.filter( + (uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId) + ) + if (expired.length > 0) { + throw new AgentSessionAttachmentExpiredError( + agentSessionAttachmentReferenceNames( + root, + claim.body, + expired.slice(0, AGENT_SESSION_EXPIRED_ATTACHMENTS_MAX) + ) + ) } const inserted = insertClaims(db, required, claim.sessionId, claim.now) const bestEffort = [...uploadIds].filter((id) => !required.includes(id)) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts index b0c95e5eaf3..8ee1bb519d8 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts @@ -1,5 +1,9 @@ import { isAbsolute, join, relative, sep } from 'node:path' -import { AGENT_SESSION_ATTACHMENTS_DIR_NAME } from '../../../shared/agent-session/agent-session-attachments' +import { + AGENT_SESSION_ATTACHMENTS_DIR_NAME, + sanitizeAgentSessionAttachmentName +} from '../../../shared/agent-session/agent-session-attachments' +import type { AgentSessionExpiredAttachment } from '../../../shared/agent-session/failures/agent-session-expired-attachments' import type { AgentJournalMessageItem } from '../../../shared/agent-session/journal/agent-session-journal-types' export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' @@ -48,13 +52,20 @@ function normalizedRoot(root: string, platform: NodeJS.Platform): string { return platform === 'win32' ? forward.toLowerCase() : forward } +type AttachmentReference = { + uploadId: string + block: AgentJournalMessageItem['blocks'][number] + text: string + rootStart: number + idEnd: number +} + /** Match exact decoded store roots; quoting/spaces cannot hide references and Windows accepts either separator. */ -export function agentSessionAttachmentReferences( +function* attachmentReferences( root: string, body: AgentJournalMessageItem, - platform: NodeJS.Platform = process.platform -): Set { - const uploadIds = new Set() + platform: NodeJS.Platform +): Generator { const ownRoot = normalizedRoot(root, platform) const pattern = new RegExp( `${AGENT_SESSION_ATTACHMENTS_DIR_NAME}[\\\\/](${UPLOAD_ID_SOURCE})(?![0-9a-z-])`, @@ -76,9 +87,56 @@ export function agentSessionAttachmentReferences( !PATH_CHARACTER.test(text.charAt(rootStart - 1)) && normalizedRoot(text.slice(rootStart, rootEnd), platform) === ownRoot ) { - uploadIds.add(uploadId) + yield { uploadId, block, text, rootStart, idEnd: match.index + match[0].length } } } } +} + +export function agentSessionAttachmentReferences( + root: string, + body: AgentJournalMessageItem, + platform: NodeJS.Platform = process.platform +): Set { + const uploadIds = new Set() + for (const { uploadId } of attachmentReferences(root, body, platform)) { + uploadIds.add(uploadId) + } return uploadIds } + +/** An image block is the whole path; a text reference ends at its quote, or at whitespace because only paths without any go unquoted. */ +function referencedFileName({ block, text, rootStart, idEnd }: AttachmentReference): string | null { + if (!/[\\/]/.test(text.charAt(idEnd))) { + return null + } + const rest = text.slice(idEnd + 1) + const opening = text.charAt(rootStart - 1) + const end = + block.type === 'image-ref' + ? -1 + : opening === '"' || opening === "'" + ? rest.indexOf(opening) + : rest.search(/\s/) + const name = (end === -1 ? rest : rest.slice(0, end)).split(/[\\/]/)[0] + return name ? sanitizeAgentSessionAttachmentName(name) : null +} + +/** Each listed upload with the file name the message gave it, or its id when it gave none, since an expired upload has nothing left on disk to read one from. */ +export function agentSessionAttachmentReferenceNames( + root: string, + body: AgentJournalMessageItem, + uploadIds: readonly string[], + platform: NodeJS.Platform = process.platform +): AgentSessionExpiredAttachment[] { + const names = new Map() + for (const reference of attachmentReferences(root, body, platform)) { + if (!names.has(reference.uploadId)) { + const name = referencedFileName(reference) + if (name) { + names.set(reference.uploadId, name) + } + } + } + return uploadIds.map((uploadId) => ({ uploadId, name: names.get(uploadId) ?? uploadId })) +} diff --git a/src/main/native-chat/agent-session-journal/journal-storage-schema.ts b/src/main/native-chat/agent-session-journal/journal-storage-schema.ts index 3b1656b1ec8..87bbf9eaa6d 100644 --- a/src/main/native-chat/agent-session-journal/journal-storage-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-storage-schema.ts @@ -56,6 +56,7 @@ const refusalDetails = z.object({ codexInstallation: z .object({ installedVersion: z.string().nullable(), minimumVersion: z.string() }) .optional(), + expiredAttachments: z.array(z.object({ uploadId: z.string(), name: z.string() })).optional(), currentFence: count, currentRevision: count, resolution: AgentJournalKnownSchemas.resolution.optional(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts index f678f4f9f16..c61dc5479c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -1,5 +1,6 @@ // Direct/queued admission rejects missing attachments atomically; stored claims survive clear migration. +import { existsSync } from 'node:fs' import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -76,6 +77,33 @@ describe('attachments at send admission', () => { expect(rig.dispatch).not.toHaveBeenCalled() }) + it('names only the expired upload when a fresh one is sent beside it', async () => { + const fresh = await storeUpload('fresh.png') + const expiredUpload = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' + const expired = join(agentSessionAttachmentStoreRoot(rig.root), expiredUpload, 'old photo.png') + const result = await clientSend({ + kind: 'message', + role: 'user', + blocks: [ + { type: 'image-ref', path: fresh }, + { type: 'image-ref', path: expired } + ] + }) + expect(result).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { + reason: 'attachmentExpired', + expiredAttachments: [{ uploadId: expiredUpload, name: 'old photo.png' }] + } + } + }) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(0) + expect(claimedSessions()).toEqual([]) + expect(existsSync(fresh)).toBe(true) + }) + it('refuses a queued send naming an expired file, and queues nothing', async () => { await rig.workingSend() expect(await clientSend(textWith(storedPath('notes.txt')), 'queue-if-active')).toMatchObject( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts index e2a9a08105d..f5126287cb7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts @@ -62,7 +62,7 @@ export function updateQueuedStructuredAgentMessage( return { ok: true, value: await queued.update(fields) } } catch (error) { if (isAgentSessionAttachmentExpiredError(error)) { - return agentSessionAttachmentExpiredRefusal() + return agentSessionAttachmentExpiredRefusal(error) } throw error } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 228b4462552..3c7ab211b5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -188,7 +188,7 @@ export async function maybeQueueStructuredAgentSessionSend( ) } catch (error) { if (isAgentSessionAttachmentExpiredError(error)) { - return agentSessionAttachmentExpiredRefusal() + return agentSessionAttachmentExpiredRefusal(error) } throw error } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 4e21300be9b..df51f5a089d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -101,7 +101,7 @@ export async function performThreadGoalChange( ) } catch (error) { if (isAgentSessionAttachmentExpiredError(error)) { - return agentSessionAttachmentExpiredRefusal() + return agentSessionAttachmentExpiredRefusal(error) } throw error } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index c7fb2ec1e46..e843f570799 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -25,7 +25,10 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' +import { + isAgentSessionAttachmentExpiredError, + type AgentSessionAttachmentExpiredError +} from '../agent-session-attachments/agent-session-attachment-claims' import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import type { StructuredAgentRegistry } from './structured-agent-registry' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -70,14 +73,18 @@ function invalid( return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } -export function agentSessionAttachmentExpiredRefusal(): { +export function agentSessionAttachmentExpiredRefusal(error: AgentSessionAttachmentExpiredError): { ok: false refusal: AgentSessionWireRefusal } { - return invalid( - 'attachmentExpired', - 'A chat attachment in this message is no longer stored on the host, so it was not sent.' - ) + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'attachmentExpired', expiredAttachments: error.expired }, + 'A chat attachment in this message is no longer stored on the host, so it was not sent.' + ) + } } /** Accept once per ID; existing submissions replay without redelivery because unknown cannot prove non-delivery. @@ -131,7 +138,7 @@ export async function performSend( error }) if (isAgentSessionAttachmentExpiredError(error)) { - return agentSessionAttachmentExpiredRefusal() + return agentSessionAttachmentExpiredRefusal(error) } // Preserve typed refusals, corrupt-database errors and newer-version guards; never retry writes past them. if ( diff --git a/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx b/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx index c76ed34ed93..9b8bcb45c78 100644 --- a/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx +++ b/src/renderer/src/components/native-chat/NativeChatFileReferencePill.tsx @@ -5,7 +5,7 @@ import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' import { getFileTypeIcon } from '@/lib/file-type-icons' import { NativeChatPromptPill } from './NativeChatPromptPill' -import { isNativeChatPastedTextPath } from './native-chat-image-paste' +import { isNativeChatPastedTextPath } from '../../../../shared/native-chat/native-chat-image-label' import { nativeChatPasteNotSavedNotice, nativeChatPastedTextId, diff --git a/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts index f5ef857f73b..430382e10f8 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-attachment-words-text.ts @@ -47,6 +47,12 @@ export const ATTACHMENT_FAILURE_PIECES = { translate('components.native-chat.failureWords.attachmentNoSource', COPY.attachmentNoSource), attachmentInvalid: () => translate('components.native-chat.failureWords.attachmentInvalid', COPY.attachmentInvalid), + attachmentsExpired: (values) => + translate( + 'components.native-chat.failureWords.attachmentsExpired', + COPY.attachmentsExpired, + values + ), attachmentUnreadable: () => translate('components.native-chat.failureWords.attachmentUnreadable', COPY.attachmentUnreadable) } satisfies Partial< diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index de906bd8fc5..20d0103782b 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -1,6 +1,7 @@ // Translate whole shared sentences with their English fallbacks so desktop and mobile wording agree. import { translate } from '@/i18n/i18n' +import { expiredAttachmentsSentence } from '../../../../shared/agent-session/failures/agent-session-attachment-failure-words' import { agentSessionFailureSentence, type AgentSessionFailureWordsContext @@ -89,6 +90,8 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.attachmentExpired', COPY.attachmentExpired), reattachFile: () => translate('components.native-chat.writeNotice.reattachFile', COPY.reattachFile), + reattachFiles: () => + translate('components.native-chat.writeNotice.reattachFiles', COPY.reattachFiles), conversationCleared: () => translate('components.native-chat.writeNotice.conversationCleared', COPY.conversationCleared), openCurrentConversation: () => @@ -188,12 +191,17 @@ export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNot ? SENTENCES[part]() : 'text' in part ? part.text - : agentSessionFailureSentence( - part.failure, - part.surface, - part.context, - sayAgentSessionFailureTranslated - ) + : 'expiredAttachmentNames' in part + ? expiredAttachmentsSentence( + part.expiredAttachmentNames, + sayAgentSessionFailureTranslated + ) + : agentSessionFailureSentence( + part.failure, + part.surface, + part.context, + sayAgentSessionFailureTranslated + ) ) ) } diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.ts index 442613d03f8..73fe6e7720d 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.ts @@ -10,12 +10,6 @@ export function isNativeChatImageAttachmentPath(path: string): boolean { return isImageDropPath(path) } -/** True for a long text paste Orca saved as a file (`orca-paste--.txt`). */ -export function isNativeChatPastedTextPath(path: string): boolean { - const base = path.split(/[\\/]/).findLast(Boolean) ?? path - return /^orca-paste-.+\.txt$/i.test(base) -} - /** Recognize legacy paste-folder paths; main still validates the actual file before restore. */ export function isNativeChatKeptPastePath(path: string): boolean { const parts = path.split(/[\\/]/).filter(Boolean) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.test.ts index 91ee4fd795f..7f308feda43 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.test.ts @@ -24,3 +24,19 @@ it('gives a message back to its conversation draft, each image on the host it li { id: 'returned-m1-2', path: '/local/b.png' } ]) }) + +it('marks only the expired image of a returned message, so the other can still be sent', () => { + const expired = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' + const fresh = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' + const store = '/srv/agent-session-attachments' + const body = structuredAgentSessionSendBody('look at these', [ + { path: `${store}/${expired}/old.png`, previewUri: 'a' }, + { path: `${store}/${fresh}/old.png`, previewUri: 'b' } + ]) + handBackStructuredAgentSessionMessage('session-1', 'm1', body, undefined, new Set([expired])) + const draft = readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('session-1')) + expect(draft.images).toEqual([ + { id: 'returned-m1-1', path: '', unavailableName: 'old.png' }, + { id: 'returned-m1-2', path: `${store}/${fresh}/old.png` } + ]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.ts index 139f78fb631..5f53670c600 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.ts @@ -1,5 +1,10 @@ +import { agentSessionAttachmentUploadIdOfPath } from '../../../../shared/agent-session/agent-session-attachments' import type { AgentJournalMessageItem } from '../../../../shared/agent-session/journal/agent-session-journal-types' -import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' +import type { NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' +import { + structuredAgentSessionDraftScopeKey, + unavailableNativeChatComposerDraftImage +} from './native-chat-composer-draft-store' import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { promptMarkersToImageReferences } from '../../../../shared/native-chat/transcript/native-chat-image-transcript-markers' @@ -14,7 +19,9 @@ export function returnMessageToComposer( attachmentIdPrefix: string, blocks: AgentJournalMessageItem['blocks'], /** Each image's SSH connection, in block order. */ - imageConnectionIds?: readonly (string | null)[] + imageConnectionIds?: readonly (string | null)[], + /** Uploads the host no longer stores: their chips come back asking to be attached again. */ + expiredUploadIds?: ReadonlySet ): boolean { // Its `[Image #k]` markers become chips for its k-th image, by the ids its images get below. const imageIds = blocks.flatMap((block, index) => @@ -22,20 +29,19 @@ export function returnMessageToComposer( ) let image = 0 // Not the attachment hook's helper: its imports reach the store, whose teardown imports this. - const images = blocks.flatMap((block, index) => { + const images = blocks.flatMap((block, index): NativeChatComposerDraftImage[] => { if (block.type !== 'image-ref') { return [] } const connectionId = imageConnectionIds?.[image++] - return block.path - ? [ - { - id: `${attachmentIdPrefix}-${index}`, - path: block.path, - ...(connectionId ? { connectionId } : {}) - } - ] - : [] + if (!block.path) { + return [] + } + const id = `${attachmentIdPrefix}-${index}` + const uploadId = agentSessionAttachmentUploadIdOfPath(block.path) + return uploadId && expiredUploadIds?.has(uploadId) + ? [unavailableNativeChatComposerDraftImage({ id, path: block.path })] + : [{ id, path: block.path, ...(connectionId ? { connectionId } : {}) }] }) // Atomically append text/images; stable image ids make repeated hand-back idempotent despite later typing. return appendNativeChatDraftCache( @@ -54,12 +60,14 @@ export function handBackStructuredAgentSessionMessage( sessionId: string, clientMessageId: string, body: AgentJournalMessageItem, - imageConnectionIds?: readonly (string | null)[] + imageConnectionIds?: readonly (string | null)[], + expiredUploadIds?: ReadonlySet ): boolean { return returnMessageToComposer( structuredAgentSessionDraftScopeKey(sessionId), `returned-${clientMessageId}`, body.blocks, - imageConnectionIds + imageConnectionIds, + expiredUploadIds ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts index f371ca60f05..7cd0830168e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts @@ -362,13 +362,17 @@ describe('structured agent session message sender', () => { // Expired paired-server attachments require removal/reattachment, so return the whole message even without a mounted view. it('gives back a message refused for an expired attachment, with its file and why', async () => { - const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/shot.png' + const uploadId = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' + const STORED = `/srv/agent-session-attachments/${uploadId}/shot.png` mocks.call.mockResolvedValue({ ok: false, refusal: { code: 'agent_session_operation_invalid', message: 'not stored', - details: { reason: 'attachmentExpired' } + details: { + reason: 'attachmentExpired', + expiredAttachments: [{ uploadId, name: 'shot.png' }] + } } }) const a = send('look at this', { attachments: [{ path: STORED, previewUri: STORED }] }) @@ -384,10 +388,11 @@ describe('structured agent session message sender', () => { { type: 'image-ref', path: STORED } ] }, - undefined + undefined, + new Set([uploadId]) ) expect(getStructuredAgentSessionSendNotice(SESSION)).toBe( - 'This attachment expired. Your message was not sent. Remove it and attach it again.' + 'shot.png expired. Your message was not sent. Remove it and attach it again.' ) expect(sendCalls()).toBe(1) }) @@ -498,7 +503,8 @@ describe('structured agent session message sender', () => { SESSION, a.clientMessageId, expect.objectContaining({ blocks: expect.any(Array) }), - ['ssh-1'] + ['ssh-1'], + undefined ) expect(JSON.stringify(mocks.call.mock.calls[0][2])).not.toContain('ssh-1') }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index fd3ecf60e25..7761a7658a7 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -36,7 +36,7 @@ import { beginSend, finish, handBack, - refusedSendParts, + handBackRefused, runtimes, settleRecorded, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS, @@ -75,7 +75,7 @@ async function attempt(entry: StructuredAgentSessionPendingSend): Promise } else if (outcome.evidence.kind === 'recorded') { settleRecorded(current, outcome.submission, 'reply', outcome.cursor) } else if (outcome.evidence.kind === 'not-recorded') { - handBack(current, refusedSendParts(outcome.evidence.failure)) + handBackRefused(current, outcome.evidence.failure) } else { // An uncertain request may have landed; return its text with an unconfirmed notice and never resend automatically. handBack(current, agentSessionUnconfirmedSendParts(outcome.thrownRefusal)) @@ -190,14 +190,14 @@ export function reserveStructuredAgentSessionSend( } if (result.kind === 'recorded') { settleRecorded(pending, result.submission, 'reply', result.cursor) + } else if (result.kind === 'refused' && result.failure) { + handBackRefused(pending, result.failure) } else { handBack( pending, result.kind === 'unconfirmed' ? agentSessionUnconfirmedSendParts(undefined) - : result.failure - ? refusedSendParts(result.failure) - : agentSessionWriteNotDoneParts('composer-send') + : agentSessionWriteNotDoneParts('composer-send') ) } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts index c2c28f27a49..8cb65314cb5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -49,13 +49,17 @@ export function finish( updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) } -function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { +function returnToComposer( + entry: StructuredAgentSessionPendingSend, + expiredUploadIds?: ReadonlySet +): boolean { try { handBackStructuredAgentSessionMessage( entry.sessionId, entry.clientMessageId, entry.body, - entry.imageConnectionIds + entry.imageConnectionIds, + expiredUploadIds ) return true } catch (error) { @@ -66,13 +70,17 @@ function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { export function handBack( entry: StructuredAgentSessionPendingSend, - notice: readonly AgentSessionWriteNoticePart[] | null + notice: readonly AgentSessionWriteNoticePart[] | null, + expiredUploadIds?: ReadonlySet ): void { try { if (entry.callerKeepsText) { return } - const parts: readonly AgentSessionWriteNoticePart[] | null = returnToComposer(entry) + const parts: readonly AgentSessionWriteNoticePart[] | null = returnToComposer( + entry, + expiredUploadIds + ) ? notice : [...(notice ?? []), 'messageNotSaved'] if (parts) { @@ -108,7 +116,7 @@ export function settleRecorded( } /** Unconfirmed rewind blocks sends until the host checks it again; use the rewind-specific refusal words. */ -export function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessionWriteNoticePart[] { +function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessionWriteNoticePart[] { return failure.kind === 'refused' && failure.details?.reason === 'rewindUnconfirmed' ? [ { text: nativeChatRewindReasonCopy('outcome-unknown') }, @@ -117,6 +125,22 @@ export function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessio : agentSessionWriteNoticeParts(failure, 'composer-send') } +/** Return a refused message, marking any image the host said expired. */ +export function handBackRefused( + entry: StructuredAgentSessionPendingSend, + failure: AgentSessionWriteFailure +): void { + const expired = + failure.kind === 'refused' && failure.code === 'agent_session_operation_invalid' + ? failure.details?.expiredAttachments + : undefined + handBack( + entry, + refusedSendParts(failure), + expired && new Set(expired.map(({ uploadId }) => uploadId)) + ) +} + function onDeadline(sessionId: string, clientMessageId: string): void { const entry = findStructuredAgentSessionPendingSend(sessionId, clientMessageId) if (!entry || entry.phase === 'recorded') { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 5443103376a..52ce1939d26 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1538,6 +1538,7 @@ "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", "attachmentInvalid": "An attachment on this message can't be sent to the agent.", + "attachmentsExpired": "{{names}} expired.", "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.", "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.", "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.", @@ -1858,6 +1859,7 @@ "queueTooLarge": "Too much text is waiting in the queue.", "quitTerminalAgent": "Quit that agent to continue the chat here.", "reattachFile": "Remove it and attach it again.", + "reattachFiles": "Remove them and attach them again.", "recordFailed": "Orca couldn't save this to the chat's history.", "recordUnreadable": "Orca couldn't read this chat's saved state.", "mailOwnerUnconfirmed": "Orca couldn't confirm this message is still meant for this chat.", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 7f333876331..c71585780e5 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -19125,6 +19125,7 @@ "attachmentNotAFile": "Ein Bild in dieser Nachricht ist keine Datei, daher wurde die Nachricht nicht gesendet.", "attachmentNoSource": "Für ein Bild in dieser Nachricht fehlt die zu sendende Datei, daher wurde die Nachricht nicht gesendet.", "attachmentInvalid": "Ein Anhang dieser Nachricht kann nicht an den Agenten gesendet werden.", + "attachmentsExpired": "Abgelaufen: {{names}}.", "attachmentUnreadable": "Ein Anhang dieser Nachricht konnte nicht gelesen werden, daher wurde die Nachricht nicht gesendet.", "emptyMessage": "Diese Nachricht ist leer und wurde daher nicht gesendet.", "commandUnavailable": "Dieser Befehl ist in Chats mit {{agent}} nicht verfügbar und wurde daher nicht gesendet.", @@ -19213,6 +19214,7 @@ "recordFailed": "Orca konnte dies nicht im Chatverlauf speichern.", "attachmentExpired": "Dieser Anhang ist abgelaufen.", "reattachFile": "Den Anhang entfernen und erneut anhängen.", + "reattachFiles": "Die Anhänge entfernen und erneut anhängen.", "conversationCleared": "Diese Unterhaltung wurde geleert.", "openCurrentConversation": "Zum Fortfahren die aktuelle Unterhaltung öffnen.", "clearUnfinished": "Der letzte /clear-Befehl wurde nicht abgeschlossen.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index aab9d841c41..90492147091 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19424,6 +19424,7 @@ "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.", "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.", "attachmentInvalid": "An attachment on this message can't be sent to the agent.", + "attachmentsExpired": "{{names}} expired.", "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.", "emptyMessage": "This message is empty, so it was not sent.", "commandUnavailable": "This command isn't available in {{agent}} chats, so it was not sent.", @@ -19513,6 +19514,7 @@ "recordFailed": "Orca couldn't save this to the chat's history.", "attachmentExpired": "This attachment expired.", "reattachFile": "Remove it and attach it again.", + "reattachFiles": "Remove them and attach them again.", "conversationCleared": "This conversation has been cleared.", "openCurrentConversation": "Open the current conversation to continue.", "clearUnfinished": "The last /clear didn't finish.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 80a92ab4e48..fb12db872d3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18906,6 +18906,7 @@ "attachmentNotAFile": "Una imagen de este mensaje no es un archivo, así que el mensaje no se envió.", "attachmentNoSource": "Una imagen de este mensaje no tiene ningún archivo que enviar, así que el mensaje no se envió.", "attachmentInvalid": "Un archivo adjunto de este mensaje no se puede enviar al agente.", + "attachmentsExpired": "Caducado: {{names}}.", "attachmentUnreadable": "No se pudo leer un archivo adjunto de este mensaje, así que el mensaje no se envió.", "emptyMessage": "Este mensaje está vacío, así que no se envió.", "commandUnavailable": "Este comando no está disponible en los chats de {{agent}}, así que no se envió.", @@ -18993,6 +18994,7 @@ "recordFailed": "Orca no pudo guardar esto en el historial del chat.", "attachmentExpired": "Este archivo adjunto caducó.", "reattachFile": "Quítelo y vuelva a adjuntarlo.", + "reattachFiles": "Quítelos y vuelva a adjuntarlos.", "conversationCleared": "Esta conversación se borró.", "openCurrentConversation": "Abra la conversación actual para continuar.", "clearUnfinished": "El último /clear no terminó.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0ea5c49eef6..eaa87783c54 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19126,6 +19126,7 @@ "attachmentNotAFile": "Une image de ce message n'est pas un fichier, le message n'a donc pas été envoyé.", "attachmentNoSource": "Une image de ce message n'a aucun fichier à envoyer, le message n'a donc pas été envoyé.", "attachmentInvalid": "Une pièce jointe de ce message ne peut pas être envoyée à l'agent.", + "attachmentsExpired": "Expiré : {{names}}.", "attachmentUnreadable": "Une pièce jointe de ce message n'a pas pu être lue, le message n'a donc pas été envoyé.", "emptyMessage": "Ce message est vide, il n'a donc pas été envoyé.", "commandUnavailable": "Cette commande n'est pas disponible dans les chats {{agent}}, elle n'a donc pas été envoyée.", @@ -19213,6 +19214,7 @@ "recordFailed": "Orca n'a pas pu enregistrer ceci dans l'historique du chat.", "attachmentExpired": "Cette pièce jointe a expiré.", "reattachFile": "Retirez-la et joignez-la à nouveau.", + "reattachFiles": "Retirez-les et joignez-les à nouveau.", "conversationCleared": "Cette conversation a été effacée.", "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", "clearUnfinished": "Le dernier /clear ne s'est pas terminé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e535e3e0f56..5bbca4f686e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18463,6 +18463,7 @@ "attachmentNotAFile": "このメッセージの画像がファイルではないため、メッセージは送信されませんでした。", "attachmentNoSource": "このメッセージの画像に送信するファイルがないため、メッセージは送信されませんでした。", "attachmentInvalid": "このメッセージの添付ファイルは Agent に送信できません。", + "attachmentsExpired": "{{names}} は期限切れです。", "attachmentUnreadable": "このメッセージの添付ファイルを読み取れなかったため、メッセージは送信されませんでした。", "emptyMessage": "このメッセージは空のため、送信されませんでした。", "commandUnavailable": "このコマンドは {{agent}} のチャットでは使用できないため、送信されませんでした。", @@ -18550,6 +18551,7 @@ "recordFailed": "Orca はこれをチャットの履歴に保存できませんでした。", "attachmentExpired": "この添付ファイルは期限切れです。", "reattachFile": "削除してから、もう一度添付してください。", + "reattachFiles": "削除してから、もう一度添付してください。", "conversationCleared": "この会話はクリアされました。", "openCurrentConversation": "続けるには、現在の会話を開いてください。", "clearUnfinished": "前回の /clear は完了しませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8dbb96b37fd..0c7028975d9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18463,6 +18463,7 @@ "attachmentNotAFile": "이 메시지의 이미지가 파일이 아니어서 메시지가 전송되지 않았습니다.", "attachmentNoSource": "이 메시지의 이미지에 보낼 파일이 없어 메시지가 전송되지 않았습니다.", "attachmentInvalid": "이 메시지의 첨부 파일은 에이전트에 보낼 수 없습니다.", + "attachmentsExpired": "{{names}}이(가) 만료되었습니다.", "attachmentUnreadable": "이 메시지의 첨부 파일을 읽을 수 없어 메시지가 전송되지 않았습니다.", "emptyMessage": "이 메시지가 비어 있어 전송되지 않았습니다.", "commandUnavailable": "이 명령은 {{agent}} 채팅에서 사용할 수 없어 전송되지 않았습니다.", @@ -18550,6 +18551,7 @@ "recordFailed": "Orca가 이 내용을 채팅 기록에 저장하지 못했습니다.", "attachmentExpired": "이 첨부 파일이 만료되었습니다.", "reattachFile": "제거한 후 다시 첨부하세요.", + "reattachFiles": "제거한 후 다시 첨부하세요.", "conversationCleared": "이 대화는 지워졌습니다.", "openCurrentConversation": "계속하려면 현재 대화를 여세요.", "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 4beec256739..1c5e00ee88c 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -20514,6 +20514,7 @@ "attachmentNotAFile": "Uma imagem nesta mensagem não é um arquivo, por isso a mensagem não foi enviada.", "attachmentNoSource": "Uma imagem nesta mensagem não tem um arquivo para enviar, por isso a mensagem não foi enviada.", "attachmentInvalid": "Um anexo nesta mensagem não pode ser enviado ao agente.", + "attachmentsExpired": "Expirado: {{names}}.", "attachmentUnreadable": "Não foi possível ler um anexo nesta mensagem, por isso a mensagem não foi enviada.", "emptyMessage": "Esta mensagem está vazia, por isso não foi enviada.", "commandUnavailable": "Este comando não está disponível em chats de {{agent}}, por isso não foi enviado.", @@ -20605,6 +20606,7 @@ "recordFailed": "O Orca não conseguiu salvar isto no histórico do chat.", "attachmentExpired": "Este anexo expirou.", "reattachFile": "Remova-o e anexe-o novamente.", + "reattachFiles": "Remova-os e anexe-os novamente.", "conversationCleared": "Esta conversa foi limpa.", "openCurrentConversation": "Abra a conversa atual para continuar.", "clearUnfinished": "O último /clear não terminou.", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 98b49802a2e..45406e0e40c 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -19699,6 +19699,7 @@ "attachmentNotAFile": "Одно из изображений в сообщении не является файлом, поэтому сообщение не отправлено.", "attachmentNoSource": "Для одного из изображений нет исходного файла, поэтому сообщение не отправлено.", "attachmentInvalid": "Вложение в этом сообщении нельзя отправить агенту.", + "attachmentsExpired": "Срок хранения истёк: {{names}}.", "attachmentUnreadable": "Не удалось прочитать вложение, поэтому сообщение не отправлено.", "emptyMessage": "Сообщение пустое, поэтому оно не отправлено.", "commandUnavailable": "Эта команда недоступна в чатах {{agent}}, поэтому она не отправлена.", @@ -19788,6 +19789,7 @@ "recordFailed": "Не удалось сохранить это в истории чата.", "attachmentExpired": "Срок хранения вложения истёк.", "reattachFile": "Удалите его и прикрепите снова.", + "reattachFiles": "Удалите их и прикрепите снова.", "conversationCleared": "Этот разговор очищен.", "openCurrentConversation": "Откройте текущий разговор, чтобы продолжить.", "clearUnfinished": "Последняя команда /clear не завершилась.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 6e0d5527bf0..0910d4bd506 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -19111,6 +19111,7 @@ "attachmentNotAFile": "Bu mesajdaki bir görsel dosya olmadığı için mesaj gönderilmedi.", "attachmentNoSource": "Bu mesajdaki bir görselin gönderilecek dosyası bulunmadığı için mesaj gönderilmedi.", "attachmentInvalid": "Bu mesajdaki bir ek ajana gönderilemiyor.", + "attachmentsExpired": "Süresi doldu: {{names}}.", "attachmentUnreadable": "Bu mesajdaki bir ek okunamadığı için mesaj gönderilmedi.", "emptyMessage": "Bu mesaj boş olduğu için gönderilmedi.", "commandUnavailable": "Bu komut {{agent}} sohbetlerinde kullanılamadığı için gönderilmedi.", @@ -19200,6 +19201,7 @@ "recordFailed": "Orca bunu sohbet geçmişine kaydedemedi.", "attachmentExpired": "Bu ekin süresi doldu.", "reattachFile": "Kaldırıp yeniden ekleyin.", + "reattachFiles": "Kaldırıp yeniden ekleyin.", "conversationCleared": "Bu konuşma temizlendi.", "openCurrentConversation": "Devam etmek için mevcut konuşmayı açın.", "clearUnfinished": "Son /clear işlemi tamamlanmadı.", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 0d8e3c7c0fe..cbf5d1c568c 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -18867,6 +18867,7 @@ "attachmentNotAFile": "此訊息中的一張圖片不是檔案,因此訊息未傳送。", "attachmentNoSource": "此訊息中的一張圖片沒有可傳送的檔案,因此訊息未傳送。", "attachmentInvalid": "此訊息中的附件無法傳送給代理程式。", + "attachmentsExpired": "{{names}} 已過期。", "attachmentUnreadable": "無法讀取此訊息中的附件,因此訊息未傳送。", "emptyMessage": "此訊息為空,因此未傳送。", "commandUnavailable": "此命令無法在 {{agent}} 聊天中使用,因此未傳送。", @@ -18956,6 +18957,7 @@ "recordFailed": "Orca 無法將此內容儲存到聊天歷史記錄中。", "attachmentExpired": "此附件已過期。", "reattachFile": "請將其移除後重新附加。", + "reattachFiles": "請將它們移除後重新附加。", "conversationCleared": "此對話已被清除。", "openCurrentConversation": "請開啟目前對話以繼續。", "clearUnfinished": "上一次 /clear 未完成。", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c17acbcc933..cca64beb6ac 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18429,6 +18429,7 @@ "attachmentNotAFile": "此消息中的一张图片不是文件,因此消息未发送。", "attachmentNoSource": "此消息中的一张图片没有可发送的文件,因此消息未发送。", "attachmentInvalid": "此消息中的附件无法发送给智能体。", + "attachmentsExpired": "{{names}} 已过期。", "attachmentUnreadable": "无法读取此消息中的附件,因此消息未发送。", "emptyMessage": "此消息为空,因此未发送。", "commandUnavailable": "此命令在 {{agent}} 聊天中不可用,因此未发送。", @@ -18517,6 +18518,7 @@ "recordFailed": "Orca 无法将此内容保存到聊天历史记录中。", "attachmentExpired": "此附件已过期。", "reattachFile": "请将其移除后重新附加。", + "reattachFiles": "请将它们移除后重新附加。", "conversationCleared": "此对话已被清除。", "openCurrentConversation": "请打开当前对话以继续。", "clearUnfinished": "上一次 /clear 未完成。", diff --git a/src/shared/agent-session/agent-session-attachments.ts b/src/shared/agent-session/agent-session-attachments.ts index 2d53879856b..a507745cec2 100644 --- a/src/shared/agent-session/agent-session-attachments.ts +++ b/src/shared/agent-session/agent-session-attachments.ts @@ -50,6 +50,13 @@ export function isAgentSessionAttachmentStorePath(filePath: string): boolean { return filePath.split(/[\\/]/).includes(AGENT_SESSION_ATTACHMENTS_DIR_NAME) } +/** The upload a stored path belongs to, read from the segment after the store's directory. */ +export function agentSessionAttachmentUploadIdOfPath(filePath: string): string | undefined { + const segments = filePath.split(/[\\/]/) + const store = segments.lastIndexOf(AGENT_SESSION_ATTACHMENTS_DIR_NAME) + return store === -1 ? undefined : segments[store + 1]?.toLowerCase() +} + // Windows refuses these as a file's name whatever its extension, and drops trailing dots and spaces. const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i diff --git a/src/shared/agent-session/failures/agent-session-attachment-failure-words.ts b/src/shared/agent-session/failures/agent-session-attachment-failure-words.ts index db9eca45d01..31bd5b3ee7c 100644 --- a/src/shared/agent-session/failures/agent-session-attachment-failure-words.ts +++ b/src/shared/agent-session/failures/agent-session-attachment-failure-words.ts @@ -2,7 +2,10 @@ import type { AgentSessionAttachmentProblem, AgentSessionAttachmentProblemReason } from './agent-session-failure' -import type { AgentSessionFailureSay } from './agent-session-failure-copy' +import { + sayAgentSessionFailureEnglish, + type AgentSessionFailureSay +} from './agent-session-failure-copy' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' const BYTES_PER_MB = 1024 * 1024 @@ -41,6 +44,19 @@ const ATTACHMENT_SENTENCES = { ) => string > +/** A long list stops short: every one of them has to go, and the next refusal names the rest. */ +const EXPIRED_NAMES_SHOWN = 3 + +export function expiredAttachmentsSentence( + names: readonly string[], + say: AgentSessionFailureSay = sayAgentSessionFailureEnglish +): string { + const shown = names.slice(0, EXPIRED_NAMES_SHOWN).join(', ') + return say('attachmentsExpired', { + names: names.length > EXPIRED_NAMES_SHOWN ? `${shown}…` : shown + }) +} + export function attachmentProblemSentence( say: AgentSessionFailureSay, context: AgentSessionFailureWordsContext, diff --git a/src/shared/agent-session/failures/agent-session-expired-attachments.ts b/src/shared/agent-session/failures/agent-session-expired-attachments.ts new file mode 100644 index 00000000000..cda2a00f58e --- /dev/null +++ b/src/shared/agent-session/failures/agent-session-expired-attachments.ts @@ -0,0 +1,51 @@ +// The uploads a refused message referenced that the host no longer stores. + +import { + isNativeChatPastedImagePath, + isNativeChatPastedTextPath +} from '../../native-chat/native-chat-image-label' + +/** Bounds the refusal, whose details a receipt or ledger row may keep; removing these and sending again names any others. */ +export const AGENT_SESSION_EXPIRED_ATTACHMENTS_MAX = 32 + +export type AgentSessionExpiredAttachment = { + uploadId: string + /** The stored file name its chip is labelled with, or the id when the message gave no name. */ + name: string +} + +function isExpiredAttachment(value: unknown): value is AgentSessionExpiredAttachment { + return ( + typeof value === 'object' && + value !== null && + 'uploadId' in value && + typeof value.uploadId === 'string' && + value.uploadId.length > 0 && + 'name' in value && + typeof value.name === 'string' && + value.name.length > 0 + ) +} + +export function readAgentSessionExpiredAttachments( + value: unknown +): AgentSessionExpiredAttachment[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + const kept = value + .filter(isExpiredAttachment) + .slice(0, AGENT_SESSION_EXPIRED_ATTACHMENTS_MAX) + .map(({ uploadId, name }) => ({ uploadId, name })) + return kept.length > 0 ? kept : undefined +} + +/** Names a person would recognize: a paste's generated name means nothing to them, so only its marked chip says which. */ +export function agentSessionExpiredAttachmentNames( + expired: readonly AgentSessionExpiredAttachment[] +): string[] { + const recognizable = expired + .map(({ name }) => name) + .filter((name) => !isNativeChatPastedImagePath(name) && !isNativeChatPastedTextPath(name)) + return [...new Set(recognizable)] +} diff --git a/src/shared/agent-session/failures/agent-session-failure-copy.ts b/src/shared/agent-session/failures/agent-session-failure-copy.ts index 35d52ed8449..c1bdf7d37b7 100644 --- a/src/shared/agent-session/failures/agent-session-failure-copy.ts +++ b/src/shared/agent-session/failures/agent-session-failure-copy.ts @@ -120,6 +120,7 @@ export const AGENT_SESSION_FAILURE_COPY = { attachmentNotAFile: "An image on this message isn't a file, so the message was not sent.", attachmentNoSource: 'An image on this message has no file to send, so the message was not sent.', attachmentInvalid: "An attachment on this message can't be sent to the agent.", + attachmentsExpired: '{{names}} expired.', attachmentUnreadable: "An attachment on this message couldn't be read, so the message was not sent.", emptyMessage: 'This message is empty, so it was not sent.', @@ -199,6 +200,8 @@ export type AgentSessionFailureCopyValues = { /** A provider protocol method that got no answer, e.g. `thread/start`. */ method?: string seconds?: string + /** Attachment file names, already listed for the sentence. */ + names?: string } export type AgentSessionFailureSay = ( diff --git a/src/shared/agent-session/failures/agent-session-legacy-refusal-fields.ts b/src/shared/agent-session/failures/agent-session-legacy-refusal-fields.ts new file mode 100644 index 00000000000..856537109d4 --- /dev/null +++ b/src/shared/agent-session/failures/agent-session-legacy-refusal-fields.ts @@ -0,0 +1,86 @@ +// The facts released clients read as loose top-level refusal fields, and how they are read back. + +import { isAgentJournalResolution } from '../journal/agent-session-journal-schemas' +import type { AgentJournalResolution } from '../journal/agent-session-journal-types' +import { + AGENT_SESSION_REWIND_REASONS, + type AgentSessionRewindReason +} from '../lifecycle/agent-session-rewind' +import type { + AgentSessionOwnerVerdict, + AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +/** The loose top-level fields released clients read; the host writes them only from details. */ +export type AgentSessionLegacyRefusalFields = { + currentFence?: number + currentRevision?: number + resolution?: AgentJournalResolution + ownerVerdict?: AgentSessionOwnerVerdict + rewindReason?: AgentSessionRewindReason +} + +const FACT_KEYS_BY_CODE: Partial< + Record +> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_checkpoint_stale: ['currentFence'], + agent_session_item_revision_stale: ['currentRevision', 'resolution'], + agent_session_already_resolved: ['currentRevision', 'resolution'] +} + +const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] + +function readFact( + key: keyof AgentSessionLegacyRefusalFields, + value: unknown +): AgentSessionLegacyRefusalFields { + switch (key) { + case 'currentFence': + return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} + case 'currentRevision': + return Number.isSafeInteger(value) && typeof value === 'number' + ? { currentRevision: value } + : {} + case 'resolution': + return isAgentJournalResolution(value) ? { resolution: value } : {} + case 'ownerVerdict': { + const ownerVerdict = OWNER_VERDICTS.find((verdict) => verdict === value) + return ownerVerdict ? { ownerVerdict } : {} + } + case 'rewindReason': { + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) + return rewindReason ? { rewindReason } : {} + } + } +} + +/** The legacy facts listed for `code`, plus the verdict every code may carry. */ +export function readAgentSessionLegacyRefusalFacts( + code: AgentSessionWireRefusalCode, + value: Record +): AgentSessionLegacyRefusalFields { + const keys = [...(FACT_KEYS_BY_CODE[code] ?? []), 'ownerVerdict' as const] + return keys.reduce( + (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), + {} + ) +} + +/** The loose fields released clients read, taken from details; the one place they are written. */ +export function agentSessionLegacyRefusalFields( + details: AgentSessionLegacyRefusalFields | undefined +): AgentSessionLegacyRefusalFields { + if (!details) { + return {} + } + const { currentFence, currentRevision, resolution, ownerVerdict, rewindReason } = details + return { + ...(rewindReason !== undefined ? { rewindReason } : {}), + ...(currentFence !== undefined ? { currentFence } : {}), + ...(resolution !== undefined ? { resolution } : {}), + ...(currentRevision !== undefined ? { currentRevision } : {}), + ...(ownerVerdict !== undefined ? { ownerVerdict } : {}) + } +} diff --git a/src/shared/agent-session/failures/agent-session-refusal-details.ts b/src/shared/agent-session/failures/agent-session-refusal-details.ts index 02ffa377db3..cb775861ff2 100644 --- a/src/shared/agent-session/failures/agent-session-refusal-details.ts +++ b/src/shared/agent-session/failures/agent-session-refusal-details.ts @@ -14,11 +14,12 @@ import { readAgentSessionArgumentProblem, type AgentSessionArgumentProblem } from '../agent-session-argument-problem' -import { isAgentJournalResolution } from '../journal/agent-session-journal-schemas' +import type { AgentSessionRewindReason } from '../lifecycle/agent-session-rewind' +import { readAgentSessionLegacyRefusalFacts } from './agent-session-legacy-refusal-fields' import { - AGENT_SESSION_REWIND_REASONS, - type AgentSessionRewindReason -} from '../lifecycle/agent-session-rewind' + readAgentSessionExpiredAttachments, + type AgentSessionExpiredAttachment +} from './agent-session-expired-attachments' import type { TuiAgent } from '../../tui-agent' import { TUI_AGENT_DISPLAY_NAMES } from '../../tui-agent-display-names' import type { @@ -195,6 +196,8 @@ type AgentSessionRefusalFactsByCode = { agentInstallation?: AgentCliInstallationProblem /** Codex's binary problem as builds before `agentInstallation` send and read it. */ codexInstallation?: CodexInstallationProblem + /** On `attachmentExpired`: which uploads, so a client can name them and mark their chips. */ + expiredAttachments?: readonly AgentSessionExpiredAttachment[] } agent_session_operation_unknown: RewindFacts agent_session_checkpoint_stale: { @@ -236,55 +239,10 @@ export type AgentSessionRefusalDetails = /** Details under whichever code, for a store that keeps them beside a code it reads as a string. */ export type AgentSessionAnyRefusalDetails = AgentSessionRefusalDetails -/** The loose top-level fields released clients read; the host writes them only from details. */ -export type AgentSessionLegacyRefusalFields = { - currentFence?: number - currentRevision?: number - resolution?: AgentJournalResolution - ownerVerdict?: AgentSessionOwnerVerdict - rewindReason?: AgentSessionRewindReason -} - -const FACT_KEYS_BY_CODE: Partial< - Record -> = { - agent_session_operation_invalid: ['rewindReason'], - agent_session_operation_unknown: ['rewindReason'], - agent_session_checkpoint_stale: ['currentFence'], - agent_session_item_revision_stale: ['currentRevision', 'resolution'], - agent_session_already_resolved: ['currentRevision', 'resolution'] -} - -const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function readFact( - key: keyof AgentSessionLegacyRefusalFields, - value: unknown -): AgentSessionLegacyRefusalFields { - switch (key) { - case 'currentFence': - return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} - case 'currentRevision': - return Number.isSafeInteger(value) && typeof value === 'number' - ? { currentRevision: value } - : {} - case 'resolution': - return isAgentJournalResolution(value) ? { resolution: value } : {} - case 'ownerVerdict': { - const ownerVerdict = OWNER_VERDICTS.find((verdict) => verdict === value) - return ownerVerdict ? { ownerVerdict } : {} - } - case 'rewindReason': { - const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) - return rewindReason ? { rewindReason } : {} - } - } -} - /** An agent this build can name; a newer host's agent reads as none. */ function isNamedAgent(value: unknown): value is TuiAgent { return typeof value === 'string' && Object.hasOwn(TUI_AGENT_DISPLAY_NAMES, value) @@ -306,11 +264,7 @@ export function readAgentSessionRefusalDetails( - (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), - {} - ) + const facts = readAgentSessionLegacyRefusalFacts(code, value) const argumentProblem = code === 'agent_session_operation_invalid' ? readAgentSessionArgumentProblem(value.argumentProblem) @@ -323,6 +277,10 @@ export function readAgentSessionRefusalDetails 0 ? (read as AgentSessionRefusalDetails) : undefined } - -/** The loose fields released clients read, taken from details; the one place they are written. */ -export function agentSessionLegacyRefusalFields( - details: AgentSessionLegacyRefusalFields | undefined -): AgentSessionLegacyRefusalFields { - if (!details) { - return {} - } - const { currentFence, currentRevision, resolution, ownerVerdict, rewindReason } = details - return { - ...(rewindReason !== undefined ? { rewindReason } : {}), - ...(currentFence !== undefined ? { currentFence } : {}), - ...(resolution !== undefined ? { resolution } : {}), - ...(currentRevision !== undefined ? { currentRevision } : {}), - ...(ownerVerdict !== undefined ? { ownerVerdict } : {}) - } -} diff --git a/src/shared/agent-session/failures/agent-session-refusal-notice.test.ts b/src/shared/agent-session/failures/agent-session-refusal-notice.test.ts index 20b97b65d59..a558342826f 100644 --- a/src/shared/agent-session/failures/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session/failures/agent-session-refusal-notice.test.ts @@ -5,6 +5,7 @@ import { agentSessionWriteNoticeParts } from './agent-session-refusal-notice' import { agentSessionFailureSentence } from './agent-session-failure-words' +import { readAgentSessionRefusalDetails } from './agent-session-refusal-details' import { agentSessionRefusalFailure, parseAgentSessionWriteFailure @@ -101,3 +102,53 @@ describe('saved Arguments refusals', () => { expect(agentSessionRefusalCauseParts(refused)).toEqual([]) }) }) + +describe('an expired attachment refusal', () => { + const notice = (details: unknown): string => + agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts( + agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: readAgentSessionRefusalDetails('agent_session_operation_invalid', details) + }), + 'composer-send' + ) + ) + const expired = (...names: string[]) => ({ + reason: 'attachmentExpired', + expiredAttachments: names.map((name, index) => ({ uploadId: `upload-${index}`, name })) + }) + + it('names the file', () => { + expect(notice(expired('notes.pdf'))).toBe( + 'notes.pdf expired. Your message was not sent. Remove it and attach it again.' + ) + }) + + it('names several, then stops short of a long list', () => { + expect(notice(expired('a.pdf', 'b.pdf'))).toBe( + 'a.pdf, b.pdf expired. Your message was not sent. Remove them and attach them again.' + ) + expect(notice(expired('a.pdf', 'b.pdf', 'c.pdf', 'd.pdf'))).toBe( + 'a.pdf, b.pdf, c.pdf… expired. Your message was not sent. Remove them and attach them again.' + ) + }) + + it.each(['orca-paste-1-abc.png', 'orca-paste-1-abc.txt'])( + "keeps a paste's generated name out of the words: %s", + (name) => { + expect(notice(expired(name))).toBe( + 'This attachment expired. Your message was not sent. Remove it and attach it again.' + ) + } + ) + + it.each([ + ['an older host, which names none', { reason: 'attachmentExpired' }], + ['a list it cannot read', { reason: 'attachmentExpired', expiredAttachments: [{ name: 1 }] }] + ])('stays generic for %s', (_case, details) => { + expect(notice(details)).toBe( + 'This attachment expired. Your message was not sent. Remove it and attach it again.' + ) + }) +}) diff --git a/src/shared/agent-session/failures/agent-session-refusal-notice.ts b/src/shared/agent-session/failures/agent-session-refusal-notice.ts index 349afd86364..1289a5b338f 100644 --- a/src/shared/agent-session/failures/agent-session-refusal-notice.ts +++ b/src/shared/agent-session/failures/agent-session-refusal-notice.ts @@ -24,6 +24,8 @@ import { type AgentSessionRefusalHolder } from './agent-session-refusal-reason-words' import { TUI_AGENT_DISPLAY_NAMES } from '../../tui-agent-display-names' +import { expiredAttachmentsSentence } from './agent-session-attachment-failure-words' +import { agentSessionExpiredAttachmentNames } from './agent-session-expired-attachments' const NOT_DONE: Record = { 'read-history': 'notDoneReadHistory', @@ -115,6 +117,17 @@ function reasonParts( } ] } + if (failure.code === 'agent_session_operation_invalid' && failure.details?.expiredAttachments) { + const expired = failure.details.expiredAttachments + const names = agentSessionExpiredAttachmentNames(expired) + if (names.length > 0) { + return [ + { expiredAttachmentNames: names }, + NOT_DONE[write], + expired.length > 1 ? 'reattachFiles' : 'reattachFile' + ] + } + } const commandWhileWorking = failure.code === 'agent_session_operation_invalid' ? COMMAND_WHILE_WORKING[write]?.[failure.details?.reason ?? ''] @@ -265,7 +278,9 @@ export function agentSessionWriteNoticeEnglish( ? AGENT_SESSION_WRITE_NOTICE_COPY[part] : 'text' in part ? part.text - : agentSessionFailureSentence(part.failure, part.surface, part.context) + : 'expiredAttachmentNames' in part + ? expiredAttachmentsSentence(part.expiredAttachmentNames) + : agentSessionFailureSentence(part.failure, part.surface, part.context) ) .join(' ') } diff --git a/src/shared/agent-session/failures/agent-session-wire-refusals.ts b/src/shared/agent-session/failures/agent-session-wire-refusals.ts index f19a3de65fb..65b46a7ea35 100644 --- a/src/shared/agent-session/failures/agent-session-wire-refusals.ts +++ b/src/shared/agent-session/failures/agent-session-wire-refusals.ts @@ -3,9 +3,11 @@ import { agentSessionLegacyRefusalFields, + type AgentSessionLegacyRefusalFields +} from './agent-session-legacy-refusal-fields' +import { readAgentSessionRefusalDetails, type AgentSessionAnyRefusalDetails, - type AgentSessionLegacyRefusalFields, type AgentSessionRefusalDetails, type AgentSessionRefusalDetailsByCode, type AgentSessionRefusalFacts diff --git a/src/shared/agent-session/failures/agent-session-write-failure.ts b/src/shared/agent-session/failures/agent-session-write-failure.ts index 3d6cc037b29..dfd0c5c3dc0 100644 --- a/src/shared/agent-session/failures/agent-session-write-failure.ts +++ b/src/shared/agent-session/failures/agent-session-write-failure.ts @@ -10,6 +10,7 @@ import type { AgentSessionArgumentProblem } from '../agent-session-argument-prob import type { TuiAgent } from '../../tui-agent' import type { CodexInstallationProblem } from '../../codex-cli-installation' import type { AgentCliInstallationProblem } from '../../agent-cli-installation-problem' +import type { AgentSessionExpiredAttachment } from './agent-session-expired-attachments' import { isAgentSessionWireRefusalCode, readAgentSessionRefusalReference, @@ -82,6 +83,7 @@ type DurableRefusalFacts = { agent?: TuiAgent agentInstallation?: AgentCliInstallationProblem codexInstallation?: CodexInstallationProblem + expiredAttachments?: readonly AgentSessionExpiredAttachment[] } agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ @@ -95,7 +97,8 @@ const DURABLE_FACT_KEYS: Partial-.txt`). */ +export function isNativeChatPastedTextPath(path: string): boolean { + const base = path.split(/[\\/]/).findLast(Boolean) ?? path + return /^orca-paste-.+\.txt$/i.test(base) +} + /** What a chat image is called where it is not drawn: its alt text, else its file's name. */ export function nativeChatImageLabel( block: NativeChatImageRefBlock,