@jc_stack/ez-agents 0.1.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { once } from 'node:events'
|
|
8
|
+
import type { Update } from 'grammy/types'
|
|
9
|
+
import { createRelay } from '../src/index.js'
|
|
10
|
+
import { ControlStore } from '../src/control-state.js'
|
|
11
|
+
import { RunStore } from '../src/runs.js'
|
|
12
|
+
import { InboxStore } from '../src/inbox.js'
|
|
13
|
+
import { ApprovalStore } from '../src/approval.js'
|
|
14
|
+
import { packageVersion } from '../src/version.js'
|
|
15
|
+
|
|
16
|
+
const message = (id: number, text = 'hello'): Update => ({
|
|
17
|
+
update_id: id,
|
|
18
|
+
message: {
|
|
19
|
+
message_id: id,
|
|
20
|
+
date: 0,
|
|
21
|
+
text,
|
|
22
|
+
from: { id: 101, is_bot: false, first_name: 'Fixture' },
|
|
23
|
+
chat: { id: 101, type: 'private', first_name: 'Fixture' },
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
const fixture = async () => {
|
|
27
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
|
|
28
|
+
const launched: string[][] = []
|
|
29
|
+
const replies: string[] = []
|
|
30
|
+
const keyboards: { text: string; callback_data: string }[][][] = []
|
|
31
|
+
const children: ReturnType<typeof spawn>[] = []
|
|
32
|
+
const config = {
|
|
33
|
+
controlDir: dir,
|
|
34
|
+
workspace: dir,
|
|
35
|
+
pairingTtlMs: 1000,
|
|
36
|
+
executorTimeoutMs: 1000,
|
|
37
|
+
executorCli: 'grok' as const,
|
|
38
|
+
telegramBotToken: 'fixture',
|
|
39
|
+
geminiApiKey: 'fixture',
|
|
40
|
+
}
|
|
41
|
+
const make = () => {
|
|
42
|
+
const relay = createRelay(config, async (texts) => {
|
|
43
|
+
launched.push(texts)
|
|
44
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 50)'])
|
|
45
|
+
children.push(child)
|
|
46
|
+
await once(child, 'spawn')
|
|
47
|
+
return { child, cleanup: async () => {}, stdout: '' }
|
|
48
|
+
})
|
|
49
|
+
relay.bot.botInfo = {
|
|
50
|
+
id: 999,
|
|
51
|
+
is_bot: true,
|
|
52
|
+
first_name: 'Fixture',
|
|
53
|
+
username: 'fixture_bot',
|
|
54
|
+
} as typeof relay.bot.botInfo
|
|
55
|
+
relay.bot.api.config.use(async (_previous, method, payload) => {
|
|
56
|
+
if (method === 'sendMessage') replies.push((payload as { text: string }).text)
|
|
57
|
+
const keyboard = (payload as { reply_markup?: { inline_keyboard?: { text: string; callback_data: string }[][] } }).reply_markup?.inline_keyboard
|
|
58
|
+
if (keyboard) keyboards.push(keyboard)
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
result: method === 'getFile' ? { file_path: 'fixture.ogg' } : { message_id: 42 },
|
|
62
|
+
} as never
|
|
63
|
+
})
|
|
64
|
+
return relay
|
|
65
|
+
}
|
|
66
|
+
const control = new ControlStore(dir, 1000)
|
|
67
|
+
await control.requestPairing(101, 101)
|
|
68
|
+
await control.approveOwner(101)
|
|
69
|
+
let relay = make()
|
|
70
|
+
return {
|
|
71
|
+
dir,
|
|
72
|
+
launched,
|
|
73
|
+
replies,
|
|
74
|
+
keyboards,
|
|
75
|
+
get relay() {
|
|
76
|
+
return relay
|
|
77
|
+
},
|
|
78
|
+
async restart() {
|
|
79
|
+
await relay.stop()
|
|
80
|
+
relay = make()
|
|
81
|
+
},
|
|
82
|
+
async close() {
|
|
83
|
+
await relay.stop()
|
|
84
|
+
await relay.drainInbox()
|
|
85
|
+
for (const child of children) {
|
|
86
|
+
if (child.exitCode === null && child.signalCode === null) await once(child, 'close')
|
|
87
|
+
}
|
|
88
|
+
// Completion bookkeeping runs asynchronously after the process close event.
|
|
89
|
+
const runs = new RunStore(dir)
|
|
90
|
+
for (let i = 0; i < 100 && (await runs.list()).some((r) => r.status === 'running'); i++)
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
92
|
+
await rm(dir, { recursive: true, force: true })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test('four-item menu is owner-only; saved AI buttons work and forged/stale buttons cannot change settings', async () => {
|
|
98
|
+
const f = await fixture()
|
|
99
|
+
const callback = (id: number, data: string, user = 101): Update => ({
|
|
100
|
+
update_id: id,
|
|
101
|
+
callback_query: { id: String(id), chat_instance: 'fixture', data,
|
|
102
|
+
from: { id: user, first_name: 'Fixture', is_bot: false }, message: message(id).message! },
|
|
103
|
+
})
|
|
104
|
+
try {
|
|
105
|
+
await f.relay.bot.handleUpdate(message(1, '/menu'))
|
|
106
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
|
|
107
|
+
['New conversation', 'Choose AI', 'Work status', 'Settings'])
|
|
108
|
+
await f.relay.bot.handleUpdate(message(2, '/ai'))
|
|
109
|
+
const pick = f.keyboards.at(-1)!.flat()[0].callback_data
|
|
110
|
+
const store = new ControlStore(f.dir, 1000)
|
|
111
|
+
await f.relay.bot.handleUpdate(callback(3, pick, 202))
|
|
112
|
+
assert.equal(await store.getActiveSession(), null)
|
|
113
|
+
await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
|
|
114
|
+
assert.equal(await store.getActiveSession(), null)
|
|
115
|
+
await f.relay.bot.handleUpdate(callback(5, pick))
|
|
116
|
+
assert.ok(await store.getActiveSession())
|
|
117
|
+
await f.relay.bot.handleUpdate(callback(6, pick))
|
|
118
|
+
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
119
|
+
await f.relay.bot.handleUpdate(message(7, '/settings'))
|
|
120
|
+
assert.match(f.replies.at(-1)!, /Default for new conversations/)
|
|
121
|
+
assert.equal(f.launched.length, 0)
|
|
122
|
+
} finally { await f.close() }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('new conversation leaves already accepted messages pinned to the old conversation', async () => {
|
|
126
|
+
const f = await fixture()
|
|
127
|
+
try {
|
|
128
|
+
await f.relay.bot.handleUpdate(message(1, 'before'))
|
|
129
|
+
const store = new ControlStore(f.dir, 1000)
|
|
130
|
+
const first = (await store.getActiveSession())!.sessionId
|
|
131
|
+
await f.relay.bot.handleUpdate(message(2, '/new'))
|
|
132
|
+
await f.relay.bot.handleUpdate(message(3, 'after'))
|
|
133
|
+
await f.relay.drainInbox(true)
|
|
134
|
+
const runs = new RunStore(f.dir)
|
|
135
|
+
assert.equal((await runs.get('tg_1'))!.execution!.sessionId, first)
|
|
136
|
+
await f.relay.drainInbox(true)
|
|
137
|
+
assert.notEqual((await runs.get('tg_3'))!.execution!.sessionId, first)
|
|
138
|
+
} finally { await f.close() }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('accepted DM survives relay restart and duplicate delivery creates exactly one run', async () => {
|
|
142
|
+
const f = await fixture()
|
|
143
|
+
try {
|
|
144
|
+
await f.relay.bot.handleUpdate(message(1))
|
|
145
|
+
assert.equal(f.launched.length, 0)
|
|
146
|
+
await f.restart()
|
|
147
|
+
await f.relay.bot.handleUpdate(message(1))
|
|
148
|
+
await f.relay.drainInbox(true)
|
|
149
|
+
assert.deepEqual(f.launched, [['hello']])
|
|
150
|
+
await f.relay.bot.handleUpdate(message(1))
|
|
151
|
+
await f.relay.drainInbox(true)
|
|
152
|
+
assert.equal((await new RunStore(f.dir).list()).length, 1)
|
|
153
|
+
} finally {
|
|
154
|
+
await f.close()
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('slow voice normalization preserves instruction order and leaves controls responsive', async () => {
|
|
159
|
+
const f = await fixture()
|
|
160
|
+
const originalFetch = globalThis.fetch
|
|
161
|
+
let release!: () => void
|
|
162
|
+
const gate = new Promise<void>((resolve) => {
|
|
163
|
+
release = resolve
|
|
164
|
+
})
|
|
165
|
+
let entered!: () => void
|
|
166
|
+
const downloading = new Promise<void>((resolve) => {
|
|
167
|
+
entered = resolve
|
|
168
|
+
})
|
|
169
|
+
globalThis.fetch = async (input) => {
|
|
170
|
+
if (String(input).includes('api.telegram.org/file/')) {
|
|
171
|
+
entered()
|
|
172
|
+
await gate
|
|
173
|
+
return new Response('fixture audio')
|
|
174
|
+
}
|
|
175
|
+
return Response.json({ candidates: [{ content: { parts: [{ text: 'Fixture voice transcript' }] } }] })
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await f.relay.bot.handleUpdate(message(1, 'Use the following voice note'))
|
|
179
|
+
const voice = message(2)
|
|
180
|
+
delete voice.message!.text
|
|
181
|
+
voice.message!.voice = { file_id: 'fixture', file_unique_id: 'fixture', duration: 2 }
|
|
182
|
+
voice.message!.reply_to_message = {
|
|
183
|
+
...message(91, 'Quoted context').message!,
|
|
184
|
+
reply_to_message: undefined,
|
|
185
|
+
}
|
|
186
|
+
await f.relay.bot.handleUpdate(voice)
|
|
187
|
+
const processing = f.relay.drainInbox(true)
|
|
188
|
+
await downloading
|
|
189
|
+
await f.relay.bot.handleUpdate(message(3, '/status'))
|
|
190
|
+
assert.ok(f.replies.some((text) => text.includes('2 incoming messages')))
|
|
191
|
+
assert.ok(f.replies.some((text) => text.includes(`Ez relay: ${packageVersion} (running)`)))
|
|
192
|
+
await f.relay.bot.handleUpdate(message(4, 'Next instruction'))
|
|
193
|
+
assert.equal(f.launched.length, 0)
|
|
194
|
+
release()
|
|
195
|
+
await processing
|
|
196
|
+
assert.equal(f.launched[0][0], 'Use the following voice note')
|
|
197
|
+
assert.match(f.launched[0][1], /Quoted context/)
|
|
198
|
+
assert.match(f.launched[0][1], /Fixture voice transcript/)
|
|
199
|
+
assert.equal(f.launched[0].length, 2)
|
|
200
|
+
assert.equal((await new InboxStore(f.dir).status()).pending, 1)
|
|
201
|
+
} finally {
|
|
202
|
+
release()
|
|
203
|
+
globalThis.fetch = originalFetch
|
|
204
|
+
await f.close()
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('cancel clears accepted and queued work, never spawns a cancelled run, and status exposes delivery uncertainty', async () => {
|
|
209
|
+
const f = await fixture()
|
|
210
|
+
try {
|
|
211
|
+
const runs = new RunStore(f.dir)
|
|
212
|
+
const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['queued'] })
|
|
213
|
+
const out = await runs.enqueueMessage(run.id, 'fixture delivery')
|
|
214
|
+
await runs.claimOutbox(out.id)
|
|
215
|
+
await f.relay.bot.handleUpdate(message(1))
|
|
216
|
+
await f.relay.bot.handleUpdate(message(2, '/cancel'))
|
|
217
|
+
await f.relay.drainInbox(true)
|
|
218
|
+
assert.equal(f.launched.length, 0)
|
|
219
|
+
assert.equal((await runs.get(run.id))?.status, 'cancelled')
|
|
220
|
+
await f.relay.bot.handleUpdate(message(3, '/status'))
|
|
221
|
+
assert.match(f.replies.at(-1)!, /0 runs; 0 incoming messages/)
|
|
222
|
+
assert.match(f.replies.at(-1)!, /1 unknown/)
|
|
223
|
+
} finally {
|
|
224
|
+
await f.close()
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('replayed approval decision can recover its wake but a new callback cannot replay consent', async () => {
|
|
229
|
+
const f = await fixture()
|
|
230
|
+
try {
|
|
231
|
+
const runs = new RunStore(f.dir)
|
|
232
|
+
const run = await runs.create({ chatId: 101, telegramUserId: 101, texts: ['approval'] })
|
|
233
|
+
await runs.patch(run.id, { status: 'completed' })
|
|
234
|
+
const approvals = new ApprovalStore(f.dir)
|
|
235
|
+
await approvals.requestApproval('fixture_action', 'Fixture?', run.id)
|
|
236
|
+
const update: Update = {
|
|
237
|
+
update_id: 11,
|
|
238
|
+
callback_query: {
|
|
239
|
+
id: 'fixture',
|
|
240
|
+
chat_instance: 'fixture',
|
|
241
|
+
from: message(1).message!.from!,
|
|
242
|
+
message: message(1).message!,
|
|
243
|
+
data: 'approval:fixture_action:approve',
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
await f.relay.bot.handleUpdate(update)
|
|
247
|
+
// Simulate crash after consent persisted, before its agent wake was created.
|
|
248
|
+
await approvals.recordDecision('fixture_action', 'approved', 101, 11)
|
|
249
|
+
await f.restart()
|
|
250
|
+
await f.relay.drainInbox(true)
|
|
251
|
+
assert.equal(f.launched.length, 1)
|
|
252
|
+
assert.match(f.launched[0][0], /approval_decision/)
|
|
253
|
+
await assert.rejects(approvals.recordDecision('fixture_action', 'approved', 101, 12), /already decided/)
|
|
254
|
+
} finally {
|
|
255
|
+
await f.close()
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('revoked owner intake never executes or recreates a pairing request on replay', async () => {
|
|
260
|
+
const f = await fixture()
|
|
261
|
+
try {
|
|
262
|
+
await f.relay.bot.handleUpdate(message(1))
|
|
263
|
+
const control = new ControlStore(f.dir, 1000)
|
|
264
|
+
await control.revokeOwner()
|
|
265
|
+
await f.relay.drainInbox(true)
|
|
266
|
+
assert.equal(f.launched.length, 0)
|
|
267
|
+
assert.equal((await control.status()).owner, null)
|
|
268
|
+
assert.deepEqual((await control.status()).pending, [])
|
|
269
|
+
assert.deepEqual(f.replies, [])
|
|
270
|
+
} finally {
|
|
271
|
+
await f.close()
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('cancelling during a download prevents dispatch even when normalization finishes', async () => {
|
|
276
|
+
const f = await fixture()
|
|
277
|
+
const originalFetch = globalThis.fetch
|
|
278
|
+
let release!: () => void
|
|
279
|
+
const gate = new Promise<void>((resolve) => {
|
|
280
|
+
release = resolve
|
|
281
|
+
})
|
|
282
|
+
let entered!: () => void
|
|
283
|
+
const downloading = new Promise<void>((resolve) => {
|
|
284
|
+
entered = resolve
|
|
285
|
+
})
|
|
286
|
+
globalThis.fetch = async () => {
|
|
287
|
+
entered()
|
|
288
|
+
await gate
|
|
289
|
+
return new Response('Fixture document')
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const doc = message(1)
|
|
293
|
+
delete doc.message!.text
|
|
294
|
+
doc.message!.document = { file_id: 'fixture', file_unique_id: 'fixture', file_name: 'fixture.txt' }
|
|
295
|
+
await f.relay.bot.handleUpdate(doc)
|
|
296
|
+
const processing = f.relay.drainInbox(true)
|
|
297
|
+
await downloading
|
|
298
|
+
await f.relay.bot.handleUpdate(message(2, '/cancel'))
|
|
299
|
+
release()
|
|
300
|
+
await processing
|
|
301
|
+
assert.equal(f.launched.length, 0)
|
|
302
|
+
assert.equal((await new RunStore(f.dir).list()).length, 0)
|
|
303
|
+
assert.equal((await new InboxStore(f.dir).status()).pending, 0)
|
|
304
|
+
} finally {
|
|
305
|
+
release()
|
|
306
|
+
globalThis.fetch = originalFetch
|
|
307
|
+
await f.close()
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('media failure quarantines its instruction batch instead of executing incomplete context', async () => {
|
|
312
|
+
const f = await fixture()
|
|
313
|
+
const originalFetch = globalThis.fetch
|
|
314
|
+
globalThis.fetch = async () => new Response('unavailable', { status: 503 })
|
|
315
|
+
try {
|
|
316
|
+
await f.relay.bot.handleUpdate(message(1, 'Use the following attachment'))
|
|
317
|
+
const doc = message(2)
|
|
318
|
+
delete doc.message!.text
|
|
319
|
+
doc.message!.document = { file_id: 'fixture', file_unique_id: 'fixture', file_name: 'fixture.txt' }
|
|
320
|
+
await f.relay.bot.handleUpdate(doc)
|
|
321
|
+
await f.relay.drainInbox(true)
|
|
322
|
+
assert.equal(f.launched.length, 0)
|
|
323
|
+
assert.deepEqual(await new InboxStore(f.dir).status(), { pending: 0, failed: 1 })
|
|
324
|
+
globalThis.fetch = async () => new Response('Recovered fixture document')
|
|
325
|
+
await f.relay.bot.handleUpdate(message(3, '/retry'))
|
|
326
|
+
await f.relay.drainInbox(true)
|
|
327
|
+
assert.equal(f.launched.length, 1)
|
|
328
|
+
assert.equal(f.launched[0][0], 'Use the following attachment')
|
|
329
|
+
assert.match(f.launched[0][1], /Attached document/)
|
|
330
|
+
await f.relay.bot.handleUpdate(message(4, '/retry'))
|
|
331
|
+
await f.relay.drainInbox(true)
|
|
332
|
+
assert.equal(f.launched.length, 1)
|
|
333
|
+
} finally {
|
|
334
|
+
globalThis.fetch = originalFetch
|
|
335
|
+
await f.close()
|
|
336
|
+
}
|
|
337
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {execFileSync} from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
test('owner help succeeds without loading control configuration or credentials',()=>{
|
|
6
|
+
const output=execFileSync(process.execPath,['bin/ezenciel-agents-owner.mjs','--help'],{
|
|
7
|
+
encoding:'utf8',env:{PATH:process.env.PATH,EZ_CONTROL_DIR:'/dev/null/not-a-directory'}
|
|
8
|
+
});
|
|
9
|
+
assert.match(output,/Usage: ezenciel-agents-owner status/);
|
|
10
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {execFile} from 'node:child_process';
|
|
7
|
+
import {promisify} from 'node:util';
|
|
8
|
+
import {snapshot,init as initManager,validate,compose,locked} from '../src/plugins/manager.mjs';
|
|
9
|
+
// Synthetic manager tests explicitly opt out of the product's default packages.
|
|
10
|
+
async function init(home,workspace,catalog,hostConfig) {
|
|
11
|
+
const file=path.join(path.dirname(home),'test-catalog.json');
|
|
12
|
+
if(!catalog)await fs.writeFile(file,'{}');
|
|
13
|
+
return initManager(home,workspace,catalog||file,hostConfig);
|
|
14
|
+
}
|
|
15
|
+
const exec=promisify(execFile),bin=new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname;
|
|
16
|
+
async function fixture(t) {
|
|
17
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
18
|
+
const source=path.join(root,'source'),home=path.join(root,'tools'),workspace=path.join(root,'mind'),fake=path.join(root,'fake');
|
|
19
|
+
for(const d of [source,workspace,fake])await fs.mkdir(d);
|
|
20
|
+
const manifest={schemaVersion:1,id:'sample',version:'0.1.0',description:'Synthetic',commands:{sample:{executable:'client.mjs',args:[]}},skills:['SKILL.md']};
|
|
21
|
+
const deployment={schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{data:'/data'},workspace:true,healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/client.mjs'],suffix:[]}}};
|
|
22
|
+
for(const [name,value]of Object.entries({'package.json':JSON.stringify({files:['client.mjs','SKILL.md']}),'ez-plugin.json':JSON.stringify(manifest),'ez-deployment.json':JSON.stringify(deployment),'Dockerfile':'FROM scratch AS runtime','.dockerignore':'','client.mjs':'','SKILL.md':'Synthetic'}))await fs.writeFile(path.join(source,name),value);
|
|
23
|
+
const log=path.join(root,'docker.log');
|
|
24
|
+
await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(log)},JSON.stringify({argv:process.argv.slice(2),secret:process.env.TELEGRAM_BOT_TOKEN})+'\\n');\nif(process.argv.includes('run')){console.log(JSON.stringify(process.argv.slice(process.argv.indexOf('/app/client.mjs')+1)));process.exit(process.argv.includes('fail')?17:0)}\n`,{mode:0o700});
|
|
25
|
+
const env={...process.env,PATH:fake+path.delimiter+process.env.PATH,TELEGRAM_BOT_TOKEN:'must-not-leak'};
|
|
26
|
+
const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
|
|
27
|
+
return {root,source,home,workspace,fake,log,manifest,deployment,env,call};
|
|
28
|
+
}
|
|
29
|
+
test('catalog paths resolve relative to the catalog and pin each new agent independently',async t=>{
|
|
30
|
+
const f=await fixture(t),catalog=path.join(f.root,'defaults.json');
|
|
31
|
+
await fs.writeFile(catalog,JSON.stringify({sample:'./source'}));
|
|
32
|
+
await initManager(f.home,f.workspace,catalog);
|
|
33
|
+
const first=JSON.parse((await f.call('plugins','available')).stdout);
|
|
34
|
+
assert.equal(first.sample.source,await fs.realpath(f.source));
|
|
35
|
+
await fs.writeFile(path.join(f.source,'SKILL.md'),'new release');
|
|
36
|
+
const second=path.join(f.root,'second');await initManager(second,f.workspace,catalog);
|
|
37
|
+
const next=JSON.parse(await fs.readFile(path.join(second,'config.json'),'utf8'));
|
|
38
|
+
assert.notEqual(first.sample.revision,next.catalog.sample.revision);
|
|
39
|
+
assert.deepEqual(JSON.parse((await f.call('plugins','available')).stdout),first);
|
|
40
|
+
});
|
|
41
|
+
test('initialization seeds tool guidance and preserves existing mind notes',async t=>{
|
|
42
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
43
|
+
const template=await fs.readFile(new URL('../templates/agent/TOOLS.md',import.meta.url),'utf8');
|
|
44
|
+
const target=path.join(f.workspace,'TOOLS.md');
|
|
45
|
+
assert.ok((await fs.readFile(target,'utf8')).startsWith(template));
|
|
46
|
+
const existing='Owner-maintained tool notes\n';await fs.writeFile(target,existing);
|
|
47
|
+
await init(path.join(f.root,'other-tools'),f.workspace);
|
|
48
|
+
assert.ok((await fs.readFile(target,'utf8')).startsWith(existing));
|
|
49
|
+
});
|
|
50
|
+
test('bound launcher installs without startup; literal args and exit codes; scopes and secrets',async t=>{
|
|
51
|
+
const f=await fixture(t);const p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
52
|
+
const install=['plugins','install','sample','--source',f.source,'--revision',p.revision];
|
|
53
|
+
await f.call(...install);assert.equal(JSON.parse((await f.call(...install)).stdout).existing,true);
|
|
54
|
+
let lines=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);assert.equal(lines.length,1);assert(lines[0].argv.includes('build'));assert(!lines[0].argv.includes('up'));assert.equal(lines[0].secret,undefined);
|
|
55
|
+
const literal=['--home','/other','$(touch /tmp/nope)','a b','x;y','--text-file','has spaces.txt'];
|
|
56
|
+
assert.deepEqual(JSON.parse((await exec(path.join(f.home,'bin','ez'),['sample',...literal],{env:f.env})).stdout),literal);
|
|
57
|
+
await assert.rejects(f.call('sample','fail'),e=>e.code===17);
|
|
58
|
+
await assert.rejects(f.call('plugins','start','../sample'));
|
|
59
|
+
await assert.rejects(f.call('tools','install','sample'));
|
|
60
|
+
await f.call('plugins','start','sample');await f.call('plugins','stop','sample');
|
|
61
|
+
await f.call('plugins','uninstall','sample');await assert.rejects(f.call('sample','doctor'));
|
|
62
|
+
await f.call(...install); // preserved snapshot is safe to reuse
|
|
63
|
+
lines=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);assert(lines.every(x=>x.secret===undefined));assert(lines.every(x=>!x.argv.includes('-v')));
|
|
64
|
+
const mode=(await fs.stat(path.join(f.home,'registry.json'))).mode&0o777;assert.equal(mode,0o600);
|
|
65
|
+
});
|
|
66
|
+
test('changed source, symlinks, reserved aliases, arbitrary Docker fields rejected',async t=>{
|
|
67
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
68
|
+
await fs.writeFile(path.join(f.source,'client.mjs'),'changed');await assert.rejects(f.call('plugins','install','sample','--source',f.source,'--revision',p.revision));
|
|
69
|
+
await fs.rm(path.join(f.source,'client.mjs'));await fs.symlink('/etc/passwd',path.join(f.source,'client.mjs'));await assert.rejects(snapshot(f.source),/symlink/);
|
|
70
|
+
const d=structuredClone(f.deployment);d.services.sample.privileged=true;assert.throws(()=>validate(f.manifest,d,p.files),/unknown/);
|
|
71
|
+
const m=structuredClone(f.manifest);m.commands={plugins:m.commands.sample};const bad=structuredClone(f.deployment);bad.commands={plugins:bad.commands.sample};assert.throws(()=>validate(m,bad,p.files),/Reserved/);
|
|
72
|
+
});
|
|
73
|
+
test('registry corruption, active writer lock and alias collision fail closed',async t=>{
|
|
74
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
75
|
+
await locked(f.home,()=>assert.rejects(locked(f.home,()=>{}),/busy/));
|
|
76
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
77
|
+
const m={...f.manifest,id:'second'};await fs.writeFile(path.join(f.source,'ez-plugin.json'),JSON.stringify(m));const other=await snapshot(f.source);
|
|
78
|
+
await assert.rejects(f.call('plugins','install','second','--source',f.source,'--revision',other.revision),/collision/);
|
|
79
|
+
await fs.writeFile(path.join(f.home,'registry.json'),'{bad');await assert.rejects(f.call('sample','doctor'));
|
|
80
|
+
});
|
|
81
|
+
test('Compose resources are namespaced, private, and restricted to owning workspace',async t=>{
|
|
82
|
+
const f=await fixture(t),p=await snapshot(f.source);
|
|
83
|
+
const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
84
|
+
assert.equal(c.name,'ezp-synthetic');assert.equal(c.services.sample.ports,undefined);assert.equal(c.services.sample.privileged,undefined);assert.equal(c.services.sample.volumes[1].read_only,true);assert.deepEqual(c.services.sample.cap_drop,['ALL']);
|
|
85
|
+
});
|
|
86
|
+
test('host onboarding binds local ez without replacing global commands',async t=>{
|
|
87
|
+
const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');await fs.mkdir(native);await fs.writeFile(path.join(native,'native-tool'),'hello');
|
|
88
|
+
await fs.writeFile(config,JSON.stringify({cli:'synthetic',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
|
|
89
|
+
await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
|
|
90
|
+
await assert.rejects(init(f.home,f.workspace),/exists/);
|
|
91
|
+
});
|
|
92
|
+
test('host install exposes runnable public commands without source aliases',async t=>{
|
|
93
|
+
const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');
|
|
94
|
+
await fs.mkdir(native);
|
|
95
|
+
await fs.symlink('ezenciel-agents-message',path.join(native,'ezenciel-agents-message'));
|
|
96
|
+
await fs.writeFile(config,JSON.stringify({cli:'codex',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
|
|
97
|
+
await init(f.home,f.workspace,undefined,config);
|
|
98
|
+
const manifest=JSON.parse(await fs.readFile(new URL('../package.json',import.meta.url)));
|
|
99
|
+
for(const [name,entry] of Object.entries(manifest.bin)) assert.equal(await fs.realpath(path.join(f.home,'bin',name)),await fs.realpath(new URL('../'+entry,import.meta.url)));
|
|
100
|
+
const result=await exec(path.join(f.home,'bin','ezenciel-agents-message'),['--help'],{env:{...process.env,PATH:path.dirname(process.execPath)+path.delimiter+process.env.PATH}});
|
|
101
|
+
assert.match(result.stdout,/Usage: ezenciel-agents-message/);
|
|
102
|
+
});
|
|
103
|
+
test('copying another agent registry is rejected before any Docker operation',async t=>{
|
|
104
|
+
const f=await fixture(t),other=path.join(f.root,'other');await init(f.home,f.workspace);await init(other,f.workspace);
|
|
105
|
+
await fs.copyFile(path.join(f.home,'registry.json'),path.join(other,'registry.json'));
|
|
106
|
+
await assert.rejects(exec(process.execPath,[bin,'--home',other,'tools','list'],{env:f.env}),/Corrupt registry/);
|
|
107
|
+
await assert.rejects(fs.stat(f.log),e=>e.code==='ENOENT');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('v2 supports bounded dependency graphs and generated private secrets',async t=>{
|
|
111
|
+
const f=await fixture(t),p=await snapshot(f.source);
|
|
112
|
+
const d=structuredClone(f.deployment);d.schemaVersion=2;d.secrets=['db-password'];
|
|
113
|
+
d.services.database={image:'example/database@sha256:'+'a'.repeat(64),user:'999:999',healthcheck:['check'],memoryMiB:128,environment:{PASSWORD:{secret:'db-password'}}};
|
|
114
|
+
d.services.sample.dependsOn=['database'];d.services.sample.environment={URL:{secret:'db-password',prefix:'db://',suffix:'@database'}};
|
|
115
|
+
validate(f.manifest,d,p.files);
|
|
116
|
+
const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
|
|
117
|
+
assert.equal(c.services.database.user,'999:999');assert.equal(c.services.sample.depends_on.database.condition,'service_healthy');assert.equal(c.services.sample.environment.URL,'db://'+'b'.repeat(64)+'@database');
|
|
118
|
+
assert.equal(c.services.database.mem_limit,'128m');assert.throws(()=>compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
|
|
119
|
+
for(const change of [x=>x.services.database.user='0:0',x=>x.services.database.environment.PASSWORD={secret:'undeclared'},x=>x.services.database.environment.PASSWORD='${HOST_SECRET}',x=>x.services.database.dependsOn=['sample'],x=>x.services.sample.dependsOn=['missing'],x=>x.services.sample.ports=['9999:9999']]){const bad=structuredClone(d);change(bad);assert.throws(()=>validate(f.manifest,bad,p.files));}
|
|
120
|
+
const old=structuredClone(d);old.schemaVersion=1;assert.throws(()=>validate(f.manifest,old,p.files));
|
|
121
|
+
});
|
|
122
|
+
test('catalog publication pins reviewed source without install or Docker calls',async t=>{
|
|
123
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
124
|
+
await f.call('plugins','catalog-add','sample','--source',f.source,'--revision',p.revision);
|
|
125
|
+
const available=JSON.parse((await f.call('plugins','available')).stdout);assert.equal(available.sample.revision,p.revision);
|
|
126
|
+
assert.deepEqual(JSON.parse((await f.call('tools','list')).stdout),{});await assert.rejects(fs.stat(f.log),e=>e.code==='ENOENT');
|
|
127
|
+
await assert.rejects(f.call('plugins','catalog-add','wrong','--source',f.source,'--revision',p.revision),/pin/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('plugin help documents data-preserving uninstall without a plugin identifier',async t=>{
|
|
131
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
132
|
+
const help=JSON.parse((await f.call('plugins','--help')).stdout);
|
|
133
|
+
assert.ok(help.commands.includes('uninstall <id>'));assert.match(help.uninstall,/retains all volumes and secrets/);
|
|
134
|
+
for(const action of ['install','uninstall','start'])assert.deepEqual(JSON.parse((await f.call('plugins',action,'--help')).stdout),help);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('inspection reveals stale catalog pin without changing it; explicit repin permits install',async t=>{
|
|
138
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
139
|
+
const old=await snapshot(f.source);
|
|
140
|
+
await f.call('plugins','catalog-add','sample','--source',f.source,'--revision',old.revision);
|
|
141
|
+
await fs.writeFile(path.join(f.source,'SKILL.md'),'Reviewed updated instructions');
|
|
142
|
+
const inspected=JSON.parse((await f.call('plugins','inspect','sample')).stdout);
|
|
143
|
+
assert.equal(inspected.catalogMatches,false);assert.equal(inspected.catalogRevision,old.revision);
|
|
144
|
+
assert.equal(inspected.source,await fs.realpath(f.source));assert.notEqual(inspected.revision,old.revision);
|
|
145
|
+
await assert.rejects(f.call('plugins','install','sample'),/Inspection is read-only/);
|
|
146
|
+
assert.equal(JSON.parse((await f.call('plugins','available')).stdout).sample.revision,old.revision);
|
|
147
|
+
await f.call('plugins','catalog-add','sample','--source',inspected.source,'--revision',inspected.revision);
|
|
148
|
+
assert.equal(JSON.parse((await f.call('plugins','inspect','sample')).stdout).catalogMatches,true);
|
|
149
|
+
assert.equal(JSON.parse((await f.call('plugins','install','sample')).stdout).revision,inspected.revision);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
test('plugin versions accept SemVer beta releases and reject malformed versions',async t=>{
|
|
154
|
+
const f=await fixture(t),p=await snapshot(f.source);
|
|
155
|
+
for(const version of ['0.1.0','0.1.0-beta.1','1.2.3-rc.0+build.12'])validate({...f.manifest,version},f.deployment,p.files);
|
|
156
|
+
for(const version of ['01.2.3','1.2','1.2.3-beta.01','1.2.3-','1.2.3+','1.2.3/beta',null,{}])assert.throws(()=>validate({...f.manifest,version},f.deployment,p.files),/manifest version/);
|
|
157
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../scripts/assert-local-registry.mjs')
|
|
8
|
+
|
|
9
|
+
const runGuard = (env: NodeJS.ProcessEnv) =>
|
|
10
|
+
spawnSync(process.execPath, [script], { encoding: 'utf8', env: { ...process.env, ...env } })
|
|
11
|
+
|
|
12
|
+
test('blocks a public npm registry', () => {
|
|
13
|
+
const result = runGuard({ npm_config_registry: 'https://registry.npmjs.org/' })
|
|
14
|
+
assert.notEqual(result.status, 0)
|
|
15
|
+
assert.match(result.stderr, /Refusing to publish outside the local ez registry/)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('allows the local verdaccio registry', () => {
|
|
19
|
+
const result = runGuard({ npm_config_registry: 'http://127.0.0.1:4873/' })
|
|
20
|
+
assert.equal(result.status, 0, result.stderr)
|
|
21
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { isSupportedReactionEmoji, normalizeReactionEmoji, TELEGRAM_REACTIONS } from '../src/reaction.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
const reactScriptPath = path.resolve('src/react.ts')
|
|
13
|
+
|
|
14
|
+
const fixture = async (run: (store: RunStore, dir: string) => Promise<void>): Promise<void> => {
|
|
15
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'ez-reactions-'))
|
|
16
|
+
try {
|
|
17
|
+
await run(new RunStore(directory), directory)
|
|
18
|
+
} finally {
|
|
19
|
+
await rm(directory, { recursive: true, force: true })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('normalizes standard Telegram reaction emojis', () => {
|
|
24
|
+
assert.equal(normalizeReactionEmoji('👍'), '👍')
|
|
25
|
+
assert.equal(normalizeReactionEmoji('🔥'), '🔥')
|
|
26
|
+
assert.equal(normalizeReactionEmoji('👀'), '👀')
|
|
27
|
+
assert.equal(normalizeReactionEmoji('🫡'), '🫡')
|
|
28
|
+
assert.equal(normalizeReactionEmoji('❤'), '❤')
|
|
29
|
+
assert.equal(isSupportedReactionEmoji('👍'), true)
|
|
30
|
+
assert.equal(isSupportedReactionEmoji('👀'), true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('normalizes emojis with variation selector FE0F', () => {
|
|
34
|
+
// Red heart with FE0F should normalize to standard ❤
|
|
35
|
+
assert.equal(normalizeReactionEmoji('❤️'), '❤')
|
|
36
|
+
// Lightning bolt with FE0F should normalize to ⚡
|
|
37
|
+
assert.equal(normalizeReactionEmoji('⚡️'), '⚡')
|
|
38
|
+
// Dove with FE0F should normalize to 🕊
|
|
39
|
+
assert.equal(normalizeReactionEmoji('🕊️'), '🕊')
|
|
40
|
+
// Shrugging man/woman with FE0F
|
|
41
|
+
assert.equal(normalizeReactionEmoji('🤷♂️'), '🤷♂')
|
|
42
|
+
assert.equal(normalizeReactionEmoji('🤷♀️'), '🤷♀')
|
|
43
|
+
// Heart on fire
|
|
44
|
+
assert.equal(normalizeReactionEmoji('❤\uFE0F\u200D\u{1F525}'), '❤🔥')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('rejects invalid or unsupported reaction emojis', () => {
|
|
48
|
+
assert.equal(normalizeReactionEmoji('✅'), undefined)
|
|
49
|
+
assert.equal(normalizeReactionEmoji('⏳'), undefined)
|
|
50
|
+
assert.equal(normalizeReactionEmoji('❌'), undefined)
|
|
51
|
+
assert.equal(normalizeReactionEmoji('🚀'), undefined)
|
|
52
|
+
assert.equal(normalizeReactionEmoji(''), undefined)
|
|
53
|
+
assert.equal(normalizeReactionEmoji(' '), undefined)
|
|
54
|
+
assert.equal(normalizeReactionEmoji(undefined), undefined)
|
|
55
|
+
assert.equal(normalizeReactionEmoji(null), undefined)
|
|
56
|
+
assert.equal(isSupportedReactionEmoji('✅'), false)
|
|
57
|
+
assert.equal(isSupportedReactionEmoji('⏳'), false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('RunStore enqueues valid reaction and normalizes emoji', async () => fixture(async (store) => {
|
|
61
|
+
const run = await store.create({ chatId: 123, telegramUserId: 123, texts: ['hi'], messageId: 456 })
|
|
62
|
+
await store.patch(run.id, { status: 'running' })
|
|
63
|
+
|
|
64
|
+
const item = await store.enqueueReaction(run.id, '❤️')
|
|
65
|
+
assert.equal(item.type, 'reaction')
|
|
66
|
+
assert.equal(item.emoji, '❤') // Normalized without FE0F
|
|
67
|
+
assert.equal(item.messageId, 456)
|
|
68
|
+
assert.equal(item.chatId, 123)
|
|
69
|
+
|
|
70
|
+
const pending = await store.pendingOutbox()
|
|
71
|
+
assert.equal(pending.length, 1)
|
|
72
|
+
assert.equal(pending[0].emoji, '❤')
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
test('RunStore rejects invalid reaction emoji with error', async () => fixture(async (store) => {
|
|
76
|
+
const run = await store.create({ chatId: 123, telegramUserId: 123, texts: ['hi'], messageId: 456 })
|
|
77
|
+
await store.patch(run.id, { status: 'running' })
|
|
78
|
+
|
|
79
|
+
await assert.rejects(
|
|
80
|
+
store.enqueueReaction(run.id, '✅'),
|
|
81
|
+
/Invalid Telegram reaction emoji "✅"/,
|
|
82
|
+
)
|
|
83
|
+
await assert.rejects(
|
|
84
|
+
store.enqueueReaction(run.id, '⏳'),
|
|
85
|
+
/Invalid Telegram reaction emoji "⏳"/,
|
|
86
|
+
)
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
test('ezenciel-agents-react CLI enforces EZ_RUN_ID and valid emoji', async () => fixture(async (store, dir) => {
|
|
90
|
+
// Missing EZ_RUN_ID
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
execFileAsync('pnpm', ['exec', 'tsx', reactScriptPath, '--emoji', '👍'], {
|
|
93
|
+
env: { ...process.env, EZ_RUN_ID: '', EZ_CONTROL_DIR: dir },
|
|
94
|
+
}),
|
|
95
|
+
(err: any) => {
|
|
96
|
+
assert.match(err.stderr, /EZ_RUN_ID is required/)
|
|
97
|
+
return true
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Invalid emoji
|
|
102
|
+
const run = await store.create({ chatId: 123, telegramUserId: 123, texts: ['hi'], messageId: 456 })
|
|
103
|
+
await store.patch(run.id, { status: 'running' })
|
|
104
|
+
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
execFileAsync('pnpm', ['exec', 'tsx', reactScriptPath, '--emoji', '✅'], {
|
|
107
|
+
env: { ...process.env, EZ_RUN_ID: run.id, EZ_CONTROL_DIR: dir },
|
|
108
|
+
}),
|
|
109
|
+
(err: any) => {
|
|
110
|
+
assert.match(err.stderr, /Invalid Telegram reaction emoji "✅"/)
|
|
111
|
+
return true
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Valid emoji succeeds
|
|
116
|
+
const { stdout } = await execFileAsync('pnpm', ['exec', 'tsx', reactScriptPath, '--emoji', '👍'], {
|
|
117
|
+
env: { ...process.env, EZ_RUN_ID: run.id, EZ_CONTROL_DIR: dir },
|
|
118
|
+
})
|
|
119
|
+
const parsed = JSON.parse(stdout)
|
|
120
|
+
assert.equal(parsed.ok, true)
|
|
121
|
+
assert.equal(parsed.emoji, '👍')
|
|
122
|
+
}))
|