@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.
Files changed (128) hide show
  1. package/.env.example +10 -1
  2. package/AGENTS.md +40 -9
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +31 -1
  5. package/Dockerfile +1 -0
  6. package/README.md +84 -12
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/architecture/ai-selection.md +12 -15
  16. package/docs/docker-runtime.md +29 -0
  17. package/docs/host-service.md +5 -8
  18. package/docs/local-qa.md +1 -1
  19. package/docs/managed-applications.md +68 -0
  20. package/docs/plugin-catalog.md +1 -0
  21. package/docs/plugin-connection.md +76 -0
  22. package/docs/plugins.md +54 -5
  23. package/docs/repair.md +26 -25
  24. package/docs/responsive-channels.md +13 -55
  25. package/docs/scheduling.md +40 -36
  26. package/docs/setup.md +11 -21
  27. package/docs/standalone-cli.md +2 -2
  28. package/docs/upgrades.md +43 -18
  29. package/package.json +8 -4
  30. package/src/agent-guidance.ts +32 -3
  31. package/src/ai-cli.ts +5 -1
  32. package/src/ai.ts +6 -28
  33. package/src/application-channel.ts +308 -0
  34. package/src/application-cli.ts +41 -0
  35. package/src/application-client.mjs +87 -0
  36. package/src/application-origin.ts +15 -0
  37. package/src/codex-session.ts +7 -10
  38. package/src/config.ts +23 -5
  39. package/src/control-state.ts +274 -21
  40. package/src/conversation-menu.ts +89 -0
  41. package/src/delivery-context.d.mts +5 -0
  42. package/src/delivery-context.mjs +25 -0
  43. package/src/desktop-bridge.ts +11 -43
  44. package/src/event-sources.ts +2 -2
  45. package/src/execution-authority.ts +2 -0
  46. package/src/executor.ts +29 -58
  47. package/src/host-executor.ts +11 -9
  48. package/src/identity.ts +11 -3
  49. package/src/index.ts +191 -93
  50. package/src/menu.ts +76 -55
  51. package/src/message-history.ts +52 -0
  52. package/src/message-send.ts +1 -1
  53. package/src/message.ts +49 -7
  54. package/src/model-policy.ts +5 -15
  55. package/src/owner.ts +7 -1
  56. package/src/plugins/connection-artifacts.mjs +31 -0
  57. package/src/plugins/connection.mjs +124 -0
  58. package/src/plugins/manager.mjs +93 -23
  59. package/src/plugins/native-tasks.d.mts +4 -0
  60. package/src/plugins/native-tasks.mjs +66 -0
  61. package/src/plugins/workspace-lease.d.mts +3 -0
  62. package/src/plugins/workspace-lease.mjs +44 -0
  63. package/src/repair-policy.ts +0 -8
  64. package/src/reply-context.ts +3 -29
  65. package/src/runs.ts +67 -9
  66. package/src/schedule-cli.ts +33 -15
  67. package/src/scheduled-tasks.ts +20 -21
  68. package/src/scheduler.ts +55 -22
  69. package/src/task-executor.ts +4 -5
  70. package/src/task-workspace.ts +2 -11
  71. package/src/update-attention.ts +1 -1
  72. package/src/updates/binding.mjs +2 -6
  73. package/src/updates/control.mjs +4 -0
  74. package/src/updates/supervisor.mjs +10 -4
  75. package/src/web-launcher.ts +19 -0
  76. package/src/workspace.ts +3 -1
  77. package/templates/agent/AGENTS.md +13 -55
  78. package/templates/agent-guidance.md +90 -37
  79. package/templates/deployments.md +24 -0
  80. package/templates/failure-review.md +6 -0
  81. package/templates/maintainer-purpose.md +12 -6
  82. package/test/agent-guidance.test.ts +29 -39
  83. package/test/ai-cli.test.ts +9 -0
  84. package/test/ai.test.ts +66 -22
  85. package/test/application-channel.test.ts +283 -0
  86. package/test/application-client.test.mjs +84 -0
  87. package/test/application-controls.test.ts +224 -0
  88. package/test/application-only.test.ts +100 -0
  89. package/test/busy-reply-relay.test.ts +11 -7
  90. package/test/channel-delivery.test.ts +63 -0
  91. package/test/channel-owner.test.ts +161 -0
  92. package/test/client-defaults.test.ts +1 -1
  93. package/test/codex-session.test.ts +18 -10
  94. package/test/config.test.ts +16 -1
  95. package/test/connection-artifacts.test.mjs +32 -0
  96. package/test/conversation-menu.test.ts +67 -0
  97. package/test/conversations.test.ts +84 -0
  98. package/test/desktop-bridge.test.ts +17 -11
  99. package/test/engine-handoff.test.ts +73 -0
  100. package/test/event-sources.test.ts +5 -8
  101. package/test/executor.test.ts +68 -16
  102. package/test/failure.test.ts +64 -0
  103. package/test/host-executor.test.ts +58 -17
  104. package/test/install-config.test.ts +1 -1
  105. package/test/intake-relay.test.ts +169 -25
  106. package/test/message-history.test.ts +127 -0
  107. package/test/model-policy.test.ts +23 -48
  108. package/test/native-tasks.test.ts +36 -0
  109. package/test/plugin-connection.test.mjs +124 -0
  110. package/test/plugin-manager.test.mjs +70 -10
  111. package/test/repair-policy.test.ts +8 -12
  112. package/test/runs.test.ts +13 -0
  113. package/test/runtime-identity.test.mjs +18 -0
  114. package/test/schedule-cli.test.ts +34 -5
  115. package/test/scheduled-tasks.test.ts +79 -8
  116. package/test/scheduler.test.ts +30 -1
  117. package/test/task-native.test.ts +5 -2
  118. package/test/update-attention.test.ts +1 -2
  119. package/test/updates.test.mjs +44 -5
  120. package/test/workspace.test.ts +2 -3
  121. package/scripts/smoke-busy-reply.ts +0 -58
  122. package/src/reply-executor.ts +0 -55
  123. package/src/reply-mcp.ts +0 -23
  124. package/templates/agent/TOOLS.md +0 -105
  125. package/templates/chat-guidance.md +0 -23
  126. package/templates/standalone-tools.md +0 -20
  127. package/templates/updates.md +0 -45
  128. 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.match(run.texts[0], /Reply privately/)
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('four-item menu is owner-only; available AI choices work and forged/stale buttons cannot change settings', async () => {
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', 'Settings'])
215
- await f.relay.bot.handleUpdate(message(2, '/ai'))
216
- const pick = f.keyboards.at(-1)!.flat()[0].callback_data
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 f.relay.bot.handleUpdate(callback(3, pick, 202))
219
- assert.equal(await store.getActiveSession(), null)
220
- await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
221
- assert.equal(await store.getActiveSession(), null)
222
- await f.relay.bot.handleUpdate(callback(5, pick))
223
- if (!(await store.getActiveSession())) {
224
- let useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')?.callback_data
225
- if (!useNow) {
226
- await f.relay.bot.handleUpdate(callback(6, f.keyboards.at(-1)!.flat()[0].callback_data))
227
- useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')!.callback_data
228
- }
229
- await f.relay.bot.handleUpdate(callback(7, useNow))
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 f.relay.bot.handleUpdate(message(9, '/settings'))
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('all non-Luna model selections and launches reject effort above high before spawning', async () => {
15
- for (const cli of ['codex', 'codex-gui', 'grok', 'claude', 'opencode', 'agy']) {
16
- for (const effort of ['xhigh', 'max', 'ultra', 'unknown']) {
17
- const preset = { id:'blocked', name:'Blocked', cli, model:'any-model', effort }
18
- await assert.rejects(validateSelection(preset, [], async () => true), /capped at high/)
19
- await assert.rejects(startExecutorJob([], { cli, effort, runId:'unused', controlDir:'/unused', workspace:'/unused', binDir:'/unused', timeoutMs:1 }), /capped at high/)
20
- }
21
- }
22
- await assert.rejects(runCodexSession({workspace:'/unused',controlDir:'/unused',prompt:'',goal:false,effort:'max'}), /capped at high/)
23
- await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
24
- })
25
-
26
- test('Codex Luna accepts xhigh and max while every other model and CLI remains capped', async () => {
27
- for (const effort of ['xhigh', 'max']) {
28
- const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort }
29
- await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh','max'] }], async () => true)
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('restricted tasks pin Luna max, preserve explicit choices and reject higher effort', () => {
37
- const args = taskArguments('/unused', ['broker'], 'prompt')
38
- assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-luna')
39
- assert.ok(args.includes('model_reasoning_effort="max"'))
40
- const custom = taskArguments('/unused', ['broker'], 'prompt', undefined, {model:'custom-model',effort:'low'})
41
- assert.equal(custom[custom.indexOf('--model')+1], 'custom-model')
42
- assert.ok(custom.includes('model_reasoning_effort="low"'))
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
+ })