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
This commit is contained in:
Kelvin Amoaba
2026-11-03 02:10:33 +00:00
committed by GitHub
parent b4618d0f8e
commit 4e841b7558
56 changed files with 598 additions and 143 deletions
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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": "もう一度確認するには、チャットを開き直してください。",
+2
View File
@@ -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": "다시 확인하려면 채팅을 다시 여세요.",
+2
View File
@@ -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.",
+2
View File
@@ -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": "Откройте чат заново для повторной проверки.",
+2
View File
@@ -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.",
+2
View File
@@ -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": "請重新開啟聊天以再次檢查。",
+2
View File
@@ -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": "请重新打开聊天以再次检查。",
@@ -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',
@@ -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.')
})
})
@@ -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<AgentSessionWriteNoticeSentence, () => 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(' ')
}
@@ -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`,
@@ -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<string>()
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))
@@ -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<string> {
const uploadIds = new Set<string>()
platform: NodeJS.Platform
): Generator<AttachmentReference> {
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<string> {
const uploadIds = new Set<string>()
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<string, string>()
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 }))
}
@@ -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(),
@@ -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(
@@ -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
}
@@ -188,7 +188,7 @@ export async function maybeQueueStructuredAgentSessionSend(
)
} catch (error) {
if (isAgentSessionAttachmentExpiredError(error)) {
return agentSessionAttachmentExpiredRefusal()
return agentSessionAttachmentExpiredRefusal(error)
}
throw error
}
@@ -101,7 +101,7 @@ export async function performThreadGoalChange(
)
} catch (error) {
if (isAgentSessionAttachmentExpiredError(error)) {
return agentSessionAttachmentExpiredRefusal()
return agentSessionAttachmentExpiredRefusal(error)
}
throw error
}
@@ -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 (
@@ -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,
@@ -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<
@@ -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<AgentSessionWriteNoticeSentence, () => 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
)
)
)
}
@@ -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-<ts>-<uuid>.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)
@@ -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` }
])
})
@@ -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<string>
): 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<string>
): boolean {
return returnMessageToComposer(
structuredAgentSessionDraftScopeKey(sessionId),
`returned-${clientMessageId}`,
body.blocks,
imageConnectionIds
imageConnectionIds,
expiredUploadIds
)
}
@@ -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')
})
@@ -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<void>
} 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')
)
}
}
@@ -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<string>
): 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<string>
): 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') {
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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ó.",
+2
View File
@@ -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é.",
+2
View File
@@ -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 は完了しませんでした。",
+2
View File
@@ -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가 완료되지 않았습니다.",
+2
View File
@@ -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.",
+2
View File
@@ -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 не завершилась.",
+2
View File
@@ -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ı.",
@@ -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 未完成。",
+2
View File
@@ -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 未完成。",
@@ -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
@@ -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,
@@ -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)]
}
@@ -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 = (
@@ -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<AgentSessionWireRefusalCode, readonly (keyof AgentSessionLegacyRefusalFields)[]>
> = {
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<string, unknown>
): AgentSessionLegacyRefusalFields {
const keys = [...(FACT_KEYS_BY_CODE[code] ?? []), 'ownerVerdict' as const]
return keys.reduce<AgentSessionLegacyRefusalFields>(
(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 } : {})
}
}
@@ -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<C extends AgentSessionWireRefusalCode> =
/** Details under whichever code, for a store that keeps them beside a code it reads as a string. */
export type AgentSessionAnyRefusalDetails = AgentSessionRefusalDetails<AgentSessionWireRefusalCode>
/** 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<AgentSessionWireRefusalCode, readonly (keyof AgentSessionLegacyRefusalFields)[]>
> = {
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<string, unknown> {
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<C extends AgentSessionWireRefusal
if (!isRecord(value)) {
return undefined
}
const keys = [...(FACT_KEYS_BY_CODE[code] ?? []), 'ownerVerdict' as const]
const facts = keys.reduce<AgentSessionLegacyRefusalFields>(
(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<C extends AgentSessionWireRefusal
code === 'agent_session_operation_invalid'
? readCodexInstallationProblem(value.codexInstallation)
: undefined
const expiredAttachments =
code === 'agent_session_operation_invalid' && value.reason === 'attachmentExpired'
? readAgentSessionExpiredAttachments(value.expiredAttachments)
: undefined
const owner =
(code === 'agent_session_conflict' || code === 'agent_session_ownership_unknown') &&
value.reason === 'conversationHeldElsewhere' &&
@@ -349,25 +307,9 @@ export function readAgentSessionRefusalDetails<C extends AgentSessionWireRefusal
? { agent: value.agent }
: {}),
...(agentInstallation ? { agentInstallation } : {}),
...(codexInstallation ? { codexInstallation } : {})
...(codexInstallation ? { codexInstallation } : {}),
...(expiredAttachments ? { expiredAttachments } : {})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reason was checked against `code`'s list and only the facts `code` lists (plus the verdict every code may carry) were kept.
return Object.keys(read).length > 0 ? (read as AgentSessionRefusalDetails<C>) : 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 } : {})
}
}
@@ -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.'
)
})
})
@@ -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<AgentSessionWriteKind, AgentSessionWriteNoticeSentence> = {
'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(' ')
}
@@ -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
@@ -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<Record<AgentSessionWireRefusalCode, readonly st
'argumentProblem',
'agent',
'agentInstallation',
'codexInstallation'
'codexInstallation',
'expiredAttachments'
],
agent_session_operation_unknown: ['rewindReason'],
agent_session_ownership_unknown: ['ownerVerdict']
@@ -54,6 +54,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = {
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.",
@@ -110,6 +111,7 @@ export type AgentSessionWriteNoticeFailurePart = {
export type AgentSessionWriteNoticePart =
| AgentSessionWriteNoticeSentence
| { text: string }
| { expiredAttachmentNames: readonly string[] }
| AgentSessionWriteNoticeFailurePart
/** Causes that already say the history can't be read here, so no sentence after them says it
@@ -23,6 +23,12 @@ export function isNativeChatPastedImagePath(path: string): boolean {
return PASTED_IMAGE_NAME.test(base)
}
/** True for a long text paste Orca saved as a file (`orca-paste-<ts>-<uuid>.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,