@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
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/.env.example +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
|
@@ -14,6 +14,7 @@ import { ApprovalStore } from '../src/approval.js'
|
|
|
14
14
|
import { Tasks } from '../src/tasks.js'
|
|
15
15
|
import { ownerRun } from './helpers/owner-run.js'
|
|
16
16
|
import { packageVersion } from '../src/version.js'
|
|
17
|
+
import type { Config } from '../src/config.js'
|
|
17
18
|
|
|
18
19
|
const message = (id: number, text = 'hello'): Update => ({
|
|
19
20
|
update_id: id,
|
|
@@ -25,7 +26,7 @@ const message = (id: number, text = 'hello'): Update => ({
|
|
|
25
26
|
chat: { id: 101, type: 'private', first_name: 'Fixture' },
|
|
26
27
|
},
|
|
27
28
|
})
|
|
28
|
-
const fixture = async () => {
|
|
29
|
+
const fixture = async (overrides: Partial<Config> = {}) => {
|
|
29
30
|
const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
|
|
30
31
|
const launched: string[][] = []
|
|
31
32
|
const replies: string[] = []
|
|
@@ -40,6 +41,7 @@ const fixture = async () => {
|
|
|
40
41
|
executorCli: 'grok' as const,
|
|
41
42
|
telegramBotToken: 'fixture',
|
|
42
43
|
geminiApiKey: 'fixture',
|
|
44
|
+
...overrides,
|
|
43
45
|
}
|
|
44
46
|
const make = () => {
|
|
45
47
|
const relay = createRelay(config, async (texts) => {
|
|
@@ -57,7 +59,7 @@ const fixture = async () => {
|
|
|
57
59
|
} as typeof relay.bot.botInfo
|
|
58
60
|
relay.bot.api.config.use(async (_previous, method, payload) => {
|
|
59
61
|
if (method === 'getChatMember' && members.get((payload as {user_id: number}).user_id) === 'error') throw new Error('Fixture membership unavailable')
|
|
60
|
-
if (method === 'sendMessage') replies.push((payload as { text: string }).text)
|
|
62
|
+
if (method === 'sendMessage' || method === 'editMessageText') replies.push((payload as { text: string }).text)
|
|
61
63
|
const keyboard = (payload as { reply_markup?: { inline_keyboard?: { text: string; callback_data: string }[][] } }).reply_markup?.inline_keyboard
|
|
62
64
|
if (keyboard) keyboards.push(keyboard)
|
|
63
65
|
return {
|
|
@@ -191,7 +193,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
|
|
|
191
193
|
const run = (await new RunStore(f.dir).list())[0]
|
|
192
194
|
assert.equal(run.chatId, 101)
|
|
193
195
|
assert.equal(run.messageId, undefined)
|
|
194
|
-
assert.
|
|
196
|
+
assert.deepEqual(JSON.parse(run.texts[0]),{event:'owner_message_in_unbound_group',chatId:-101,title:'Family',messageId:3,text:'Hi from the group'})
|
|
195
197
|
assert.match(run.texts[0], /"chatId":-101/)
|
|
196
198
|
assert.equal(f.launched.length, 1)
|
|
197
199
|
await f.relay.bot.handleUpdate(group(4))
|
|
@@ -201,7 +203,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
|
|
|
201
203
|
} finally { await f.close() }
|
|
202
204
|
})
|
|
203
205
|
|
|
204
|
-
test('
|
|
206
|
+
test('conversation menu is owner-only and removes the retired settings control', async () => {
|
|
205
207
|
const f = await fixture()
|
|
206
208
|
const callback = (id: number, data: string, user = 101): Update => ({
|
|
207
209
|
update_id: id,
|
|
@@ -211,32 +213,53 @@ test('four-item menu is owner-only; available AI choices work and forged/stale b
|
|
|
211
213
|
try {
|
|
212
214
|
await f.relay.bot.handleUpdate(message(1, '/menu'))
|
|
213
215
|
assert.deepEqual(f.keyboards.at(-1)!.flat().map((b) => b.text),
|
|
214
|
-
['New conversation', 'Choose AI', 'Work status'
|
|
215
|
-
await f.relay.bot.handleUpdate(message(2, '/
|
|
216
|
-
|
|
216
|
+
['New conversation', 'Conversations', 'Choose AI', 'Work status'])
|
|
217
|
+
await f.relay.bot.handleUpdate(message(2, '/settings'))
|
|
218
|
+
assert.match(f.replies.at(-1)!, /Settings was removed.*Use \/ai/)
|
|
219
|
+
await f.relay.bot.handleUpdate(message(3, '/ai'))
|
|
220
|
+
const pick = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Refresh available AIs')!.callback_data
|
|
217
221
|
const store = new ControlStore(f.dir, 1000)
|
|
218
|
-
await
|
|
219
|
-
|
|
220
|
-
await f.relay.bot.handleUpdate(callback(4,
|
|
221
|
-
assert.equal(
|
|
222
|
-
await
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
assert.ok(await store.getActiveSession())
|
|
232
|
-
await f.relay.bot.handleUpdate(callback(8, pick))
|
|
222
|
+
const before = await store.status()
|
|
223
|
+
const keyboardCount = f.keyboards.length
|
|
224
|
+
await f.relay.bot.handleUpdate(callback(4, pick, 202))
|
|
225
|
+
assert.equal(f.keyboards.length, keyboardCount)
|
|
226
|
+
assert.deepEqual(await store.status(), before)
|
|
227
|
+
await f.relay.bot.handleUpdate(callback(5, 'ai:forged'))
|
|
228
|
+
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
229
|
+
assert.deepEqual(await store.status(), before)
|
|
230
|
+
await f.relay.bot.handleUpdate(callback(6, pick))
|
|
231
|
+
assert.equal(f.keyboards.length, keyboardCount + 1)
|
|
232
|
+
const refreshed = await store.status()
|
|
233
|
+
await f.relay.bot.handleUpdate(callback(7, pick))
|
|
233
234
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
234
|
-
await
|
|
235
|
-
assert.match(f.replies.at(-1)!, /Default for new conversations/)
|
|
235
|
+
assert.deepEqual(await store.status(), refreshed)
|
|
236
236
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
237
237
|
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
238
238
|
await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
|
|
239
|
-
assert.match(f.replies.at(-1)!, /No scheduled tasks for this owner/)
|
|
239
|
+
assert.match(f.replies.at(-1)!, /No active scheduled tasks for this owner/)
|
|
240
|
+
assert.equal(f.launched.length, 0)
|
|
241
|
+
} finally { await f.close() }
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('application-backed channels keep AI and retired settings controls in the application', async () => {
|
|
245
|
+
const f = await fixture({ channelBackendUrl: 'http://127.0.0.1:1', channelBackendToken: 'fixture' })
|
|
246
|
+
try {
|
|
247
|
+
const store = new ControlStore(f.dir, 1000)
|
|
248
|
+
const before = await store.status()
|
|
249
|
+
let id = 1
|
|
250
|
+
for (const text of ['/ai', '/settings', '/new', '/chats', '/rename Example']) {
|
|
251
|
+
await f.relay.bot.handleUpdate(message(id++, text))
|
|
252
|
+
assert.match(f.replies.at(-1)!, /managed in the connected application/)
|
|
253
|
+
}
|
|
254
|
+
for (const data of ['menu:ai', 'menu:settings', 'menu:new', 'menu:chats', 'chat:list:0:0', 'chat:open:invalid', 'ai:old-button']) {
|
|
255
|
+
await f.relay.bot.handleUpdate({ update_id: id, callback_query: {
|
|
256
|
+
id: String(id), chat_instance: 'fixture', data,
|
|
257
|
+
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(id++).message!,
|
|
258
|
+
} })
|
|
259
|
+
assert.match(f.replies.at(-1)!, /managed in the connected application/)
|
|
260
|
+
}
|
|
261
|
+
assert.deepEqual(await store.status(), before)
|
|
262
|
+
assert.equal(f.keyboards.length, 0)
|
|
240
263
|
assert.equal(f.launched.length, 0)
|
|
241
264
|
} finally { await f.close() }
|
|
242
265
|
})
|
|
@@ -484,3 +507,124 @@ test('approved family messages enter restricted task runs, never the owner sessi
|
|
|
484
507
|
await tasks.ownerCall('setup','list',{}).then(()=>assert.fail('completed run cannot change grants'),()=>{})
|
|
485
508
|
} finally {await f.close()}
|
|
486
509
|
})
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
test('Telegram named conversation buttons switch and archive without rerouting accepted messages', async () => {
|
|
513
|
+
const f = await fixture()
|
|
514
|
+
try {
|
|
515
|
+
let id = 1
|
|
516
|
+
const send = (text: string) => f.relay.bot.handleUpdate(message(id++, text))
|
|
517
|
+
const click = async (data: string, user = 101) => f.relay.bot.handleUpdate({ update_id: id, callback_query: {
|
|
518
|
+
id: String(id), chat_instance: 'fixture', data,
|
|
519
|
+
from: { id: user, first_name: 'Fixture', is_bot: false }, message: message(id++).message!,
|
|
520
|
+
} })
|
|
521
|
+
const store = new ControlStore(f.dir, 1000)
|
|
522
|
+
await send('Client launch')
|
|
523
|
+
const first = (await store.getActiveSession())!.sessionId
|
|
524
|
+
await send('/new')
|
|
525
|
+
await send('Holiday planning')
|
|
526
|
+
const second = (await store.getActiveSession())!.sessionId
|
|
527
|
+
await send('/chats')
|
|
528
|
+
assert.ok(f.keyboards.at(-1)!.flat().some(b => b.text === 'Client launch'))
|
|
529
|
+
assert.ok(!f.keyboards.at(-1)!.flat().some(b => b.callback_data.startsWith('chat:archive:')))
|
|
530
|
+
assert.ok(f.keyboards.at(-1)!.every(row => row.filter(b => b.callback_data.startsWith('chat:open:')).length === 0 || row.length === 1))
|
|
531
|
+
const before = await store.status()
|
|
532
|
+
await click(`chat:open:${first}`, 202)
|
|
533
|
+
await click(`chat:archive:${second}`, 202)
|
|
534
|
+
assert.deepEqual(await store.status(), before)
|
|
535
|
+
await click(`chat:open:${first}`)
|
|
536
|
+
assert.equal((await store.getActiveSession())!.sessionId, first)
|
|
537
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:archive:')), [{text: 'Archive this conversation', callback_data: `chat:archive:${first}`}])
|
|
538
|
+
await send('/rename@fixture_bot <Client & launch>')
|
|
539
|
+
assert.equal((await store.getActiveSession())!.title, '<Client & launch>')
|
|
540
|
+
await click(`chat:archive:${first}`)
|
|
541
|
+
assert.equal(await store.getActiveSession(), null)
|
|
542
|
+
await click('chat:list:0:0')
|
|
543
|
+
assert.ok(!f.keyboards.at(-1)!.flat().some(b => b.callback_data === `chat:open:${first}`))
|
|
544
|
+
await click('chat:list:1:0')
|
|
545
|
+
assert.ok(f.keyboards.at(-1)!.flat().some(b => b.text === '<Client & launch>'))
|
|
546
|
+
await f.restart()
|
|
547
|
+
await click(`chat:restore:${first}`)
|
|
548
|
+
await click(`chat:open:${first}`)
|
|
549
|
+
await send('Continue launch')
|
|
550
|
+
for (let n = 0; n < 3; n++) await f.relay.drainInbox(true)
|
|
551
|
+
const runs = await new RunStore(f.dir).list()
|
|
552
|
+
assert.equal(runs.find(r => r.texts.includes('Holiday planning'))!.execution!.sessionId, second)
|
|
553
|
+
assert.equal(runs.find(r => r.texts.includes('Continue launch'))!.execution!.sessionId, first)
|
|
554
|
+
assert.ok(!runs.some(r => r.texts.some(t => t.startsWith('/rename'))))
|
|
555
|
+
await click('chat:open:../../escape')
|
|
556
|
+
assert.match(f.replies.at(-1)!, /unavailable/)
|
|
557
|
+
} finally { await f.close() }
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('conversation menu paginates and stale pages remain usable after archiving', async () => {
|
|
561
|
+
const f = await fixture()
|
|
562
|
+
try {
|
|
563
|
+
const store = new ControlStore(f.dir, 1000)
|
|
564
|
+
for (let n = 0; n < 10; n++) {
|
|
565
|
+
await store.captureChoice({ id: 'fixture', name: 'Grok', cli: 'grok' }, `Topic ${n}`)
|
|
566
|
+
if (n < 9) await store.resetSession()
|
|
567
|
+
}
|
|
568
|
+
let id = 1
|
|
569
|
+
const click = (data: string) => f.relay.bot.handleUpdate({ update_id: id, callback_query: {
|
|
570
|
+
id: String(id), chat_instance: 'fixture', data,
|
|
571
|
+
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(id++).message!,
|
|
572
|
+
} })
|
|
573
|
+
await click('chat:list:0:0')
|
|
574
|
+
assert.equal(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:open:')).length, 8)
|
|
575
|
+
assert.ok(f.keyboards.at(-1)!.flat().some(b => b.text === 'Next'))
|
|
576
|
+
await click('chat:list:0:1')
|
|
577
|
+
assert.equal(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:open:')).length, 2)
|
|
578
|
+
for (const session of await store.listSessions()) await store.archiveSession(session.sessionId, true)
|
|
579
|
+
await click('chat:list:0:1')
|
|
580
|
+
assert.equal(f.keyboards.at(-1)!.flat().filter(b => b.callback_data.startsWith('chat:open:')).length, 0)
|
|
581
|
+
} finally { await f.close() }
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
test('older conversation names come from owner messages and detail keeps archive available when resume fails', async () => {
|
|
586
|
+
const f = await fixture()
|
|
587
|
+
try {
|
|
588
|
+
const store = new ControlStore(f.dir, 1000)
|
|
589
|
+
const old = await store.ensureActiveSession()
|
|
590
|
+
await store.markSessionStarted(old.sessionId)
|
|
591
|
+
const execution = await store.captureChoice({ id: 'grok', name: 'Grok', cli: 'grok' })
|
|
592
|
+
const runs = new RunStore(f.dir)
|
|
593
|
+
await runs.create({ chatId: 101, telegramUserId: 101, texts: ['Internal update event'], execution })
|
|
594
|
+
await runs.create({ chatId: 101, telegramUserId: 101, messageId: 9, texts: [JSON.stringify({event: 'approval_decision', decision: 'approve'})], execution })
|
|
595
|
+
await runs.create({ chatId: 101, telegramUserId: 101, messageId: 10, texts: ['/start'], execution })
|
|
596
|
+
await runs.create({ chatId: 101, telegramUserId: 101, messageId: 11, texts: ['Client launch checklist'], execution })
|
|
597
|
+
await store.captureChoice({ id: 'grok', name: 'Grok', cli: 'grok' }, 'A later message must not relabel old history')
|
|
598
|
+
assert.equal((await store.getActiveSession())!.title, undefined)
|
|
599
|
+
await store.resetSession()
|
|
600
|
+
await f.relay.bot.handleUpdate(message(1, '/chats'))
|
|
601
|
+
const rows = f.keyboards.at(-1)!
|
|
602
|
+
assert.ok(rows.flat().some(b => b.text.startsWith('Client launch checklist · ')))
|
|
603
|
+
assert.ok(!rows.flat().some(b => b.text === '✓ New conversation'))
|
|
604
|
+
assert.ok(!rows.flat().some(b => b.text.includes(old.sessionId.slice(0, 8))))
|
|
605
|
+
assert.ok(!rows.flat().some(b => b.callback_data.startsWith('chat:archive:')))
|
|
606
|
+
await f.relay.bot.handleUpdate({ update_id: 2, callback_query: {
|
|
607
|
+
id: '2', chat_instance: 'fixture', data: `chat:open:${old.sessionId}`,
|
|
608
|
+
from: { id: 101, first_name: 'Fixture', is_bot: false }, message: message(2).message!,
|
|
609
|
+
} })
|
|
610
|
+
assert.match(f.replies.at(-1)!, /Client launch checklist.*\n.*binding/)
|
|
611
|
+
assert.deepEqual(f.keyboards.at(-1)![0], [{text: 'Archive this conversation', callback_data: `chat:archive:${old.sessionId}`}])
|
|
612
|
+
} finally { await f.close() }
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
test('web launcher is private-owner-only and bypasses native intake without replacing controls', async () => {
|
|
617
|
+
const f=await fixture({webLauncher:{command:'voice',label:'Voice',url:'https://voice.example/'}})
|
|
618
|
+
try {
|
|
619
|
+
const stranger=message(1,'/voice');stranger.message!.from!.id=202
|
|
620
|
+
await f.relay.bot.handleUpdate(stranger)
|
|
621
|
+
assert.equal(f.keyboards.length,0)
|
|
622
|
+
await f.relay.bot.handleUpdate(message(2,'/voice'))
|
|
623
|
+
assert.deepEqual(f.keyboards.at(-1),[[{text:'Voice',web_app:{url:'https://voice.example/'}}]])
|
|
624
|
+
await f.relay.bot.handleUpdate(message(3,'/menu'))
|
|
625
|
+
assert.deepEqual(f.keyboards.at(-1)!.flat().map(b=>b.text),['New conversation','Conversations','Choose AI','Work status','Voice'])
|
|
626
|
+
await f.relay.drainInbox(true);assert.equal(f.launched.length,0)
|
|
627
|
+
await new ControlStore(f.dir,1000).revokeOwner()
|
|
628
|
+
const before=f.keyboards.length;await f.relay.bot.handleUpdate(message(4,'/voice'));assert.equal(f.keyboards.length,before)
|
|
629
|
+
} finally {await f.close()}
|
|
630
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, readFile, writeFile, readdir } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
9
|
+
import { deliveredMessages } from '../src/message-history.js'
|
|
10
|
+
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
|
|
13
|
+
async function fixture(t: test.TestContext) {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-history-'))
|
|
15
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
16
|
+
await ownerRun(dir, 'tg_1')
|
|
17
|
+
const runs = new RunStore(dir)
|
|
18
|
+
const patchFixture = async (id: string, patch: Record<string, unknown>) => {
|
|
19
|
+
const file = join(dir, 'runs', id + '.json')
|
|
20
|
+
await writeFile(file, JSON.stringify({ ...JSON.parse(await readFile(file, 'utf8')), ...patch }))
|
|
21
|
+
}
|
|
22
|
+
const send = async (runId: string, text: string, ids: number[]) => {
|
|
23
|
+
const item = await runs.enqueueMessage(runId, text)
|
|
24
|
+
await runs.claimOutbox(item.id)
|
|
25
|
+
await runs.markOutboxSent(item.id, ids)
|
|
26
|
+
return join(dir, 'outbox', item.id + '.sent.json')
|
|
27
|
+
}
|
|
28
|
+
return { dir, runs, send, patchFixture }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('CLI retrieves Synopsys delivery from another session without changing sessions or sending', async t => {
|
|
32
|
+
const { dir, runs, send, patchFixture } = await fixture(t)
|
|
33
|
+
await ownerRun(dir, 'r_schedule_report')
|
|
34
|
+
const owner = (await new ControlStore(dir, 1000).status()).owner!
|
|
35
|
+
await patchFixture('r_schedule_report', { nativeSessionId: 'report_session', scheduled: {
|
|
36
|
+
id: 's_earnings', revision: 1, dueAt: new Date().toISOString(), pairedAt: owner.pairedAt,
|
|
37
|
+
} })
|
|
38
|
+
await send('r_schedule_report', 'Synopsys (SNPS): quarterly results', [10, 11])
|
|
39
|
+
await runs.patch('r_schedule_report', { status: 'completed' })
|
|
40
|
+
await patchFixture('tg_1', { texts: ['Why now?'] })
|
|
41
|
+
await send('tg_1', 'Other report', [12])
|
|
42
|
+
const before = await readdir(join(dir, 'outbox'))
|
|
43
|
+
const controlBefore = await new ControlStore(dir, 1000).status()
|
|
44
|
+
const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-message.mjs', 'history', '--message-id', '11'], {
|
|
45
|
+
env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'tg_1' },
|
|
46
|
+
})
|
|
47
|
+
const result = JSON.parse(stdout)
|
|
48
|
+
assert.equal(result.messages.length, 1)
|
|
49
|
+
assert.equal(result.messages[0].text, 'Synopsys (SNPS): quarterly results')
|
|
50
|
+
assert.equal(result.messages[0].nativeSessionId, 'report_session')
|
|
51
|
+
assert.equal(result.messages[0].scheduleId, 's_earnings')
|
|
52
|
+
assert.deepEqual(result.messages[0].messageIds, [10, 11])
|
|
53
|
+
assert.deepEqual(await new ControlStore(dir, 1000).status(), controlBefore)
|
|
54
|
+
assert.deepEqual(await readdir(join(dir, 'outbox')), before)
|
|
55
|
+
const latest = await deliveredMessages(dir, 'tg_1', { limit: 1 })
|
|
56
|
+
assert.equal(latest.messages[0].text, 'Other report')
|
|
57
|
+
assert.equal(latest.hasMore, true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('only confirmed deliveries in current owner binding are exposed', async t => {
|
|
61
|
+
const { dir, runs, send, patchFixture } = await fixture(t)
|
|
62
|
+
await send('tg_1', 'included', [1])
|
|
63
|
+
await runs.enqueueMessage('tg_1', 'pending')
|
|
64
|
+
const failed = await runs.enqueueMessage('tg_1', 'uncertain')
|
|
65
|
+
await runs.claimOutbox(failed.id)
|
|
66
|
+
await runs.failOutbox(failed.id, 'unknown', true)
|
|
67
|
+
await writeFile(join(dir, 'outbox', 'corrupt.sent.json'), '{')
|
|
68
|
+
await writeFile(join(dir, 'outbox', 'null.sent.json'), 'null')
|
|
69
|
+
const invalid = await send('tg_1', 'invalid receipt', [2])
|
|
70
|
+
const record = JSON.parse(await readFile(invalid, 'utf8'))
|
|
71
|
+
record.receipt.messageIds = []
|
|
72
|
+
await writeFile(invalid, JSON.stringify(record))
|
|
73
|
+
for (const [id, patch] of [
|
|
74
|
+
['r_other_chat', { chatId: 202 }],
|
|
75
|
+
['r_other_owner', { telegramUserId: 202 }],
|
|
76
|
+
['r_old', { createdAt: '2000-01-01T00:00:00.000Z' }],
|
|
77
|
+
['r_external', { external: { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] } }],
|
|
78
|
+
] as const) {
|
|
79
|
+
await ownerRun(dir, id)
|
|
80
|
+
await patchFixture(id, patch)
|
|
81
|
+
await send(id, 'excluded', [3])
|
|
82
|
+
}
|
|
83
|
+
assert.deepEqual((await deliveredMessages(dir, 'tg_1')).messages.map(m => m.text), ['included'])
|
|
84
|
+
await writeFile(join(dir, 'runs', 'broken.json'), '{')
|
|
85
|
+
assert.equal((await deliveredMessages(dir, 'tg_1')).messages.length, 1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('unauthorized callers and invalid arguments fail closed', async t => {
|
|
89
|
+
const { dir, runs, patchFixture } = await fixture(t)
|
|
90
|
+
for (const limit of [0, 51, NaN, 1.5]) await assert.rejects(deliveredMessages(dir, 'tg_1', { limit }), /Limit/)
|
|
91
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1', { messageId: -1 }), /Message ID/)
|
|
92
|
+
await assert.rejects(deliveredMessages(dir, '../tg_1'), /identifier/)
|
|
93
|
+
await assert.rejects(deliveredMessages(dir, 'missing'), /No active/)
|
|
94
|
+
await ownerRun(dir, 'r_external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
|
|
95
|
+
await assert.rejects(deliveredMessages(dir, 'r_external'), /blocked/)
|
|
96
|
+
await patchFixture('tg_1', { telegramUserId: 202 })
|
|
97
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1'), /owner-mismatch/)
|
|
98
|
+
await patchFixture('tg_1', { telegramUserId: 101, status: 'completed' })
|
|
99
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1'), /No active/)
|
|
100
|
+
await runs.patch('tg_1', { status: 'running' })
|
|
101
|
+
await new ControlStore(dir, 1000).revokeOwner()
|
|
102
|
+
await assert.rejects(deliveredMessages(dir, 'tg_1'), /owner-mismatch/)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('approval messages retain the content delivered to Telegram', async t => {
|
|
106
|
+
const { dir, send } = await fixture(t)
|
|
107
|
+
const file = await send('tg_1', 'placeholder', [20])
|
|
108
|
+
const item = JSON.parse(await readFile(file, 'utf8'))
|
|
109
|
+
delete item.text
|
|
110
|
+
item.type = 'approval'
|
|
111
|
+
item.approvalPrompt = 'Approve this messaging task?'
|
|
112
|
+
await writeFile(file, JSON.stringify(item))
|
|
113
|
+
assert.equal((await deliveredMessages(dir, 'tg_1')).messages[0].text, item.approvalPrompt)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('CLI flushes complete long reports and rejects send options in history mode', async t => {
|
|
117
|
+
const { dir, send } = await fixture(t)
|
|
118
|
+
const text = 'Report evidence. '.repeat(8000)
|
|
119
|
+
await send('tg_1', text, [30])
|
|
120
|
+
const env = { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'tg_1' }
|
|
121
|
+
const cli = [ 'bin/ezenciel-agents-message.mjs', 'history' ]
|
|
122
|
+
const { stdout } = await promisify(execFile)(process.execPath, cli, { env })
|
|
123
|
+
assert.equal(JSON.parse(stdout).messages[0].text, text)
|
|
124
|
+
const before = await readdir(join(dir, 'outbox'))
|
|
125
|
+
await assert.rejects(promisify(execFile)(process.execPath, [...cli, '--text', 'do not send'], { env }), /Unknown option/)
|
|
126
|
+
assert.deepEqual(await readdir(join(dir, 'outbox')), before)
|
|
127
|
+
})
|
|
@@ -11,56 +11,31 @@ import { taskArguments } from '../src/task-executor.js'
|
|
|
11
11
|
import { runCodexSession } from '../src/codex-session.js'
|
|
12
12
|
import { runDesktopTurn } from '../src/desktop-bridge.js'
|
|
13
13
|
|
|
14
|
-
test('
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort }), { model:'gpt-5.6-luna', effort })
|
|
31
|
-
assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort }), /capped at high/)
|
|
32
|
-
assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort }), /capped at high/)
|
|
33
|
-
}
|
|
14
|
+
test('engine defaults stay omitted and explicit native settings survive every adapter', async () => {
|
|
15
|
+
for(const cli of ['codex','codex-gui','grok','claude','opencode','agy']) {
|
|
16
|
+
assert.deepEqual(executionDefaults(cli,{}),{})
|
|
17
|
+
const preset={id:'chosen',name:'Chosen',cli,model:'native-model',effort:'ultra'}
|
|
18
|
+
assert.deepEqual(executionDefaults(cli,preset),preset)
|
|
19
|
+
await validateSelection(preset,[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true)
|
|
20
|
+
await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/installed client catalog/)
|
|
21
|
+
assert.throws(()=>executionDefaults(cli,{effort:'bad option'}),/Invalid reasoning effort/)
|
|
22
|
+
}
|
|
23
|
+
assert.deepEqual(executionOverrides('codex',{model:'old',effort:'max'},'new'),{model:'new',effort:undefined})
|
|
24
|
+
const saved={id:'saved',name:'Saved',cli:'codex',model:'chosen',effort:'max'}
|
|
25
|
+
assert.deepEqual(persistedPreset(saved),saved)
|
|
26
|
+
const args=taskArguments('/unused',['broker'],'--literal')
|
|
27
|
+
assert.equal(args.at(-1),'-');assert(!args.includes('--model'));assert(!args.some(s=>s.includes('model_reasoning_effort')))
|
|
28
|
+
const explicit=taskArguments('/unused',['broker'],'text',undefined,saved)
|
|
29
|
+
assert(explicit.includes('chosen'));assert(explicit.includes('model_reasoning_effort="max"'))
|
|
34
30
|
})
|
|
35
31
|
|
|
36
|
-
test('
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
assert.
|
|
42
|
-
|
|
43
|
-
assert.throws(() => taskArguments('/unused', ['broker'], 'prompt', undefined, {effort:'xhigh'}), /capped at high/)
|
|
44
|
-
assert.deepEqual(executionDefaults('codex', {model:'custom-model',effort:'medium'}), {model:'custom-model',effort:'medium'})
|
|
45
|
-
assert.deepEqual(executionDefaults('codex', {model:'gpt-6-astra'}), {model:'gpt-6-astra',effort:'high'})
|
|
46
|
-
assert.deepEqual(executionDefaults('codex', {model:'gpt-5.6-terra'}), {model:'gpt-5.6-terra',effort:'high'})
|
|
47
|
-
assert.deepEqual(executionDefaults('codex', {model:'gpt-5.6-luna'}), {model:'gpt-5.6-luna',effort:'max'})
|
|
48
|
-
assert.deepEqual(executionDefaults('codex', {}), {model:'gpt-5.6-luna',effort:'max'})
|
|
49
|
-
assert.deepEqual(executionOverrides('codex', {model:'gpt-5.6-luna',effort:'max'}, 'gpt-6-astra'), {model:'gpt-6-astra',effort:'high'})
|
|
50
|
-
assert.deepEqual(executionOverrides('codex', {model:'gpt-5.6-luna',effort:'max'}, 'gpt-5.6-luna'), {model:'gpt-5.6-luna',effort:'max'})
|
|
51
|
-
const stored = persistedPreset({id:'luna',name:'Luna',cli:'codex',model:'gpt-5.6-luna',effort:'max'})
|
|
52
|
-
assert.equal(stored.effort, undefined)
|
|
53
|
-
assert.deepEqual(executionDefaults('codex', stored), {id:'luna',name:'Luna',cli:'codex',model:'gpt-5.6-luna',effort:'max'})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('preset persistence rejects above-high choices without changing current settings', async () => {
|
|
57
|
-
const dir = await mkdtemp(join(tmpdir(), 'ez-effort-'))
|
|
58
|
-
try {
|
|
59
|
-
const control = new ControlStore(dir, 1000)
|
|
60
|
-
const before = await control.aiState(initialPreset('codex'))
|
|
61
|
-
await assert.rejects(control.savePreset({id:'bad',name:'Bad',cli:'codex',model:'any',effort:'max'}), /capped at high/)
|
|
62
|
-
assert.deepEqual(await control.aiState(initialPreset('codex')), before)
|
|
63
|
-
} finally { await rm(dir,{recursive:true,force:true}) }
|
|
32
|
+
test('invalid setting syntax never mutates a saved choice', async () => {
|
|
33
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-native-setting-'))
|
|
34
|
+
try {
|
|
35
|
+
const control=new ControlStore(dir,1000),before=await control.aiState(initialPreset('codex'))
|
|
36
|
+
await assert.rejects(control.savePreset({id:'bad',name:'Bad',cli:'codex',effort:'bad option'}),/Invalid AI preset/)
|
|
37
|
+
assert.deepEqual(await control.aiState(initialPreset('codex')),before)
|
|
38
|
+
}finally{await rm(dir,{recursive:true,force:true})}
|
|
64
39
|
})
|
|
65
40
|
|
|
66
41
|
test('non-Codex catalog defaults survive executor normalization and host revalidation', async () => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import * as fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import { nativeTaskBinding,nativeTasks } from '../src/plugins/native-tasks.mjs'
|
|
7
|
+
import { ControlStore } from '../src/control-state.js'
|
|
8
|
+
|
|
9
|
+
test('native tasks use verified control binding, sanitized environment and unchanged scheduler',async()=>{
|
|
10
|
+
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'ez-native-tasks-'))),home=path.join(root,'tools'),workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),hostConfig=path.join(root,'host-executor.json')
|
|
11
|
+
try {
|
|
12
|
+
for(const dir of [home,workspace,controlDir])await fs.mkdir(dir)
|
|
13
|
+
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace,hostConfig}))
|
|
14
|
+
const host={cli:'grok',agents:[{toolsHome:home,workspace,controlDir}]}
|
|
15
|
+
await fs.writeFile(hostConfig,JSON.stringify(host))
|
|
16
|
+
const binding=await nativeTaskBinding(home,{HOME:root,PATH:process.env.PATH,TELEGRAM_BOT_TOKEN:'secret',EZ_RUN_ID:'forged',NODE_OPTIONS:'injection',EZ_CONTROL_DIR:'/wrong'})
|
|
17
|
+
assert.equal(binding.cwd,workspace);assert.equal(binding.env.EZ_CONTROL_DIR,controlDir);assert.equal(binding.env.EZ_EXECUTOR_CLI,'grok')
|
|
18
|
+
for(const key of ['TELEGRAM_BOT_TOKEN','EZ_RUN_ID','NODE_OPTIONS'])assert.equal(binding.env[key],undefined)
|
|
19
|
+
assert.match((await nativeTasks(home,['--help'])).stdout,/durable, asynchronous CLI task/)
|
|
20
|
+
const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
|
|
21
|
+
const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
22
|
+
const text='Read scan; $(must-not-run) /goal literal'
|
|
23
|
+
const saved=await nativeTasks(home,['create','native-fixture','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
|
|
24
|
+
const value=JSON.parse(saved.stdout);assert.equal(value.text,text);assert.equal(value.execution.preset.cli,'grok')
|
|
25
|
+
assert.equal(JSON.parse((await nativeTasks(home,['show','native-fixture'])).stdout).text,text)
|
|
26
|
+
assert.equal((await nativeTasks(home,['remove','native-fixture'])).code,0)
|
|
27
|
+
await assert.rejects(nativeTasks(home,['--help'],{signal:AbortSignal.abort()}),/cancelled/)
|
|
28
|
+
await assert.rejects(nativeTasks(home,['bad\0argument']),/literal/)
|
|
29
|
+
for(const input of [['--text-file','/private/secret'],['--text-file=/private/secret']])
|
|
30
|
+
await assert.rejects(nativeTasks(home,['create','--now',...input]),/inline --text/)
|
|
31
|
+
await fs.writeFile(hostConfig,JSON.stringify({...host,agents:[{...host.agents[0],workspace:root}]}))
|
|
32
|
+
await assert.rejects(nativeTaskBinding(home),/does not match/)
|
|
33
|
+
await fs.writeFile(path.join(home,'config.json'),JSON.stringify({schemaVersion:1,workspace}))
|
|
34
|
+
await assert.rejects(nativeTasks(home,['--help']),/standalone/)
|
|
35
|
+
}finally{await fs.rm(root,{recursive:true,force:true})}
|
|
36
|
+
})
|