feat(react-ui): show and edit the acts of a trigger

The trigger form has a Permissions section that lists the act.*
grants like the grants of an agent: a plain-words line on each,
sensitive ones marked, and a grant the chosen agent does not hold
cannot be ticked. It says in a callout when a run only reads and why:
no acts, the agent holds none of them, the proposal said read only,
or a read only connection made it. The details page shows the same,
the trigger list marks a read only trigger, and the proposal card has
a Permissions row, so the person sees the acts before pressing Create.

The editor always sends the acts it loaded, so saving a form never
drops them silently. New strings are in every locale.

Assisted-by: Claude:claude-sonnet-5-5 node playwright
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 20:53:02 +00:00
1 parent d8bd3879f2
commit 89e29e6008
28 files changed
+654 -39

No files matched your search

@@ -21,11 +21,11 @@ const HISTORY = [
]
// The server, in memory: the state of the proposal and what each call does.
async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails = false } = {}) {
async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails = false, extra = {} } = {}) {
const s = { state: 'pending', canUndo: true, blocked: '', calls: [] }
const view = () => ({
state: s.state,
trigger: trigger(s.state === 'accepted' ? { enabled: true, next_run: '2026-11-02T08:00:00Z' } : {}),
trigger: trigger({ ...extra, ...(s.state === 'accepted' ? { enabled: true, next_run: '2026-11-02T08:00:00Z' } : {}) }),
answered_at: s.state === 'pending' ? undefined : AT,
can_undo: s.state === 'accepted' && s.canUndo,
undo_blocked: s.blocked || undefined,
@@ -36,6 +36,10 @@ async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails
await page.route('**/api/chats/c1', route => route.fulfill({ json: detail }))
await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true } }))
await page.route('**/api/chats/agents*', route => route.fulfill({ json: { agents: [] } }))
await page.route('**/api/chats/agents/builtin-grants', route => route.fulfill({ json: { grants: [
{ name: 'act.files', title: 'Write files on its own', description: 'A run that nobody watches may write files inside the agent\'s folders.', sensitive: true },
{ name: 'act.spend', title: 'Use paid or external tools on its own', description: 'A run that nobody watches may use paid or external tools and the network.', sensitive: true },
] } }))
await page.route('**/api/chats/triggers/preview*', route => route.fulfill({ json: { times: ['2026-11-02T08:00:00Z'], summary: 'Every Monday at 09:00' } }))
await page.route(/\/api\/chats\/triggers\/proposals\/p1(\/[a-z]+)?$/, (route) => {
const req = route.request()
@@ -100,6 +104,25 @@ for (const [name, size] of [['desktop', { width: 1440, height: 900 }], ['phone',
await page.screenshot({ path: `test-results/proposal-${name}-pending.png` })
})
test('the card says a run only reads when the trigger holds no act, and why', async ({ page }) => {
await open(page, { extra: { acts: [], acts_held: [], read_only: true, read_only_why: 'no_acts' } })
const sum = card(page).getByTestId('trigger-acts-summary')
await expect(sum).toHaveAttribute('data-read-only', 'true')
await expect(sum).toContainText('Read only')
await expect(card(page).getByTestId('trigger-acts-why')).toContainText('only read and report')
})
test('the card lists the acts the trigger would run with, sensitive ones marked, before it is created', async ({ page }) => {
const s = await open(page, { extra: { acts: ['act.files', 'act.spend'], acts_held: ['act.files'], read_only: false } })
const sum = card(page).getByTestId('trigger-acts-summary')
await expect(sum.locator('[data-act="act.files"]')).toContainText('Write files on its own')
await expect(sum.locator('[data-act="act.files"]')).toContainText('needs care')
await expect(sum.locator('[data-act="act.spend"]')).toHaveCount(0)
await expect(card(page).getByTestId('trigger-acts-unused')).toContainText('Use paid or external tools on its own')
expect(s.calls).not.toContain('POST confirm')
await page.screenshot({ path: `test-results/proposal-${name}-acts.png` })
})
test('accepting leaves a receipt in place, undo takes it back, and the state survives a reload', async ({ page }) => {
const s = await open(page)
await card(page).getByRole('button', { name: 'Create trigger' }).click()
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "Berechtigungen"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "Berechtigungen"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "Darf {{count}} Art von Aktion nutzen",
"acts_other": "Darf {{count}} Arten von Aktionen nutzen"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_other": "Die Tokens eines Laufs sind nach dem ersten Lauf bekannt. Höchstens {{count}} Läufe pro Tag.",
"remoteNote": "{{model}} ist ein entfernter Dienst: Was ein Lauf liest, verlässt diesen Server.",
"stopWidget": "Pausiere ihn im Widget oder lösche ihn unter Trigger",
"stop": "Schalte ihn aus oder lösche ihn unter Trigger"
"stop": "Schalte ihn aus oder lösche ihn unter Trigger",
"actsLabel": "Berechtigungen"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "{{count}} Dateien löschen",
"cleared_one": "{{count}} Datei in den Papierkorb verschoben.",
"cleared_other": "{{count}} Dateien in den Papierkorb verschoben."
},
"triggerActs": {
"help": "Ein Lauf startet von selbst, also bestätigt niemand einen Schritt. Er kann lesen und berichten. Hake an, was er zusätzlich tun darf. Er bekommt nie mehr, als sein Agent hat.",
"noAgent": "Es ist kein Agent gewählt, daher begrenzt nur das Angehakte den Lauf.",
"readOnly": "Nur lesen",
"canAct": "Darf selbstständig handeln",
"canActText_one": "Ein Lauf darf {{count}} Art von Aktion ohne Rückfrage nutzen.",
"canActText_other": "Ein Lauf darf {{count}} Arten von Aktionen ohne Rückfrage nutzen.",
"sensitive": "mit Vorsicht",
"addonRow": "Ein Lauf darf die Aktionen des Add-ons {{id}} nutzen.",
"blocked": "Dein Agent hat dies nicht, daher lässt es sich nicht anhaken. Füge es zuerst dem Agenten hinzu.",
"unused": "Dein Agent hat dies nicht, daher nutzt ein Lauf es nicht.",
"unusedList": "Aufgeführt, aber nicht genutzt, weil der Agent es nicht hat: {{names}}.",
"writesNote": "Sein Plan nennt die Datei {{file}}, daher darf ein Lauf auch Dateien schreiben.",
"listCan_one": "Kann handeln ({{count}})",
"listCan_other": "Kann handeln ({{count}})",
"why": {
"no_acts": "Keine Berechtigung zum Handeln gesetzt, daher lesen und berichten seine Läufe nur.",
"agent": "Sein Agent hat die verlangten Berechtigungen nicht, daher lesen und berichten seine Läufe nur.",
"declared": "Er wurde so eingerichtet, dass er nur liest und berichtet. Nichts, was hier angehakt wird, ändert das.",
"binding": "Eine Verbindung im Nur-Lesen-Modus hat ihn erstellt. Nichts, was hier angehakt wird, ändert das."
}
}
}
@@ -419,7 +419,8 @@
"runMeta": "took {{time}}",
"agent": "Agent",
"cap": "Most runs a day",
"watches": "Watches the folder {{folder}}"
"watches": "Watches the folder {{folder}}",
"acts": "Permissions"
},
"edit": {
"titleNew": "New trigger",
@@ -444,7 +445,8 @@
"schedule": "When",
"media": "Media sources",
"webhooks": "Webhooks",
"limits": "Limits"
"limits": "Limits",
"acts": "Permissions"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -453,7 +455,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "May use {{count}} kind of action",
"acts_other": "May use {{count}} kinds of action"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -723,7 +723,8 @@
"costUnknown_other": "The tokens of a run are known after the first run. At most {{count}} runs a day.",
"remoteNote": "{{model}} is a remote service: what a run reads leaves this server.",
"stopWidget": "Pause it from the widget, or delete it in Triggers",
"stop": "Switch it off or delete it in Triggers"
"stop": "Switch it off or delete it in Triggers",
"actsLabel": "Permissions"
}
},
"subAgents": {
@@ -1052,5 +1053,27 @@
"clearYes_other": "Delete {{count}} files",
"cleared_one": "Moved {{count}} file to the Trash.",
"cleared_other": "Moved {{count}} files to the Trash."
},
"triggerActs": {
"help": "A run starts by itself, so nobody is there to approve a step. It can read and report. Tick what it may also do. It never gets more than its agent holds.",
"noAgent": "No agent is chosen, so only what you tick limits it.",
"readOnly": "Read only",
"canAct": "May act on its own",
"canActText_one": "A run may use {{count}} kind of action without asking.",
"canActText_other": "A run may use {{count}} kinds of action without asking.",
"sensitive": "needs care",
"addonRow": "A run may use the actions of the add-on {{id}}.",
"blocked": "Your agent does not hold this, so it cannot be ticked. Add it to the agent first.",
"unused": "Your agent does not hold this, so a run does not use it.",
"unusedList": "Listed, but not used because the agent does not hold it: {{names}}.",
"writesNote": "Its plan names the file {{file}}, so a run may also write files.",
"listCan_one": "Can act ({{count}})",
"listCan_other": "Can act ({{count}})",
"why": {
"no_acts": "No permission to act is set, so its runs only read and report.",
"agent": "Its agent does not hold the permissions it asks for, so its runs only read and report.",
"declared": "It was set up to read and report only. Nothing ticked here changes that.",
"binding": "A connection in read only mode made it. Nothing ticked here changes that."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "Permisos"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "Permisos"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "Puede usar {{count}} tipo de acción",
"acts_other": "Puede usar {{count}} tipos de acción"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_other": "Los tokens de una ejecución se conocen tras la primera. Como máximo {{count}} ejecuciones al día.",
"remoteNote": "{{model}} es un servicio remoto: lo que lee una ejecución sale de este servidor.",
"stopWidget": "Páusalo desde el widget o elimínalo en Disparadores",
"stop": "Apágalo o elimínalo en Disparadores"
"stop": "Apágalo o elimínalo en Disparadores",
"actsLabel": "Permisos"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "Eliminar {{count}} archivos",
"cleared_one": "{{count}} archivo movido a la Papelera.",
"cleared_other": "{{count}} archivos movidos a la Papelera."
},
"triggerActs": {
"help": "Una ejecución empieza sola, así que nadie aprueba un paso. Puede leer e informar. Marca lo que además puede hacer. Nunca recibe más de lo que tiene su agente.",
"noAgent": "No hay agente elegido, así que solo lo que marques la limita.",
"readOnly": "Solo lectura",
"canAct": "Puede actuar por sí misma",
"canActText_one": "Una ejecución puede usar {{count}} tipo de acción sin preguntar.",
"canActText_other": "Una ejecución puede usar {{count}} tipos de acción sin preguntar.",
"sensitive": "requiere cuidado",
"addonRow": "Una ejecución puede usar las acciones del complemento {{id}}.",
"blocked": "Tu agente no tiene esto, así que no se puede marcar. Añádelo antes al agente.",
"unused": "Tu agente no tiene esto, así que una ejecución no lo usa.",
"unusedList": "En la lista, pero sin uso porque el agente no lo tiene: {{names}}.",
"writesNote": "Su plan nombra el archivo {{file}}, así que una ejecución también puede escribir archivos.",
"listCan_one": "Puede actuar ({{count}})",
"listCan_other": "Puede actuar ({{count}})",
"why": {
"no_acts": "No tiene permiso para actuar, así que sus ejecuciones solo leen e informan.",
"agent": "Su agente no tiene los permisos que pide, así que sus ejecuciones solo leen e informan.",
"declared": "Se creó para leer e informar solamente. Nada de lo que marques aquí lo cambia.",
"binding": "La creó una conexión en modo de solo lectura. Nada de lo que marques aquí lo cambia."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "Permissions"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "Permissions"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "Peut utiliser {{count}} type d'action",
"acts_other": "Peut utiliser {{count}} types d'action"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_other": "Les jetons d’une exécution sont connus après la première. Au plus {{count}} exécutions par jour.",
"remoteNote": "{{model}} est un service distant : ce que lit une exécution quitte ce serveur.",
"stopWidget": "Mettez-le en pause depuis le widget, ou supprimez-le dans Déclencheurs",
"stop": "Désactivez-le ou supprimez-le dans Déclencheurs"
"stop": "Désactivez-le ou supprimez-le dans Déclencheurs",
"actsLabel": "Permissions"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "Supprimer {{count}} fichiers",
"cleared_one": "{{count}} fichier déplacé dans la corbeille.",
"cleared_other": "{{count}} fichiers déplacés dans la corbeille."
},
"triggerActs": {
"help": "Une exécution démarre seule, donc personne n'approuve une étape. Elle peut lire et rendre compte. Cochez ce qu'elle peut faire en plus. Elle n'obtient jamais plus que son agent.",
"noAgent": "Aucun agent n'est choisi, donc seul ce que vous cochez la limite.",
"readOnly": "Lecture seule",
"canAct": "Peut agir seule",
"canActText_one": "Une exécution peut utiliser {{count}} type d'action sans demander.",
"canActText_other": "Une exécution peut utiliser {{count}} types d'action sans demander.",
"sensitive": "à manier avec soin",
"addonRow": "Une exécution peut utiliser les actions du module {{id}}.",
"blocked": "Votre agent n'a pas cette permission, on ne peut donc pas la cocher. Ajoutez-la d'abord à l'agent.",
"unused": "Votre agent n'a pas cette permission, donc une exécution ne l'utilise pas.",
"unusedList": "Listée, mais inutilisée car l'agent ne l'a pas : {{names}}.",
"writesNote": "Son plan nomme le fichier {{file}}, donc une exécution peut aussi écrire des fichiers.",
"listCan_one": "Peut agir ({{count}})",
"listCan_other": "Peut agir ({{count}})",
"why": {
"no_acts": "Aucune permission d'agir n'est définie, donc ses exécutions lisent et rendent compte seulement.",
"agent": "Son agent n'a pas les permissions demandées, donc ses exécutions lisent et rendent compte seulement.",
"declared": "Il a été créé pour lire et rendre compte seulement. Rien de ce qui est coché ici ne change cela.",
"binding": "Une connexion en lecture seule l'a créé. Rien de ce qui est coché ici ne change cela."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "Izin"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "Izin"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "Boleh memakai {{count}} jenis tindakan",
"acts_other": "Boleh memakai {{count}} jenis tindakan"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_one": "Token per jalan diketahui setelah jalan pertama. Paling banyak {{count}} jalan per hari.",
"remoteNote": "{{model}} adalah layanan jarak jauh: apa yang dibaca sebuah jalan meninggalkan server ini.",
"stopWidget": "Jeda dari widget, atau hapus di Pemicu",
"stop": "Matikan atau hapus di Pemicu"
"stop": "Matikan atau hapus di Pemicu",
"actsLabel": "Izin"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "Hapus {{count}} berkas",
"cleared_one": "{{count}} berkas dipindahkan ke Tempat Sampah.",
"cleared_other": "{{count}} berkas dipindahkan ke Tempat Sampah."
},
"triggerActs": {
"help": "Satu jalan dimulai sendiri, jadi tidak ada yang menyetujui langkahnya. Ia bisa membaca dan melapor. Centang apa yang boleh dilakukannya juga. Ia tidak pernah mendapat lebih dari yang dimiliki agennya.",
"noAgent": "Belum ada agen yang dipilih, jadi hanya centangan Anda yang membatasinya.",
"readOnly": "Hanya baca",
"canAct": "Boleh bertindak sendiri",
"canActText_one": "Satu jalan boleh memakai {{count}} jenis tindakan tanpa bertanya.",
"canActText_other": "Satu jalan boleh memakai {{count}} jenis tindakan tanpa bertanya.",
"sensitive": "perlu hati-hati",
"addonRow": "Satu jalan boleh memakai tindakan add-on {{id}}.",
"blocked": "Agen Anda tidak memiliki izin ini, jadi tidak bisa dicentang. Tambahkan dulu ke agen.",
"unused": "Agen Anda tidak memiliki izin ini, jadi satu jalan tidak memakainya.",
"unusedList": "Terdaftar, tetapi tidak dipakai karena agen tidak memilikinya: {{names}}.",
"writesNote": "Rencananya menyebut berkas {{file}}, jadi satu jalan juga boleh menulis berkas.",
"listCan_one": "Bisa bertindak ({{count}})",
"listCan_other": "Bisa bertindak ({{count}})",
"why": {
"no_acts": "Tidak ada izin bertindak, jadi jalannya hanya membaca dan melapor.",
"agent": "Agennya tidak memiliki izin yang diminta, jadi jalannya hanya membaca dan melapor.",
"declared": "Ini dibuat untuk hanya membaca dan melapor. Centangan di sini tidak mengubahnya.",
"binding": "Dibuat oleh koneksi dalam mode hanya baca. Centangan di sini tidak mengubahnya."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "Permessi"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "Permessi"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "Può usare {{count}} tipo di azione",
"acts_other": "Può usare {{count}} tipi di azione"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_other": "I token di un'esecuzione si conoscono dopo la prima. Al massimo {{count}} esecuzioni al giorno.",
"remoteNote": "{{model}} è un servizio remoto: quello che un'esecuzione legge esce da questo server.",
"stopWidget": "Mettilo in pausa dal widget, o eliminalo in Trigger",
"stop": "Spegnilo o eliminalo in Trigger"
"stop": "Spegnilo o eliminalo in Trigger",
"actsLabel": "Permessi"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "Elimina {{count}} file",
"cleared_one": "{{count}} file spostato nel Cestino.",
"cleared_other": "{{count}} file spostati nel Cestino."
},
"triggerActs": {
"help": "Un'esecuzione parte da sola, quindi nessuno approva un passo. Può leggere e riferire. Spunta ciò che può fare in più. Non ottiene mai più di quanto ha il suo agente.",
"noAgent": "Nessun agente scelto, quindi la limita solo ciò che spunti.",
"readOnly": "Sola lettura",
"canAct": "Può agire da sola",
"canActText_one": "Un'esecuzione può usare {{count}} tipo di azione senza chiedere.",
"canActText_other": "Un'esecuzione può usare {{count}} tipi di azione senza chiedere.",
"sensitive": "da usare con cura",
"addonRow": "Un'esecuzione può usare le azioni del componente {{id}}.",
"blocked": "Il tuo agente non ha questo permesso, quindi non si può spuntare. Aggiungilo prima all'agente.",
"unused": "Il tuo agente non ha questo permesso, quindi un'esecuzione non lo usa.",
"unusedList": "In elenco, ma non usato perché l'agente non lo ha: {{names}}.",
"writesNote": "Il suo piano nomina il file {{file}}, quindi un'esecuzione può anche scrivere file.",
"listCan_one": "Può agire ({{count}})",
"listCan_other": "Può agire ({{count}})",
"why": {
"no_acts": "Nessun permesso di agire, quindi le sue esecuzioni leggono e riferiscono soltanto.",
"agent": "Il suo agente non ha i permessi richiesti, quindi le sue esecuzioni leggono e riferiscono soltanto.",
"declared": "È stato creato per leggere e riferire soltanto. Nulla di ciò che spunti qui lo cambia.",
"binding": "L'ha creato una connessione in sola lettura. Nulla di ciò che spunti qui lo cambia."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "권한"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "권한"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "{{count}}가지 종류의 동작 사용 가능",
"acts_other": "{{count}}가지 종류의 동작 사용 가능"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_one": "실행당 토큰은 첫 실행 후에 알 수 있습니다. 하루 최대 {{count}}회.",
"remoteNote": "{{model}}은(는) 원격 서비스입니다. 실행이 읽는 내용이 이 서버 밖으로 나갑니다.",
"stopWidget": "위젯에서 일시 중지하거나 트리거에서 삭제하세요",
"stop": "트리거에서 끄거나 삭제하세요"
"stop": "트리거에서 끄거나 삭제하세요",
"actsLabel": "권한"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "파일 {{count}}개 삭제",
"cleared_one": "파일 {{count}}개를 휴지통으로 옮겼습니다.",
"cleared_other": "파일 {{count}}개를 휴지통으로 옮겼습니다."
},
"triggerActs": {
"help": "실행은 스스로 시작되므로 단계를 승인해 줄 사람이 없습니다. 읽고 보고할 수 있습니다. 추가로 허용할 것을 선택하세요. 에이전트가 가진 것보다 많이 받지는 않습니다.",
"noAgent": "에이전트를 고르지 않았으므로 선택한 항목만 제한합니다.",
"readOnly": "읽기 전용",
"canAct": "스스로 동작 가능",
"canActText_one": "실행은 묻지 않고 {{count}}가지 종류의 동작을 쓸 수 있습니다.",
"canActText_other": "실행은 묻지 않고 {{count}}가지 종류의 동작을 쓸 수 있습니다.",
"sensitive": "주의 필요",
"addonRow": "실행은 애드온 {{id}}의 동작을 쓸 수 있습니다.",
"blocked": "에이전트에 이 권한이 없어 선택할 수 없습니다. 먼저 에이전트에 추가하세요.",
"unused": "에이전트에 이 권한이 없어 실행에서 쓰지 않습니다.",
"unusedList": "목록에 있지만 에이전트에 없어서 쓰지 않음: {{names}}.",
"writesNote": "계획에 파일 {{file}}이(가) 있어 실행이 파일도 쓸 수 있습니다.",
"listCan_one": "동작 가능 ({{count}})",
"listCan_other": "동작 가능 ({{count}})",
"why": {
"no_acts": "동작 권한이 없어 실행은 읽고 보고만 합니다.",
"agent": "에이전트에 요청한 권한이 없어 실행은 읽고 보고만 합니다.",
"declared": "읽고 보고만 하도록 만들어졌습니다. 여기서 선택해도 바뀌지 않습니다.",
"binding": "읽기 전용 모드의 연결이 만들었습니다. 여기서 선택해도 바뀌지 않습니다."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "Permissões"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "Permissões"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "Pode usar {{count}} tipo de ação",
"acts_other": "Pode usar {{count}} tipos de ação"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_other": "Os tokens de uma execução são conhecidos após a primeira. No máximo {{count}} execuções por dia.",
"remoteNote": "{{model}} é um serviço remoto: o que uma execução lê sai deste servidor.",
"stopWidget": "Pause pelo widget ou exclua em Gatilhos",
"stop": "Desligue ou exclua em Gatilhos"
"stop": "Desligue ou exclua em Gatilhos",
"actsLabel": "Permissões"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "Excluir {{count}} arquivos",
"cleared_one": "{{count}} arquivo movido para a Lixeira.",
"cleared_other": "{{count}} arquivos movidos para a Lixeira."
},
"triggerActs": {
"help": "Uma execução começa sozinha, então ninguém aprova um passo. Ela pode ler e relatar. Marque o que ela também pode fazer. Ela nunca recebe mais do que o seu agente tem.",
"noAgent": "Nenhum agente escolhido, então só o que você marcar a limita.",
"readOnly": "Somente leitura",
"canAct": "Pode agir sozinha",
"canActText_one": "Uma execução pode usar {{count}} tipo de ação sem perguntar.",
"canActText_other": "Uma execução pode usar {{count}} tipos de ação sem perguntar.",
"sensitive": "requer cuidado",
"addonRow": "Uma execução pode usar as ações do complemento {{id}}.",
"blocked": "Seu agente não tem esta permissão, então ela não pode ser marcada. Adicione-a antes ao agente.",
"unused": "Seu agente não tem esta permissão, então uma execução não a usa.",
"unusedList": "Listada, mas sem uso porque o agente não a tem: {{names}}.",
"writesNote": "O plano cita o arquivo {{file}}, então uma execução também pode gravar arquivos.",
"listCan_one": "Pode agir ({{count}})",
"listCan_other": "Pode agir ({{count}})",
"why": {
"no_acts": "Sem permissão para agir, então suas execuções só leem e relatam.",
"agent": "Seu agente não tem as permissões pedidas, então suas execuções só leem e relatam.",
"declared": "Foi criado para apenas ler e relatar. Nada que você marque aqui muda isso.",
"binding": "Foi criado por uma conexão em modo somente leitura. Nada que você marque aqui muda isso."
}
}
}
@@ -218,7 +218,8 @@
"watches": "Watches the folder {{folder}}",
"noRuns": "This trigger has not run yet.",
"runMeta": "took {{time}}",
"prompt": "What to do"
"prompt": "What to do",
"acts": "权限"
},
"edit": {
"titleNew": "New trigger",
@@ -234,7 +235,8 @@
"basics": "Basics",
"prompt": "What to do",
"schedule": "When",
"limits": "Limits"
"limits": "Limits",
"acts": "权限"
},
"summary": {
"basics": "{{name}}, {{model}}",
@@ -242,7 +244,9 @@
"promptEmpty": "Nothing to do yet",
"folderEmpty": "Needs a folder",
"cap": "Up to {{count}} runs a day",
"capDefault": "Default limit"
"capDefault": "Default limit",
"acts_one": "可使用 {{count}} 类操作",
"acts_other": "可使用 {{count}} 类操作"
},
"name": "Name",
"namePlaceholder": "daily-digest",
@@ -56,7 +56,8 @@
"costUnknown_one": "每次运行的 token 数在首次运行后可知。每天最多 {{count}} 次。",
"remoteNote": "{{model}} 是远程服务:运行读取的内容会离开此服务器。",
"stopWidget": "在小组件中暂停,或在触发器中删除",
"stop": "在触发器中关闭或删除"
"stop": "在触发器中关闭或删除",
"actsLabel": "权限"
}
},
"mcpPage": {
@@ -730,5 +731,27 @@
"clearYes_other": "删除 {{count}} 个文件",
"cleared_one": "已将 {{count}} 个文件移到回收站。",
"cleared_other": "已将 {{count}} 个文件移到回收站。"
},
"triggerActs": {
"help": "运行会自行开始,没有人批准每一步。它可以读取并汇报。勾选它还可以做什么。它得到的权限不会超过其代理。",
"noAgent": "未选择代理,因此只有你勾选的项目起限制作用。",
"readOnly": "只读",
"canAct": "可自行操作",
"canActText_one": "一次运行可在不询问的情况下使用 {{count}} 类操作。",
"canActText_other": "一次运行可在不询问的情况下使用 {{count}} 类操作。",
"sensitive": "需谨慎",
"addonRow": "一次运行可使用附加组件 {{id}} 的操作。",
"blocked": "你的代理没有此权限,因此无法勾选。请先将它添加到代理。",
"unused": "你的代理没有此权限,因此运行不会使用它。",
"unusedList": "已列出,但代理没有,所以不使用:{{names}}。",
"writesNote": "其计划指明了文件 {{file}},因此一次运行也可以写入文件。",
"listCan_one": "可操作({{count}})",
"listCan_other": "可操作({{count}})",
"why": {
"no_acts": "未设置操作权限,因此其运行只读取并汇报。",
"agent": "其代理没有它要求的权限,因此其运行只读取并汇报。",
"declared": "它被设置为只读取并汇报。在此勾选不会改变这一点。",
"binding": "它由只读模式的连接创建。在此勾选不会改变这一点。"
}
}
}
@@ -0,0 +1,114 @@
import { useTranslation } from 'react-i18next'
import { useBuiltinGrants } from '../hooks/useBuiltinGrants'
import { actNamed, actRows, actsAfter, actsStatus, actsStatusOf, actCatalog } from '../utils/triggerActs'
import { Callout } from './ui'
import './trigger-acts.css'
// What the runs of a trigger may do with nobody present. Three views of the
// same rules (utils/triggerActs.js):
// ActsEditor the checklist of the trigger form, drawn like the grants of an
// agent: a plain-words line on each, sensitive ones marked
// ActsSummary the read only view of a trigger the server returned (details
// page, proposal card)
// ReadOnlyTag a short mark for a list row
// A run that holds no act only reads and reports. The server enforces this at
// each tool call; the interface only shows it.
function Tag({ children }) {
return <span className="ta-tag">{children}</span>
}
/** One short reason why a run only reads, in plain words. */
function ReadOnlyWhy({ why, t }) {
return <>{t(`triggerActs.why.${why || 'no_acts'}`)}</>
}
/**
* The editable list. value is the acts of the form, agentGrants the grants of
* the chosen agent (null: no agent chosen), writes the file the plan names,
* lock why the trigger is read only whatever is ticked.
*/
// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react
export function ActsEditor({ value, onChange, agentGrants, writes = '', lock = '' }) {
const { t } = useTranslation('chats')
const catalog = actCatalog(useBuiltinGrants())
const rows = actRows(catalog, value, agentGrants)
const status = actsStatus({ acts: value, agentGrants, writes, lock })
return (
<div className="ta" data-testid="trigger-acts">
<p className="dk-hint">{t('triggerActs.help')}</p>
{agentGrants == null && <p className="dk-hint">{t('triggerActs.noAgent')}</p>}
<Callout tone={status.readOnly ? 'info' : 'warn'} testId="trigger-acts-status" title={status.readOnly ? t('triggerActs.readOnly') : t('triggerActs.canAct')}>
{status.readOnly
? <ReadOnlyWhy why={status.why} t={t} />
: t('triggerActs.canActText', { count: status.held.length })}
</Callout>
{writes && !lock && <p className="dk-hint" data-testid="trigger-acts-writes">{t('triggerActs.writesNote', { file: writes })}</p>}
<div className="ag-grants" data-testid="trigger-acts-list">
{rows.map((g) => (
<label key={g.name} className="ag-grants__row" data-testid="trigger-act" data-act={g.name} data-blocked={g.blocked || undefined}>
<input
type="checkbox"
checked={g.on}
disabled={!!lock || (g.blocked && !g.on)}
onChange={(e) => onChange(actsAfter(value, g.name, e.target.checked))}
/>
<span className="ag-grants__text">
<span className="ag-grants__title">
{g.title}
{g.sensitive && <Tag>{t('triggerActs.sensitive')}</Tag>}
</span>
<span className="ag-note">{g.extra ? t('triggerActs.addonRow', { id: g.name.split(':')[1] }) : g.description}</span>
{g.blocked && <span className="ag-note" data-testid="trigger-act-blocked">{g.on ? t('triggerActs.unused') : t('triggerActs.blocked')}</span>}
</span>
</label>
))}
</div>
</div>
)
}
/**
* The read only view of a trigger as the server returned it. compact leaves out
* the explanation of each grant (the proposal card is short).
*/
// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react
export function ActsSummary({ trigger, compact = false }) {
const { t } = useTranslation('chats')
const catalog = actCatalog(useBuiltinGrants())
const status = actsStatusOf(trigger)
if (status.readOnly || status.held.length === 0) {
const why = status.why || (status.unused.length > 0 ? 'agent' : 'no_acts')
return (
<div className="ta ta--sum" data-testid="trigger-acts-summary" data-read-only="true">
<b>{t('triggerActs.readOnly')}</b>{' '}<span data-testid="trigger-acts-why"><ReadOnlyWhy why={why} t={t} /></span>
</div>
)
}
const rows = actNamed(catalog, status.held)
const unused = actNamed(catalog, status.unused)
return (
<div className="ta ta--sum" data-testid="trigger-acts-summary">
<ul className="ta-list">
{rows.map((g) => (
<li key={g.name} data-act={g.name}>
<b>{g.title}</b>{g.sensitive && <Tag>{t('triggerActs.sensitive')}</Tag>}
{!compact && g.description && <span className="ag-note ta-desc"> {g.description}</span>}
</li>
))}
</ul>
{unused.length > 0 && (
<p className="ag-note" data-testid="trigger-acts-unused">{t('triggerActs.unusedList', { names: unused.map((g) => g.title).join(', ') })}</p>
)}
</div>
)
}
/** A mark for a list row: nothing for a run that can act, "Read only" for one that cannot. */
// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react
export function ReadOnlyTag({ trigger }) {
const { t } = useTranslation('chats')
const status = actsStatusOf(trigger)
if (!status.readOnly) return <span data-testid="trigger-acts-tag" data-read-only="false">{t('triggerActs.listCan', { count: status.held.length })}</span>
return <span data-testid="trigger-acts-tag" data-read-only="true" title={t(`triggerActs.why.${status.why || 'no_acts'}`)}>{t('triggerActs.readOnly')}</span>
}
@@ -5,6 +5,7 @@ import { proposalEdit, proposalView, receiptWhen } from '../../utils/chatProposa
import { useWellKnown } from '../../hooks/useWellKnown'
import { widgetList } from '../../utils/wellKnown'
import { plainSummary } from '../../utils/widgetTrigger'
import { ActsSummary } from '../TriggerActs'
import Icon from '../Icon'
import { Button } from '../ui'
@@ -49,6 +50,7 @@ function Plan({ p, t }) {
? <><code>{p.plan.writes}</code>{t('proposal.plan.writesWidget', { widget: widget.title })}</>
: <code>{p.plan.writes}</code>],
['cost', costText],
['acts', <ActsSummary key="acts" trigger={p} compact />],
['stop', t(widget ? 'proposal.plan.stopWidget' : 'proposal.plan.stop')],
].filter(Boolean)
return (
@@ -0,0 +1,6 @@
/* What the runs of a trigger may do alone: the editor list, the summary and the tag. */
.ta { display: flex; flex-direction: column; gap: var(--sp-2); }
.ta--sum { margin: 0; }
.ta-tag { margin-left: var(--sp-2); font-size: var(--fs-small); color: var(--dk-muted); }
.ta-list { margin: 0; padding-left: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-1); }
.ta-desc { display: block; }
@@ -14,6 +14,7 @@ import { JobMark, JobStrip } from '../components/agents/JobBits'
import { jobSentence } from '../utils/agentJobText'
import { AgentViews, StatusMark } from '../components/agents/AgentBits'
import { ClockTime, OutputBlock, RelTime, RunItem } from '../components/agents/RunBits'
import { ReadOnlyTag } from '../components/TriggerActs'
import Icon from '../components/Icon'
import { Button, EmptyState, LoadError, Page, PageHeader, Segmented, SkeletonRows, Switch } from '../components/ui'
import './agents.css'
@@ -361,6 +362,7 @@ function TriggerRow({ task, jobs, agent, onToggle, onRun, onOpen, onEdit, onDele
: <span className="ag-mono"><Icon name="cube" />{task.model || t('jobs.tasks.noAgent')}</span>}
{!off && Date.parse(task.next_run || '') > Date.now() && <span><Icon name="calendar" />{t('jobs.tasks.next')} <RelTime iso={task.next_run} /></span>}
{off && <span>{t('jobs.task.disabled')}</span>}
<ReadOnlyTag trigger={task} />
</span>
</div>
<div className="ag-row__record">
@@ -21,10 +21,12 @@ import { jobOfRun, STRIP } from '../utils/agentJobs'
import { jobSentence } from '../utils/agentJobText'
import { formatDuration, firstLine } from '../utils/agentRuns'
import { jobDurationMs } from '../utils/agentJobs'
import { actsStatus } from '../utils/triggerActs'
import {
EVERY_HOURS, EVERY_MINUTES, HOURLY_MINUTES, dayCodes, emptyTrigger, formatNext, joinTimes, presetOrder,
scheduleText, triggerForm, triggerPayload,
} from '../utils/chatTrigger'
import { ActsEditor, ActsSummary } from '../components/TriggerActs'
import Icon from '../components/Icon'
// eslint-disable-next-line no-unused-vars
import { Button, LoadError, Page, PageHeader, SkeletonRows, Switch } from '../components/ui'
@@ -178,6 +180,8 @@ function TaskView({ id }) {
{task.kind === 'file' ? t('jobs.task.watches', { folder: task.folder }) : task.summary}
{task.next_run && !off ? <span className="ag-muted"> · {t('chats:triggersPage.next', { time: formatNext(task.next_run, task.time_zone) })}</span> : null}
</dd>
<dt>{t('jobs.task.acts')}</dt>
<dd data-testid="task-acts"><ActsSummary trigger={task} /></dd>
<dt>{t('jobs.task.cap')}</dt>
<dd className="ag-facts__model">{task.daily_cap || '-'}</dd>
<dt>{t('jobs.task.enabledSwitch')}</dt>
@@ -243,6 +247,7 @@ const SECTIONS = [
{ id: 'basics', keys: ['name', 'agent_id', 'model', 'enabled'] },
{ id: 'prompt', keys: ['prompt'] },
{ id: 'schedule', keys: ['kind', 'schedule', 'time_zone', 'folder', 'quiet_seconds'] },
{ id: 'acts', keys: ['acts'] },
{ id: 'limits', keys: ['daily_cap'] },
]
@@ -440,6 +445,9 @@ function TaskForm({ id, isNew }) {
})
const picked = agents.find(a => a.id === task.agentId)
// The grants of the chosen agent cap what the trigger can hold; with no agent only the list does.
const agentGrants = picked ? (picked.builtins || []) : null
const status = actsStatus({ acts: task.acts, agentGrants, writes: task.writes, lock: task.lock })
const summary = (sid) => {
if (sid === 'basics') {
if (!task.name.trim()) return { state: 'needs', text: t('jobs.edit.summary.basicsEmpty') }
@@ -452,6 +460,10 @@ function TaskForm({ id, isNew }) {
if (task.schedule.preset === 'file') return { state: task.folder.trim() ? 'ready' : 'needs', text: task.folder.trim() ? t('jobs.task.watches', { folder: task.folder }) : t('jobs.edit.summary.folderEmpty') }
return { state: 'ready', text: scheduleText(task.schedule) }
}
if (sid === 'acts') {
if (status.readOnly) return { state: 'ready', text: t('chats:triggerActs.readOnly') }
return { state: 'ready', text: t('jobs.edit.summary.acts', { count: status.held.length }) }
}
return { state: 'ready', text: task.dailyCap ? t('jobs.edit.summary.cap', { count: Number(task.dailyCap) }) : t('jobs.edit.summary.capDefault') }
}
@@ -564,6 +576,13 @@ function TaskForm({ id, isNew }) {
</div>
)
}
if (sid === 'acts') {
return (
<div className="aj-form">
<ActsEditor value={task.acts} onChange={(acts) => set('acts', acts)} agentGrants={agentGrants} writes={task.writes} lock={task.lock} />
</div>
)
}
return (
<div className="aj-form">
<div className="dk-field aj-time">
+7
View File
@@ -126,6 +126,7 @@ export function emptyTrigger() {
return {
id: '', name: '', prompt: '', enabled: true, schedule: emptyForm(), zone: browserZone(),
folder: '', quiet: '', dailyCap: '', agentId: '', model: '', sourceChatId: '',
acts: [], writes: '', lock: '',
}
}
@@ -135,6 +136,10 @@ export function triggerForm(tr) {
id: tr.id, name: tr.name, prompt: tr.prompt, enabled: tr.enabled, schedule: presetOf(tr), zone: tr.time_zone || browserZone(),
folder: tr.folder || '', quiet: tr.quiet_seconds ? String(tr.quiet_seconds) : '', dailyCap: String(tr.daily_cap || ''),
agentId: tr.agent_id || '', model: tr.model || '', sourceChatId: tr.source_chat_id || '',
// What a run may do alone. writes (the file its plan names) and lock (why a
// run is read only whatever is ticked) come from the server and are not sent.
acts: Array.isArray(tr.acts) ? tr.acts : [], writes: tr.plan?.writes || '',
lock: tr.read_only && (tr.read_only_why === 'declared' || tr.read_only_why === 'binding') ? tr.read_only_why : '',
}
}
@@ -144,6 +149,8 @@ export function triggerPayload(v) {
const body = {
name: v.name.trim(), prompt: v.prompt, kind: file ? 'file' : 'schedule', enabled: v.enabled,
daily_cap: v.dailyCap ? Number(v.dailyCap) : 0,
// Always sent: an edit that leaves acts out keeps them, an empty list takes them away.
acts: v.acts || [],
agent_id: v.agentId || undefined, model: v.model || undefined, source_chat_id: v.sourceChatId || undefined,
}
if (file) {
+96
View File
@@ -0,0 +1,96 @@
// What the runs of a trigger may do with nobody present: the act.* names of a
// trigger. A run starts read only; each name adds one class of action, within
// the grants of the trigger's agent. These functions are the rules the editor,
// the details page and the proposal card share, so they say the same thing.
// The server applies the same rules at each tool call. No React, so a node test
// drives them.
const list = (x) => (Array.isArray(x) ? x : [])
export const ACT_FILES = 'act.files'
export const ACT_ADDONS = 'act.addons'
/** True for the name of an act: act.files, act.addons, act.addons:<id>, act.send, act.spend. */
export const isActGrant = (name) => typeof name === 'string' && name.startsWith('act.')
/** The act.* entries of the builtin grants the server declares. */
export function actCatalog(grants) {
return list(grants).filter((g) => isActGrant(g?.name))
}
/**
* Whether an agent's grants cover an act: the name itself, or act.addons for
* act.addons:<id>. Without an agent (null) nothing but the trigger's own list
* limits it.
*/
export function withinAgent(name, agentGrants) {
if (agentGrants == null) return true
const held = list(agentGrants)
return held.includes(name) || (name.startsWith(`${ACT_ADDONS}:`) && held.includes(ACT_ADDONS))
}
/**
* The rows of the permission list: every act the server declares, on when the
* trigger holds it, and blocked when the agent does not hold it (a blocked
* row can be switched off but not on). An act the trigger holds that the
* server does not list, such as act.addons:<id>, stays as an extra row so it
* can be taken away.
*/
export function actRows(catalog, acts, agentGrants) {
const on = new Set(list(acts))
const known = new Set(list(catalog).map((g) => g.name))
const rows = list(catalog).map((g) => ({
name: g.name, title: g.title, description: g.description, sensitive: !!g.sensitive,
on: on.has(g.name), blocked: !withinAgent(g.name, agentGrants), extra: false,
}))
for (const name of list(acts)) {
if (known.has(name)) continue
rows.push({ name, title: name, description: '', sensitive: true, on: true, blocked: !withinAgent(name, agentGrants), extra: true })
}
return rows
}
/** The acts after one is switched on or off. */
export function actsAfter(acts, name, on) {
const rest = list(acts).filter((x) => x !== name)
return on ? [...rest, name] : rest
}
/**
* What the runs of a trigger can do, from the fields of the form:
* { readOnly, why, held, unused }. held are the acts that apply; unused the
* ones the trigger lists but its agent does not hold. why is declared or
* binding (set by how the trigger was made, and nothing ticked changes it),
* agent (it lists acts its agent holds none of) or no_acts. A trigger whose
* plan names a file it writes also holds act.files, as the server does.
*/
export function actsStatus({ acts, agentGrants = null, writes = '', lock = '' }) {
const listed = list(acts)
if (lock) return { readOnly: true, why: lock, held: [], unused: listed }
const held = listed.filter((n) => withinAgent(n, agentGrants))
if (writes && !held.includes(ACT_FILES) && withinAgent(ACT_FILES, agentGrants)) held.push(ACT_FILES)
const unused = listed.filter((n) => !held.includes(n))
if (held.length === 0) return { readOnly: true, why: listed.length > 0 ? 'agent' : 'no_acts', held, unused }
return { readOnly: false, why: '', held, unused }
}
/** The same answer from a trigger as the server returns it (acts, acts_held, read_only, read_only_why). */
export function actsStatusOf(trigger) {
const acts = list(trigger?.acts)
const held = list(trigger?.acts_held)
return {
readOnly: !!trigger?.read_only, why: trigger?.read_only_why || '', held,
unused: trigger?.read_only ? acts : acts.filter((n) => !held.includes(n)),
}
}
/** The rows of a list of act names, with the title and weight the server declares. */
export function actNamed(catalog, names) {
const byName = new Map(list(catalog).map((g) => [g.name, g]))
return list(names).map((name) => {
const g = byName.get(name)
return g
? { name, title: g.title, description: g.description, sensitive: !!g.sensitive }
: { name, title: name, description: '', sensitive: name.startsWith(`${ACT_ADDONS}:`) }
})
}
@@ -49,7 +49,7 @@ test('chat triggers: a stored file trigger is the file preset', () => {
test('chat triggers: the request body of a schedule and of a file trigger', () => {
const base = { name: ' Digest ', prompt: 'Go.', enabled: true, dailyCap: '', agentId: '', model: 'm', sourceChatId: '', zone: 'Europe/Rome', folder: '', quiet: '' }
assert.deepEqual(triggerPayload({ ...base, schedule: form({ preset: 'weekdays', time: '09:00' }) }), {
name: 'Digest', prompt: 'Go.', kind: 'schedule', enabled: true, daily_cap: 0, agent_id: undefined, model: 'm', source_chat_id: undefined,
name: 'Digest', prompt: 'Go.', kind: 'schedule', enabled: true, daily_cap: 0, acts: [], agent_id: undefined, model: 'm', source_chat_id: undefined,
schedule: 'weekdays 09:00', time_zone: 'Europe/Rome',
})
const file = triggerPayload({ ...base, schedule: form({ preset: 'file' }), folder: ' /Contracts ', quiet: '45', dailyCap: '5', agentId: 'a1' })
@@ -0,0 +1,103 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
actCatalog, actNamed, actRows, actsAfter, actsStatus, actsStatusOf, isActGrant, withinAgent,
} from '../../src/utils/triggerActs.js'
import { emptyTrigger, triggerForm, triggerPayload } from '../../src/utils/chatTrigger.js'
const grants = [
{ name: 'memory.write', title: 'Save memory', description: 'd', sensitive: false },
{ name: 'act.files', title: 'Write files on its own', description: 'files', sensitive: true },
{ name: 'act.addons', title: 'Use add-on actions on its own', description: 'addons', sensitive: true },
{ name: 'act.send', title: 'Send messages on its own', description: 'send', sensitive: true },
{ name: 'act.spend', title: 'Use paid or external tools on its own', description: 'spend', sensitive: true },
]
test('trigger acts: the catalog keeps only the act names', () => {
assert.deepEqual(actCatalog(grants).map((g) => g.name), ['act.files', 'act.addons', 'act.send', 'act.spend'])
assert.deepEqual(actCatalog(undefined), [])
assert.equal(isActGrant('act.files'), true)
assert.equal(isActGrant('memory.write'), false)
})
test('trigger acts: an agent covers an act by name, or act.addons covers one add-on', () => {
assert.equal(withinAgent('act.files', ['act.files']), true)
assert.equal(withinAgent('act.spend', ['act.files']), false)
assert.equal(withinAgent('act.addons:mail', ['act.addons']), true)
assert.equal(withinAgent('act.addons:mail', ['act.files']), false)
assert.equal(withinAgent('act.spend', null), true, 'no agent, no ceiling')
assert.equal(withinAgent('act.spend', []), false, 'an agent with no grants covers nothing')
})
test('trigger acts: rows are on when held, blocked when the agent lacks them, and keep an unlisted act', () => {
const rows = actRows(actCatalog(grants), ['act.files', 'act.addons:mail'], ['act.files'])
const by = Object.fromEntries(rows.map((r) => [r.name, r]))
assert.equal(by['act.files'].on, true)
assert.equal(by['act.files'].blocked, false)
assert.equal(by['act.spend'].on, false)
assert.equal(by['act.spend'].blocked, true)
assert.equal(by['act.files'].sensitive, true)
assert.equal(by['act.addons:mail'].extra, true)
assert.equal(by['act.addons:mail'].on, true)
assert.equal(by['act.addons:mail'].blocked, true, 'the agent holds act.files only')
assert.equal(rows.length, 5)
})
test('trigger acts: switching an act on or off', () => {
assert.deepEqual(actsAfter(['act.files'], 'act.send', true), ['act.files', 'act.send'])
assert.deepEqual(actsAfter(['act.files', 'act.send'], 'act.files', false), ['act.send'])
assert.deepEqual(actsAfter(undefined, 'act.send', false), [])
})
test('trigger acts: a trigger with no acts is read only and says so', () => {
const s = actsStatus({ acts: [], agentGrants: ['act.files'] })
assert.deepEqual([s.readOnly, s.why], [true, 'no_acts'])
})
test('trigger acts: acts the agent does not hold do not apply, and the reason is the agent', () => {
const s = actsStatus({ acts: ['act.spend'], agentGrants: ['act.files'] })
assert.deepEqual([s.readOnly, s.why, s.held, s.unused], [true, 'agent', [], ['act.spend']])
const mixed = actsStatus({ acts: ['act.spend', 'act.files'], agentGrants: ['act.files'] })
assert.deepEqual([mixed.readOnly, mixed.held, mixed.unused], [false, ['act.files'], ['act.spend']])
})
test('trigger acts: a plan that names a file it writes holds act.files', () => {
const s = actsStatus({ acts: [], agentGrants: ['act.files'], writes: 'data/out.json' })
assert.deepEqual([s.readOnly, s.held], [false, ['act.files']])
assert.equal(actsStatus({ acts: [], agentGrants: [], writes: 'data/out.json' }).readOnly, true, 'the agent must hold it too')
})
test('trigger acts: a lock from the way it was made wins over every act', () => {
for (const lock of ['declared', 'binding']) {
const s = actsStatus({ acts: ['act.files'], agentGrants: ['act.files'], lock })
assert.deepEqual([s.readOnly, s.why, s.held], [true, lock, []])
}
})
test('trigger acts: the status of a trigger as the server returns it', () => {
const t = { acts: ['act.files', 'act.spend'], acts_held: ['act.files'], read_only: false }
assert.deepEqual(actsStatusOf(t), { readOnly: false, why: '', held: ['act.files'], unused: ['act.spend'] })
const ro = actsStatusOf({ acts: ['act.files'], read_only: true, read_only_why: 'binding' })
assert.deepEqual([ro.readOnly, ro.why, ro.unused], [true, 'binding', ['act.files']])
assert.equal(actsStatusOf(null).readOnly, false)
})
test('trigger acts: names get the title and weight of the catalog', () => {
const rows = actNamed(grants, ['act.send', 'act.addons:mail'])
assert.deepEqual(rows.map((r) => [r.name, r.title, r.sensitive]), [
['act.send', 'Send messages on its own', true],
['act.addons:mail', 'act.addons:mail', true],
])
})
test('trigger acts: the editor sends the acts it loaded, and an empty list when none', () => {
const loaded = triggerForm({ id: 'x', name: 'n', prompt: 'p', enabled: true, kind: 'schedule', schedule: 'daily 08:00', acts: ['act.files'], read_only: false,
plan: { writes: 'data/out.json' } })
assert.deepEqual(loaded.acts, ['act.files'])
assert.equal(loaded.writes, 'data/out.json')
assert.deepEqual(triggerPayload(loaded).acts, ['act.files'])
assert.deepEqual(triggerPayload(emptyTrigger()).acts, [], 'a new trigger sends an empty list: read only')
const locked = triggerForm({ id: 'x', name: 'n', prompt: 'p', enabled: true, kind: 'schedule', schedule: 'daily 08:00', read_only: true, read_only_why: 'declared' })
assert.equal(locked.lock, 'declared')
assert.deepEqual(triggerPayload(locked).acts, [])
})