@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.50
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/AGENTS.md +8 -6
- package/CHANGELOG.md +15 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +18 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +55 -18
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +9 -13
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/executor.ts +9 -0
- package/src/host-executor-client.ts +19 -7
- package/src/index.ts +125 -21
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/runs.ts +61 -3
- package/src/schedule-cli.ts +18 -12
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +1 -1
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/supervisor.mjs +1 -1
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +19 -10
- package/test/workspace.test.ts +6 -0
|
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
|
|
7
7
|
import { Scheduler } from '../src/scheduler.js'
|
|
8
8
|
import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-tasks.js'
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import type { Trigger } from '../src/schedule-time.js'
|
|
10
11
|
|
|
11
12
|
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
12
13
|
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex', model: 'fixture-model' } }
|
|
@@ -40,7 +41,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
40
41
|
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
41
42
|
const text = scheduledTasksText(active, owner)
|
|
42
43
|
|
|
43
|
-
assert.equal(text, '📅 Scheduled tasks · 1 active\n\
|
|
44
|
+
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nNext/last times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Schedule · Weekdays at 09:00 · Asia/Dubai\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n Execution · codex (default login) · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
|
|
44
45
|
assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
|
|
45
46
|
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
46
47
|
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
@@ -118,7 +119,7 @@ test('a legacy invalid selection does not prevent the active menu from rendering
|
|
|
118
119
|
await writeFile(file,JSON.stringify(saved))
|
|
119
120
|
|
|
120
121
|
const active = await scheduler.listActiveReadOnly([])
|
|
121
|
-
assert.match(scheduledTasksText(active,owner),/codex · gpt-5.6-terra · max/)
|
|
122
|
+
assert.match(scheduledTasksText(active,owner),/codex \(default login\) · gpt-5.6-terra · max/)
|
|
122
123
|
})
|
|
123
124
|
|
|
124
125
|
test('detail view truncates long instructions and tolerates invalid timestamps', async t => {
|
|
@@ -135,3 +136,24 @@ test('detail view truncates long instructions and tolerates invalid timestamps',
|
|
|
135
136
|
const broken = {...active.find(schedule => schedule.id === saved.id)!,nextAt:'not-a-date',lastRun:{status:'completed',at:'also-bad',currentRevision:true}} as never
|
|
136
137
|
assert.doesNotMatch(scheduledTaskDetailText(broken,1,1),/NaN/)
|
|
137
138
|
})
|
|
139
|
+
|
|
140
|
+
test('frequency describes saved rules without inventing a uniform interval for arbitrary cron', async t => {
|
|
141
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-frequency-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
142
|
+
const scheduler=new Scheduler(dir),start='2027-01-01T00:00:00Z',timezone='America/New_York'
|
|
143
|
+
const cases:[Trigger,string][]=[
|
|
144
|
+
[{at:start},'Once'],[{everySeconds:60,start},'Every minute'],[{everySeconds:90,start},'Every 90 seconds'],
|
|
145
|
+
[{everySeconds:3600,start},'Every hour'],[{everySeconds:172800,start},'Every 2 days'],
|
|
146
|
+
[{cron:'*/15 * * * *',timezone,start},'Every 15 minutes · America/New_York'],
|
|
147
|
+
[{cron:'*/30 * * * *',timezone,start},'Every 30 minutes · America/New_York'],
|
|
148
|
+
[{cron:'15 * * * *',timezone,start},'Hourly at :15 · America/New_York'],
|
|
149
|
+
[{cron:'30 9 * * *',timezone,start},'Daily at 09:30 · America/New_York'],
|
|
150
|
+
[{cron:'*/7 * * * *',timezone,start},'Cron */7 * * * * · America/New_York'],
|
|
151
|
+
[{cron:'0 9 1 * 1',timezone,start},'Cron 0 9 1 * 1 · America/New_York'],
|
|
152
|
+
]
|
|
153
|
+
for(const [i,[trigger,label]] of cases.entries()){
|
|
154
|
+
const saved=await scheduler.save({id:`t-${i}`,name:`Task ${i}`,text:'Inspect saved work.',owner,execution,enabled:true,trigger})
|
|
155
|
+
const active=(await scheduler.listActiveReadOnly([])).find(s=>s.id===saved.id)!
|
|
156
|
+
assert.ok(scheduledTasksText([active],owner).includes(`Schedule · ${label}\n`))
|
|
157
|
+
assert.ok(scheduledTaskDetailText(active,1).includes(`Schedule\n${label}\n`))
|
|
158
|
+
}
|
|
159
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { queueSessionClose } from '../src/session-close.js'
|
|
4
|
+
import type { RunStore } from '../src/runs.js'
|
|
5
|
+
import type { Owner, SessionState } from '../src/control-state.js'
|
|
6
|
+
|
|
7
|
+
test('a private application scope never gets an owner-authority close turn', async () => {
|
|
8
|
+
const created: unknown[] = []
|
|
9
|
+
const runs = { create: async (input: unknown) => { created.push(input); return input } } as unknown as RunStore
|
|
10
|
+
const owner = { id: 'owner', telegramUserId: 42, pairedAt: new Date(0).toISOString() } as Owner
|
|
11
|
+
const session: SessionState = { sessionId: 'scoped', hasStarted: true, preset: { id: 'p', cli: 'codex', model: 'm' } as SessionState['preset'], applicationScope: 'a'.repeat(64) }
|
|
12
|
+
assert.equal(await queueSessionClose(runs, owner, session), undefined)
|
|
13
|
+
assert.equal(created.length, 0)
|
|
14
|
+
const { applicationScope: _, ...ownerSession } = session
|
|
15
|
+
assert.ok(await queueSessionClose(runs, owner, ownerSession))
|
|
16
|
+
assert.equal(created.length, 1)
|
|
17
|
+
})
|
|
@@ -10,7 +10,7 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
10
10
|
import { RunStore, activeSetup } from '../src/runs.js'
|
|
11
11
|
import { Scheduler } from '../src/scheduler.js'
|
|
12
12
|
import { CliUnavailableError, EXECUTOR_REGISTRY, type ExecutorOptions } from '../src/executor.js'
|
|
13
|
-
import { codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
|
|
13
|
+
import { attemptFailure, codexRejection, nextSetup, quotaScope, startupObserver, startupLine } from '../src/setup-fallback.js'
|
|
14
14
|
import type { AiPreset } from '../src/ai.js'
|
|
15
15
|
|
|
16
16
|
const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<250;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
@@ -22,7 +22,7 @@ const observe=(...lines:unknown[])=>{const o=startupObserver();o.write(lines.map
|
|
|
22
22
|
test('startup observer advances only on typed rejection before any work',()=>{
|
|
23
23
|
assert.deepEqual(observe({type:'system',subtype:'init'},{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791460800}},{type:'assistant',error:'rate_limit',message:{content:[]}},{type:'result',is_error:true}),
|
|
24
24
|
{workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T12:00:00.000Z'}})
|
|
25
|
-
assert.equal(observe({type:'assistant',error:'authentication_failed'}).rejection?.category,'
|
|
25
|
+
assert.equal(observe({type:'assistant',error:'authentication_failed'}).rejection?.category,'login-unavailable')
|
|
26
26
|
assert.equal(observe({type:'assistant',error:'model_not_found'}).rejection?.category,'provider-rejected')
|
|
27
27
|
// Real output, unknown errors and unparseable lines all count as work.
|
|
28
28
|
assert.equal(observe({type:'assistant',message:{content:[{type:'tool_use'}]}},{type:'assistant',error:'rate_limit'}).workBegan,true)
|
|
@@ -35,6 +35,35 @@ test('startup observer advances only on typed rejection before any work',()=>{
|
|
|
35
35
|
assert.equal(observe(startupLine({workBegan:true}),startupLine({workBegan:false,rejection:{category:'quota'}})).workBegan,true)
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
+
// Claude 2.1.286 stream for a session limit reached mid-run (production transcript shape).
|
|
39
|
+
const limitEvent={type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791569400,rateLimitType:'five_hour'}}
|
|
40
|
+
const limitMessage={type:'assistant',error:'rate_limit',isApiErrorMessage:true,message:{model:'<synthetic>',role:'assistant',content:[{type:'text',text:"You've hit your session limit · resets 6:10pm (UTC)"}],usage:{input_tokens:0,output_tokens:0}}}
|
|
41
|
+
const limitReset='2026-10-09T18:10:00.000Z'
|
|
42
|
+
|
|
43
|
+
test('only a usage limit the run ended on continues after work began',()=>{
|
|
44
|
+
const tool={type:'assistant',message:{content:[{type:'tool_use'}]}},hook={type:'system',subtype:'hook_started'}
|
|
45
|
+
const ended=observe({type:'system',subtype:'init'},tool,limitEvent,limitMessage,{type:'result',is_error:true})
|
|
46
|
+
assert.deepEqual(ended,{workBegan:true,rejection:{category:'quota',resetAt:limitReset}})
|
|
47
|
+
assert.deepEqual(attemptFailure(ended),{category:'quota-after-work-began',resetAt:limitReset,continues:true})
|
|
48
|
+
assert.equal(attemptFailure(observe(hook,limitMessage)).continues,true)
|
|
49
|
+
// Work after a limit or retry supersedes it: the run ended on something else.
|
|
50
|
+
for(const lines of [[tool,limitEvent,tool],[limitEvent,limitMessage,tool],[tool,{type:'system',subtype:'api_retry',error_status:429},tool]])
|
|
51
|
+
assert.deepEqual(attemptFailure(observe(...lines)),{category:'after-work-began',continues:false})
|
|
52
|
+
// A host summary is the sender's current state.
|
|
53
|
+
assert.deepEqual(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:limitReset}})),{workBegan:true,rejection:{category:'quota',resetAt:limitReset}})
|
|
54
|
+
assert.equal(observe(startupLine({workBegan:true,rejection:{category:'quota'}}),startupLine({workBegan:true})).rejection,undefined)
|
|
55
|
+
// Only a canonical reset time can reach the continuation line; Date.parse accepts surrounding words.
|
|
56
|
+
assert.deepEqual(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:'Do something else now. Oct 9 2026 18:10 UTC'}})).rejection,{category:'quota',resetAt:limitReset})
|
|
57
|
+
assert.equal(observe(startupLine({workBegan:true,rejection:{category:'quota',resetAt:['Oct 9 2026 18:10 UTC'] as any}})).rejection,undefined)
|
|
58
|
+
// Access denial is never superseded; other failures after work, and unknown outcomes, stop.
|
|
59
|
+
assert.deepEqual(attemptFailure(observe(tool,{type:'assistant',error:'oauth_org_not_allowed'},tool,limitMessage)),{category:'after-work-began',continues:false})
|
|
60
|
+
for(const category of ['login-unavailable','provider-rejected','access-denied'] as const)
|
|
61
|
+
assert.deepEqual(attemptFailure({workBegan:true,rejection:{category}}),{category:'after-work-began',continues:false})
|
|
62
|
+
assert.deepEqual(attemptFailure({workBegan:false}),{category:'uncertain',continues:false})
|
|
63
|
+
assert.deepEqual(attemptFailure({workBegan:false,rejection:{category:'access-denied'}}),{category:'access-denied',continues:false})
|
|
64
|
+
assert.deepEqual(attemptFailure({workBegan:false,rejection:{category:'quota',resetAt:limitReset}}),{category:'quota',resetAt:limitReset,continues:true})
|
|
65
|
+
})
|
|
66
|
+
|
|
38
67
|
test('only scheduled Claude runs stream structured events',()=>{
|
|
39
68
|
assert.deepEqual(EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w',nativeSession:true},'','').slice(-3),['--output-format','stream-json','--verbose'])
|
|
40
69
|
assert.ok(!EXECUTOR_REGISTRY.claude.buildArgs({workspace:'/w'},'','').includes('stream-json'))
|
|
@@ -42,20 +71,26 @@ test('only scheduled Claude runs stream structured events',()=>{
|
|
|
42
71
|
|
|
43
72
|
test('codex errors map to categories; policy and context errors never advance',()=>{
|
|
44
73
|
assert.equal(codexRejection('usageLimitExceeded'),'quota')
|
|
45
|
-
assert.equal(codexRejection('unauthorized'),'
|
|
74
|
+
assert.equal(codexRejection('unauthorized'),'login-unavailable')
|
|
46
75
|
assert.equal(codexRejection('serverOverloaded'),'provider-rejected')
|
|
47
76
|
assert.equal(codexRejection({responseTooManyFailedAttempts:{httpStatusCode:429}}),'quota')
|
|
48
77
|
assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:403}}),'access-denied')
|
|
78
|
+
assert.equal(codexRejection({httpConnectionFailed:{httpStatusCode:401}}),'login-unavailable')
|
|
49
79
|
for(const info of ['cyberPolicy','contextWindowExceeded','other',{httpConnectionFailed:{httpStatusCode:null}},undefined]) assert.equal(codexRejection(info),undefined)
|
|
50
80
|
})
|
|
51
81
|
|
|
52
|
-
test('a quota rejection skips
|
|
82
|
+
test('a quota rejection skips models in the same login scope; named accounts have separate scopes',()=>{
|
|
53
83
|
assert.equal(quotaScope(claudeA),quotaScope(claudeB))
|
|
54
84
|
assert.equal(quotaScope({...codexA,provider:'bridge'}),quotaScope(codexB))
|
|
85
|
+
assert.equal(quotaScope({...claudeA,authProfile:'work'}),quotaScope({...claudeB,authProfile:'work'}))
|
|
86
|
+
assert.notEqual(quotaScope(claudeA),quotaScope({...claudeA,authProfile:'work'}))
|
|
87
|
+
assert.notEqual(quotaScope({...codexA,authProfile:'work'}),quotaScope({...codexB,authProfile:'personal'}))
|
|
55
88
|
assert.equal(quotaScope({id:'p',name:'p',cli:'pi',model:'opencode-go/m'}),quotaScope({id:'o',name:'o',cli:'opencode',model:'opencode-go/x'}))
|
|
56
89
|
const first={preset:claudeA,quotaScope:'claude',outcome:'failed' as const,category:'quota' as const}
|
|
57
90
|
const next=nextSetup([claudeA,claudeB,codexA,codexB],[first])
|
|
58
91
|
assert.equal(next.preset,codexA);assert.deepEqual(next.skipped.map(s=>[s.preset.id,s.outcome,s.category]),[['claude-b','skipped','same-quota-scope']])
|
|
92
|
+
// A limit reached after work began exhausts the same scope.
|
|
93
|
+
assert.deepEqual(nextSetup([claudeA,claudeB,codexA],[{...first,category:'quota-after-work-began'}]).skipped.map(s=>s.category),['same-quota-scope'])
|
|
59
94
|
// A non-quota rejection (model unavailable) leaves the scope eligible.
|
|
60
95
|
assert.equal(nextSetup([claudeA,claudeB],[{...first,category:'provider-rejected'}]).preset,claudeB)
|
|
61
96
|
})
|
|
@@ -63,10 +98,10 @@ test('a quota rejection skips later setups in the same scope, even with differen
|
|
|
63
98
|
type Script={lines?:unknown[];code?:number;unavailable?:boolean;hold?:boolean}
|
|
64
99
|
const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codexB],primary=claudeA)=>{
|
|
65
100
|
const dir=await mkdtemp(join(tmpdir(),`ez-fallback-${name}-`)),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
66
|
-
const launches:ExecutorOptions[]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
|
|
67
|
-
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(
|
|
101
|
+
const launches:ExecutorOptions[]=[],prompts:string[][]=[],admitted:(AiPreset|undefined)[]=[],sent:string[]=[]
|
|
102
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(texts,options)=>{
|
|
68
103
|
const script=scripts[launches.length] ?? {code:0}
|
|
69
|
-
launches.push(options);admitted.push(activeSetup((await runs.get(options.runId))!))
|
|
104
|
+
launches.push(options);prompts.push(texts);admitted.push(activeSetup((await runs.get(options.runId))!))
|
|
70
105
|
if(script.unavailable)throw new CliUnavailableError(`Native CLI ${options.cli} is not executable on the host PATH`)
|
|
71
106
|
const out=(script.lines ?? []).map(l=>typeof l==='string'?l:JSON.stringify(l)).join('\n')
|
|
72
107
|
const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(out ? out+'\n' : '')});setTimeout(()=>process.exit(${script.code ?? 0}),${script.hold ? 60000 : 10})`],{detached:process.platform!=='win32'})
|
|
@@ -78,7 +113,7 @@ const fixture=async(name:string,scripts:Script[],fallbacks=[claudeB,codexA,codex
|
|
|
78
113
|
const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:'00000000-0000-4000-8000-000000000000',preset:primary,fallbacks},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
|
|
79
114
|
const run=await scheduler.trigger(saved.id,saved.revision,'fallback-'+name,owner,runs)
|
|
80
115
|
await relay.drainSources()
|
|
81
|
-
return {dir,runs,scheduler,relay,run,launches,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
|
|
116
|
+
return {dir,runs,scheduler,relay,run,launches,prompts,admitted,sent,done:async()=>{await until(async()=>['completed','failed','cancelled'].includes((await runs.get(run.id))!.status));return (await runs.get(run.id))!},
|
|
82
117
|
close:async()=>{await relay.stop();await rm(dir,{recursive:true,force:true})}}
|
|
83
118
|
}
|
|
84
119
|
const quota={type:'assistant',error:'rate_limit'},work={type:'assistant',message:{content:[{type:'text',text:'working'}]}}
|
|
@@ -97,8 +132,9 @@ test('a quota rejection before work advances within the same occurrence, skippin
|
|
|
97
132
|
}finally{await f.close()}
|
|
98
133
|
})
|
|
99
134
|
|
|
100
|
-
test('failure after work began, access denial and unknown failures stop without replay',async()=>{
|
|
101
|
-
for(const [name,lines,category] of [['partial',[work,
|
|
135
|
+
test('non-quota failure after work began, access denial and unknown failures stop without replay',async()=>{
|
|
136
|
+
for(const [name,lines,category] of [['partial',[work,{type:'assistant',error:'overloaded'}],'after-work-began'],['recovered',[work,limitEvent,quota,work],'after-work-began'],
|
|
137
|
+
['denied',[{type:'assistant',error:'oauth_org_not_allowed'},quota],'access-denied'],['denied-after-work',[work,{type:'assistant',error:'oauth_org_not_allowed'},quota],'after-work-began'],['silent',[],'uncertain']] as const){
|
|
102
138
|
const f=await fixture(name,[{lines:[...lines],code:1}])
|
|
103
139
|
try{
|
|
104
140
|
const run=await f.done()
|
|
@@ -108,6 +144,46 @@ test('failure after work began, access denial and unknown failures stop without
|
|
|
108
144
|
}
|
|
109
145
|
})
|
|
110
146
|
|
|
147
|
+
const revoked={type:'system',subtype:'api_error',error:{status:401,message:'OAuth access token has been revoked.'}}
|
|
148
|
+
const retry={type:'system',subtype:'api_retry',error_status:401,error:'authentication_failed',attempt:1,max_retries:10,retry_delay_ms:624}
|
|
149
|
+
|
|
150
|
+
test('revoked-login metadata followed by a quota rejection does not mark work as started',()=>{
|
|
151
|
+
assert.deepEqual(observe({type:'system',subtype:'init'},revoked,retry,
|
|
152
|
+
{type:'rate_limit_event',rate_limit_info:{status:'rejected',resetsAt:1791475200}},
|
|
153
|
+
{...quota,isApiErrorMessage:true,message:{model:'<synthetic>',content:[{type:'text',text:'session limit'}],usage:{input_tokens:0,output_tokens:0}}}),
|
|
154
|
+
{workBegan:false,rejection:{category:'quota',resetAt:'2026-10-08T16:00:00.000Z'}})
|
|
155
|
+
assert.deepEqual(observe(revoked),{workBegan:false,rejection:{category:'login-unavailable'}})
|
|
156
|
+
assert.deepEqual(observe(retry),{workBegan:false,rejection:{category:'login-unavailable'}})
|
|
157
|
+
assert.equal(observe(work,revoked).workBegan,true)
|
|
158
|
+
assert.equal(observe(work,retry).workBegan,true)
|
|
159
|
+
assert.equal(observe({type:'system',subtype:'api_error',error:{status:403}},quota).rejection?.category,'access-denied')
|
|
160
|
+
assert.equal(observe({type:'system',subtype:'api_error',error:{status:null}},quota).workBegan,true)
|
|
161
|
+
assert.equal(observe({...retry,error_status:null},quota).workBegan,true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('unavailable login advances to a saved account and the next occurrence uses the unchanged chain',async()=>{
|
|
165
|
+
const profiles=['claude2','claude3','claude4']
|
|
166
|
+
const fallbacks=[claudeB,...profiles.map((authProfile,i)=>({...claudeA,id:`profile-${i}`,authProfile,effort:'high'}))]
|
|
167
|
+
const f=await fixture('login-recovery',[{lines:[revoked,retry],code:1},{lines:[work],code:0},
|
|
168
|
+
{lines:[{type:'assistant',error:'authentication_failed'}],code:1},{lines:[work],code:0}],fallbacks,{...claudeA,effort:'high'})
|
|
169
|
+
try{
|
|
170
|
+
const first=await f.done()
|
|
171
|
+
assert.equal(first.status,'completed')
|
|
172
|
+
assert.deepEqual(first.attempts!.map(a=>[a.quotaScope,a.category]),[
|
|
173
|
+
['claude','login-unavailable'],['claude','same-login-scope'],['claude@claude2',undefined]])
|
|
174
|
+
const saved=await f.scheduler.get('daily')
|
|
175
|
+
assert.deepEqual(saved!.execution!.fallbacks,fallbacks)
|
|
176
|
+
const second=await f.scheduler.trigger(saved!.id,saved!.revision,'next-occurrence',saved!.owner,f.runs)
|
|
177
|
+
await f.relay.drainSources()
|
|
178
|
+
await until(async()=>['completed','failed'].includes((await f.runs.get(second.id))!.status))
|
|
179
|
+
assert.equal((await f.runs.get(second.id))!.status,'completed')
|
|
180
|
+
assert.notEqual(first.id,second.id)
|
|
181
|
+
assert.deepEqual(f.launches.map(l=>[l.authProfile,l.model,l.effort]),[
|
|
182
|
+
[undefined,'opus','high'],['claude2','opus','high'],[undefined,'opus','high'],['claude2','opus','high']])
|
|
183
|
+
assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,4)
|
|
184
|
+
}finally{await f.close()}
|
|
185
|
+
})
|
|
186
|
+
|
|
111
187
|
test('each scheduled attempt launches with its own captured auth profile',async()=>{
|
|
112
188
|
const primary={...claudeA,authProfile:'work'}
|
|
113
189
|
const f=await fixture('profile',[{lines:[{type:'assistant',error:'model_not_found'}],code:1},{lines:[work],code:0}],[claudeB],primary)
|
|
@@ -117,6 +193,74 @@ test('each scheduled attempt launches with its own captured auth profile',async(
|
|
|
117
193
|
}finally{await f.close()}
|
|
118
194
|
})
|
|
119
195
|
|
|
196
|
+
test('four Claude accounts advance in order on quota rejection before work',async()=>{
|
|
197
|
+
const profiles=['claude2','claude3','claude4']
|
|
198
|
+
const fallbacks=profiles.map((authProfile,i)=>({...claudeA,id:`fallback-${i}`,authProfile,effort:'high'}))
|
|
199
|
+
const f=await fixture('four-accounts',[{lines:[quota],code:1},{lines:[quota],code:1},{lines:[quota],code:1},{lines:[work],code:0}],fallbacks,{...claudeA,effort:'high'})
|
|
200
|
+
try{
|
|
201
|
+
const run=await f.done()
|
|
202
|
+
assert.equal(run.status,'completed')
|
|
203
|
+
assert.deepEqual(f.launches.map(l=>[l.cli,l.authProfile,l.model,l.effort]),
|
|
204
|
+
[undefined,...profiles].map(profile=>['claude',profile,'opus','high']))
|
|
205
|
+
assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,4)
|
|
206
|
+
assert.ok(f.launches.every(l=>l.runId===run.id))
|
|
207
|
+
assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.outcome]),[
|
|
208
|
+
['claude','failed'],['claude@claude2','failed'],['claude@claude3','failed'],['claude@claude4','completed']])
|
|
209
|
+
}finally{await f.close()}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('arbitrary mixed setup lists preserve CLI, provider, model and effort per attempt',async()=>{
|
|
213
|
+
const candidates:AiPreset[]=[claudeA,
|
|
214
|
+
{...codexA,authProfile:'work',effort:'xhigh'},
|
|
215
|
+
{...setup('desktop','codex-gui','gpt-6-sol'),effort:'high'},
|
|
216
|
+
{...setup('open','opencode','anthropic/claude-sonnet'),provider:'anthropic',effort:'high'},
|
|
217
|
+
{...setup('pi','pi','openai/gpt-6-sol'),effort:'medium'},
|
|
218
|
+
{...setup('grok','grok','grok-4'),effort:'low'}]
|
|
219
|
+
const f=await fixture('mixed',candidates.map((_,i)=>i<candidates.length-1?{unavailable:true}:{code:0}),candidates.slice(1),candidates[0])
|
|
220
|
+
try{
|
|
221
|
+
const run=await f.done()
|
|
222
|
+
assert.equal(run.status,'completed')
|
|
223
|
+
assert.deepEqual(f.launches.map(l=>[l.cli,l.authProfile,l.provider,l.model,l.effort]),
|
|
224
|
+
candidates.map(p=>[p.cli,p.authProfile,p.provider,p.model,p.effort]))
|
|
225
|
+
assert.deepEqual(run.attempts!.map(a=>a.preset),candidates)
|
|
226
|
+
assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,candidates.length)
|
|
227
|
+
}finally{await f.close()}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('a usage limit after work began continues the same run on the next login, told so',async()=>{
|
|
231
|
+
const opus={...claudeA,effort:'xhigh'},profiles=['claude2','claude3','claude4'].map((authProfile,i)=>({...opus,id:`fallback-${i}`,authProfile}))
|
|
232
|
+
for(const event of [work,{type:'system',subtype:'hook_started'}]){
|
|
233
|
+
// Default login stops mid-run, claude2 is already exhausted before work, claude3 completes.
|
|
234
|
+
const f=await fixture('mid-run-limit',[{lines:[{type:'system',subtype:'init'},event,limitEvent,limitMessage,{type:'result',is_error:true}],code:1},
|
|
235
|
+
{lines:[limitEvent,limitMessage],code:1},{lines:[work],code:0}],profiles,opus)
|
|
236
|
+
try{
|
|
237
|
+
const run=await f.done()
|
|
238
|
+
assert.equal(run.status,'completed');assert.equal(run.id,f.run.id)
|
|
239
|
+
assert.deepEqual(f.launches.map(l=>[l.authProfile,l.model,l.effort,l.runId]),[[undefined,'opus','xhigh',run.id],['claude2','opus','xhigh',run.id],['claude3','opus','xhigh',run.id]])
|
|
240
|
+
assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.outcome,a.category,a.resetAt,a.sessionId]),[
|
|
241
|
+
['claude','failed','quota-after-work-began',limitReset,f.launches[0].sessionId],
|
|
242
|
+
['claude@claude2','failed','quota',limitReset,f.launches[1].sessionId],
|
|
243
|
+
['claude@claude3','completed',undefined,undefined,f.launches[2].sessionId]])
|
|
244
|
+
assert.equal(new Set(f.launches.map(l=>l.sessionId)).size,3)
|
|
245
|
+
// The first launch is the saved prompt; each continuation adds only the transport fact.
|
|
246
|
+
const fact=`[run ${run.id} continues: claude · opus · xhigh began work in native session ${f.launches[0].sessionId} and stopped at its usage limit (resets ${limitReset}). That work may be partly applied.]`
|
|
247
|
+
assert.deepEqual(f.prompts,[f.prompts[0],[...f.prompts[0],fact],[...f.prompts[0],fact]])
|
|
248
|
+
assert.ok(!f.prompts[0].some(text=>text.includes('continues')))
|
|
249
|
+
assert.equal((await f.runs.list()).filter(r=>r.scheduled?.id==='daily').length,1)
|
|
250
|
+
}finally{await f.close()}
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('a run that reaches the limit on every login fails with each setup recorded',async()=>{
|
|
255
|
+
const f=await fixture('limit-everywhere',[{lines:[work,limitEvent,limitMessage],code:1},{lines:[limitEvent,limitMessage],code:1}],[{...claudeA,authProfile:'claude2'}])
|
|
256
|
+
try{
|
|
257
|
+
const run=await f.done()
|
|
258
|
+
assert.equal(run.status,'failed');assert.equal(run.failureReason,'setups-unavailable');assert.equal(f.launches.length,2)
|
|
259
|
+
assert.deepEqual(run.attempts!.map(a=>[a.quotaScope,a.category]),[['claude','quota-after-work-began'],['claude@claude2','quota']])
|
|
260
|
+
assert.match(run.failure!.error,/quota-after-work-began, resets 2026-10-09T18:10:00\.000Z.*quota, resets/)
|
|
261
|
+
}finally{await f.close()}
|
|
262
|
+
})
|
|
263
|
+
|
|
120
264
|
test('cancellation stops the chain',async()=>{
|
|
121
265
|
const f=await fixture('cancel',[{lines:[quota],hold:true}])
|
|
122
266
|
try{
|
|
@@ -140,14 +284,14 @@ test('when every setup is unavailable the run reports each setup clearly',async(
|
|
|
140
284
|
}finally{await f.close()}
|
|
141
285
|
})
|
|
142
286
|
|
|
143
|
-
test('schedules reject duplicate or
|
|
287
|
+
test('schedules reject duplicate or invalid setups and the socket cannot rewrite attempts',async()=>{
|
|
144
288
|
const dir=await mkdtemp(join(tmpdir(),'ez-fallback-validate-')),control=new ControlStore(dir,1000),scheduler=new Scheduler(dir)
|
|
145
289
|
try{
|
|
146
290
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
147
291
|
const owner=(await control.status()).owner!,base={id:'t',name:'T',text:'Work',owner,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}}
|
|
148
292
|
const execution=(fallbacks:AiPreset[])=>({sessionId:'00000000-0000-4000-8000-000000000000',preset:claudeA,fallbacks})
|
|
149
293
|
await assert.rejects(scheduler.save({...base,execution:execution([{...claudeA,id:'copy'}])}),/distinct/)
|
|
150
|
-
await assert.rejects(scheduler.save({...base,execution:execution([claudeB,
|
|
294
|
+
await assert.rejects(scheduler.save({...base,execution:execution([{...claudeB,cli:'unknown'}])}),/AI selection/)
|
|
151
295
|
await assert.rejects(scheduler.save({...base,execution:execution([{...codexA,model:undefined}])}),/explicit model/)
|
|
152
296
|
const forged=[{preset:setup('forged','claude','opus-max'),quotaScope:'claude',outcome:'running' as const}]
|
|
153
297
|
assert.equal(activeSetup({execution:execution([claudeB]),attempts:forged}),undefined)
|
|
@@ -177,7 +321,7 @@ test('host transport admits only the active task setup and profile, forwards onl
|
|
|
177
321
|
await runs.patch(id,{status:'running',attempts:[{preset:primary,quotaScope:'claude',outcome:'running'}]})
|
|
178
322
|
server=serveHostExecutor({cli:'claude',agents:[{name:'t',workspace,controlDir,binDir:join(root,'bin')}]},abort.signal,async(_texts,options)=>{
|
|
179
323
|
launched.push({cli:options.cli,model:options.model,authProfile:options.authProfile})
|
|
180
|
-
const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({
|
|
324
|
+
const lines=options.model==='opus'?[JSON.stringify({type:'system',subtype:'init'}),JSON.stringify({...revoked,error:{...revoked.error,message:'secret provider diagnostic'}}),JSON.stringify({...retry,error:'secret provider diagnostic'})].join('\n')+'\n':''
|
|
181
325
|
const child=spawn(process.execPath,['-e',`process.stdout.write(${JSON.stringify(lines)});process.exit(1)`])
|
|
182
326
|
return {child,cleanup:async()=>{},stdout:''}
|
|
183
327
|
},async()=>[{cli:'claude',authProfile:'work',model:'opus',name:'Work',efforts:[]}])
|
|
@@ -188,11 +332,11 @@ test('host transport admits only the active task setup and profile, forwards onl
|
|
|
188
332
|
const first=await events()
|
|
189
333
|
const summaries=first.trim().split('\n').map(l=>JSON.parse(l)).filter(e=>e.stream==='stdout').flatMap(e=>e.text.trim().split('\n').map((l:string)=>JSON.parse(l)))
|
|
190
334
|
assert.ok(summaries.every((e:{type:string})=>e.type==='ez.startup'))
|
|
191
|
-
assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'
|
|
192
|
-
assert.doesNotMatch(first,/secret
|
|
335
|
+
assert.deepEqual(summaries.at(-1),{type:'ez.startup',workBegan:false,rejection:{category:'login-unavailable'}})
|
|
336
|
+
assert.doesNotMatch(first,/secret provider diagnostic/)
|
|
193
337
|
const {rm:remove}=await import('node:fs/promises');await remove(join(directory,id+'.events'))
|
|
194
338
|
// The relay advances: the next attempt reuses the run ID.
|
|
195
|
-
await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'
|
|
339
|
+
await runs.patch(id,{attempts:[{preset:primary,quotaScope:'claude',outcome:'failed',category:'login-unavailable'},{preset:claudeB,quotaScope:'claude',outcome:'running'}]})
|
|
196
340
|
await request({cli:'claude',model:'sonnet'})
|
|
197
341
|
await events();await remove(join(directory,id+'.events'))
|
|
198
342
|
assert.deepEqual(launched.map(l=>[l.cli,l.model,l.authProfile]),[['claude','opus','work'],['claude','sonnet',undefined]])
|
|
@@ -243,3 +387,37 @@ test('schedule CLI saves ordered fallbacks, preserves them on edit and clears th
|
|
|
243
387
|
await assert.rejects(exec(process.execPath,[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback','cli=codex'],{env}),/cli=EXECUTOR,model=MODEL/)
|
|
244
388
|
await assert.rejects(exec(process.execPath,[bin,'create','dup','--cli','claude','--model','opus',...when,'--fallback','cli=claude,model=opus'],{env}),/distinct/)
|
|
245
389
|
})
|
|
390
|
+
|
|
391
|
+
test('schedule CLI saves arbitrary profile/model/effort combinations and validates every profile',async t=>{
|
|
392
|
+
const {execFile}=await import('node:child_process'),{promisify}=await import('node:util'),{fileURLToPath}=await import('node:url')
|
|
393
|
+
const {mkdir}=await import('node:fs/promises'),{serveTestLedger}=await import('./helpers/ledger.js')
|
|
394
|
+
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
395
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-fallback-profiles-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
396
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
397
|
+
const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
398
|
+
for(const profile of ['claude2','claude3','claude4'])await mkdir(join(dir,'cli','profiles',profile,'claude'),{recursive:true})
|
|
399
|
+
await mkdir(join(dir,'cli','profiles','work','codex'),{recursive:true})
|
|
400
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
|
|
401
|
+
const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
402
|
+
const when=['--at','2027-09-09T09:00:00+04:00','--text','Work']
|
|
403
|
+
const tuples=[
|
|
404
|
+
['claude','claude2',undefined,'opus','high'],['claude','claude3',undefined,'sonnet','medium'],
|
|
405
|
+
['claude','claude4',undefined,'opus','xhigh'],['codex','work',undefined,'gpt-6-sol','xhigh'],
|
|
406
|
+
['codex-gui',undefined,undefined,'gpt-6-astra','high'],['opencode',undefined,'anthropic','anthropic/claude-sonnet','high'],
|
|
407
|
+
['pi',undefined,undefined,'openai/gpt-6-sol','medium'],['grok',undefined,undefined,'grok-4','low']]
|
|
408
|
+
const flags=tuples.flatMap(([cli,profile,provider,model,effort])=>['--fallback',
|
|
409
|
+
`cli=${cli},model=${model},effort=${effort}${profile?`,auth-profile=${profile}`:''}${provider?`,provider=${provider}`:''}`])
|
|
410
|
+
const saved=await cli('create','chain','--cli','claude','--model','opus','--effort','high',...when,...flags)
|
|
411
|
+
assert.deepEqual(saved.execution.fallbacks.map((p:AiPreset)=>[p.cli,p.authProfile,p.provider,p.model,p.effort]),tuples)
|
|
412
|
+
assert.deepEqual((await cli('edit','chain',...when)).execution,saved.execution)
|
|
413
|
+
const base=[bin,'create','bad','--cli','claude','--model','opus',...when,'--fallback']
|
|
414
|
+
for(const [spec,error] of [
|
|
415
|
+
['cli=claude,model=opus,auth-profile=missing',/not provisioned/],
|
|
416
|
+
['cli=claude,model=opus,auth-profile=../escape',/Invalid fallback/],
|
|
417
|
+
['cli=grok,model=grok-4,auth-profile=work',/Invalid fallback/],
|
|
418
|
+
['cli=claude,model=opus,effort=not valid',/Invalid reasoning effort/],
|
|
419
|
+
['cli=claude,model=opus,auth-profile=',/Invalid fallback/],
|
|
420
|
+
['cli=unknown,model=fixture',/Unsupported executor/],
|
|
421
|
+
] as const)await assert.rejects(exec(process.execPath,[...base,spec],{env}),error)
|
|
422
|
+
await assert.rejects(exec(process.execPath,[...base,'cli=claude,model=opus,auth-profile=claude2','--fallback','cli=claude,model=opus,auth-profile=claude2'],{env}),/distinct/)
|
|
423
|
+
})
|
package/test/updates.test.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { prepare, submit, command, read, jobPath, eligibility, jobs, cleanupStal
|
|
|
12
12
|
import { perform, environment, packageManager, backupStateDirectory, execute, brokerNeedsRefresh } from '../src/updates/runtime.mjs';
|
|
13
13
|
import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
|
|
14
14
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
15
|
+
import { TOOLS_LINE } from '../src/tools-line.mjs';
|
|
15
16
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
16
17
|
import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
|
|
17
18
|
const exec=promisify(execFile);
|
|
@@ -314,15 +315,23 @@ test('main transaction stages before stopping, pins rollback image, preserves st
|
|
|
314
315
|
const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
|
|
315
316
|
assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
|
|
316
317
|
});
|
|
317
|
-
test('
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
assert.equal((await
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
318
|
+
test('apply queues while a plugin call holds its lease; activation drains it first',async t=>{
|
|
319
|
+
const f=await fixture(t),directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
|
|
320
|
+
await fs.writeFile(path.join(directory,'00000000-0000-4000-8000-000000000002.json'),JSON.stringify({pid:process.pid,container:'live-call'}),{mode:0o600});
|
|
321
|
+
assert.equal((await queued(f)).status,'queued');
|
|
322
|
+
await fs.rm(path.join(directory,'00000000-0000-4000-8000-000000000002.json'));
|
|
323
|
+
});
|
|
324
|
+
test('main activation and rollback retire the tools footer and never write guidance',async t=>{
|
|
325
|
+
const footer='Owner mandate\n\n<!-- ez tools: begin -->\nUse `ez`.\n<!-- ez tools: end -->\n';
|
|
326
|
+
for(const outcome of ['completed','rolled-back']) {
|
|
327
|
+
const f=await fixture(t),instructions=path.join(f.agent.workspace,'AGENTS.md');await fs.writeFile(instructions,footer);
|
|
328
|
+
await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'),f.old);
|
|
329
|
+
const retired=await fs.readFile(instructions,'utf8');
|
|
330
|
+
assert.equal(retired,'Owner mandate\n\n'+TOOLS_LINE+'\n');
|
|
331
|
+
const options=outcome==='rolled-back'?{fail:(_command,args)=>args.includes('up')}:undefined;
|
|
332
|
+
assert.equal((await perform(f.home,await queued(f),runtime(f,options))).status,outcome);
|
|
333
|
+
assert.equal(await fs.readFile(instructions,'utf8'),retired);
|
|
334
|
+
}
|
|
326
335
|
});
|
|
327
336
|
test('latest overrides stale deployment pins and changing latest changes image bindings',async t=>{
|
|
328
337
|
const tags=[],hashes=[];
|
|
@@ -477,7 +486,7 @@ test('bound dispatch follows active package root and retains private scope',asyn
|
|
|
477
486
|
const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
|
|
478
487
|
assert.match(bound.policy,/beta-channel/);
|
|
479
488
|
assert.equal(await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8'),prior);
|
|
480
|
-
assert.
|
|
489
|
+
await assert.rejects(fs.access(path.join(f.agent.workspace,'AGENTS.md')),{code:'ENOENT'}); // binding never seeds guidance
|
|
481
490
|
const config=await read(path.join(f.home,'config.json'));config.packageRoot=f.source;await atomic(path.join(f.home,'config.json'),config);
|
|
482
491
|
// A native launcher from the real package looks up its entry point in the active root.
|
|
483
492
|
await fs.writeFile(path.join(f.source,'bin/ezenciel-agents.mjs'),'#!/usr/bin/env node\nconsole.log(process.env.EZ_DEPLOYMENT_DIR+"|"+process.env.EZ_CONTROL_DIR)',{mode:0o755});
|
package/test/workspace.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import test from 'node:test'
|
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { initializeWorkspace } from '../src/workspace.js'
|
|
9
|
+
import { TOOLS_LINE } from '../src/tools-line.mjs'
|
|
9
10
|
|
|
10
11
|
test('packaged launcher help and invalid arguments never start the relay', () => {
|
|
11
12
|
const bin = fileURLToPath(new URL('../bin/ezenciel-agents.mjs', import.meta.url))
|
|
@@ -42,6 +43,11 @@ test('fresh mind is private; repeat initialization preserves customization and o
|
|
|
42
43
|
await writeFile(purpose, 'Research partner\n')
|
|
43
44
|
assert.deepEqual(await initializeWorkspace(workspace, purpose), ['AGENTS.md'])
|
|
44
45
|
assert.equal((await stat(path.join(workspace, 'AGENTS.md'))).mode & 0o777, 0o600)
|
|
46
|
+
const seed = await readFile(path.join(workspace, 'AGENTS.md'), 'utf8')
|
|
47
|
+
assert.match(seed, /^## Purpose\n\nResearch partner\n\n## Memory\n/)
|
|
48
|
+
assert.match(seed, /`memory\/MEMORY\.md`/)
|
|
49
|
+
assert.ok(seed.endsWith(`\n${TOOLS_LINE}\n`))
|
|
50
|
+
assert.doesNotMatch(seed, /ez tools: begin/)
|
|
45
51
|
assert.ok(!(await readdir(workspace)).includes('SOUL.md'))
|
|
46
52
|
assert.ok(!(await readdir(workspace)).includes('USER.md'))
|
|
47
53
|
assert.ok(!(await readdir(workspace)).includes('MEMORY.md'))
|