@jc_stack/ez-agents 0.1.0-beta.36 → 0.1.0-beta.38
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 +2 -2
- package/CHANGELOG.md +37 -0
- package/Dockerfile +13 -2
- package/README.md +0 -1
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/compose.yaml +1 -0
- package/docs/application-channel.md +9 -2
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/docker-runtime.md +19 -0
- package/docs/upgrades.md +1 -1
- package/package.json +1 -1
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +132 -7
- package/src/application-channel.ts +1 -1
- package/src/config.ts +1 -1
- package/src/control-state.ts +1 -4
- package/src/executor.ts +62 -22
- package/src/host-executor.ts +50 -18
- package/src/index.ts +3 -2
- package/src/menu.ts +2 -2
- package/src/message-history.ts +19 -1
- package/src/message.ts +2 -2
- package/src/plugins/manager.mjs +3 -4
- package/src/setup.ts +3 -2
- package/test/ai.test.ts +185 -1
- package/test/config.test.ts +2 -2
- package/test/conversations.test.ts +2 -8
- package/test/engine-handoff.test.ts +3 -4
- package/test/executor.test.ts +112 -12
- package/test/host-executor.test.ts +168 -12
- package/test/message-history.test.ts +47 -0
- package/test/model-policy.test.ts +2 -2
- package/test/plugin-manager.test.mjs +17 -0
- package/test/setup.test.ts +7 -7
package/src/host-executor.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { isHostRunId } from './host-executor-protocol.js'
|
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
|
|
11
11
|
import { parseIsolationClass, type IsolationClass } from './isolation.js'
|
|
12
|
-
import { readModels, validateSelection } from './ai.js'
|
|
12
|
+
import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
|
|
13
13
|
import type { ChildProcess } from 'node:child_process'
|
|
14
14
|
import { taskWorkspace } from './task-workspace.js'
|
|
15
15
|
import { packageVersion } from './version.js'
|
|
@@ -17,12 +17,30 @@ import { installedPluginVersions } from './software-status.js'
|
|
|
17
17
|
import { processSnapshot } from './process-tree.js'
|
|
18
18
|
|
|
19
19
|
export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
|
|
20
|
-
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[] }
|
|
20
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[]; opencodeProviders?: string[] }
|
|
21
21
|
export type HostInstallation = { cli: string; isolation?: IsolationClass; agents: HostBinding[] }
|
|
22
22
|
|
|
23
|
+
// Periodic refresh body, extracted for testing. Throws on failure; the
|
|
24
|
+
// caller keeps the last-good disk catalog and the host alive.
|
|
25
|
+
export const refreshAgentCatalog = async (agent: HostBinding,
|
|
26
|
+
readFresh: (agent: HostBinding) => Promise<ModelChoice[]>,
|
|
27
|
+
seen: Map<HostBinding, string>): Promise<void> => {
|
|
28
|
+
const catalogModels = await readFresh(agent)
|
|
29
|
+
// Empty discovery is not an authoritative deletion of the installed catalog.
|
|
30
|
+
if (!catalogModels.length) return
|
|
31
|
+
const models = JSON.stringify(catalogModels)
|
|
32
|
+
if (seen.get(agent) === models) return
|
|
33
|
+
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
|
|
34
|
+
const file = path.join(agent.controlDir, 'host-executor/models.json')
|
|
35
|
+
await writeFile(file + '.tmp', models, { mode: 0o600 })
|
|
36
|
+
await rename(file + '.tmp', file)
|
|
37
|
+
seen.set(agent, models)
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
|
|
24
41
|
|
|
25
|
-
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob
|
|
42
|
+
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
|
|
43
|
+
readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>) => {
|
|
26
44
|
if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
|
|
27
45
|
if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
|
|
28
46
|
resolveExecutor(installation.cli)
|
|
@@ -31,11 +49,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
31
49
|
throw new Error('Each agent requires a separate workspace and control directory')
|
|
32
50
|
const active = new Map<string, ChildProcess>()
|
|
33
51
|
const tasks = new Set<Promise<void>>()
|
|
52
|
+
let catalogRefresh: Promise<void> | undefined
|
|
34
53
|
const locks: string[] = []
|
|
35
54
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
36
55
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
37
56
|
const catalog = async (agent: HostBinding) => {
|
|
38
|
-
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex')
|
|
57
|
+
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
58
|
+
undefined, undefined, validateOpencodeProviders(agent.opencodeProviders), agent.controlDir)
|
|
39
59
|
const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
|
|
40
60
|
const declared = providers.flatMap(provider => provider.models.map(model => {
|
|
41
61
|
const native = discovered.find(candidate => candidate.cli === 'codex' && !candidate.provider && candidate.model === model)
|
|
@@ -46,11 +66,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
46
66
|
model.model !== undefined && declaredModels.has(model.model) && model.cli === 'codex'
|
|
47
67
|
return [...discovered.filter(model => !isReplacedByProvider(model)), ...declared]
|
|
48
68
|
}
|
|
69
|
+
const freshCatalog = readCatalog ?? catalog
|
|
70
|
+
const beat = async (agent: HostBinding) => {
|
|
71
|
+
const directory = path.join(agent.controlDir, 'host-executor')
|
|
72
|
+
await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
|
|
73
|
+
await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
|
|
74
|
+
}
|
|
49
75
|
try {
|
|
50
76
|
for (const agent of installation.agents) {
|
|
51
77
|
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
52
78
|
const providers=(agent.codexProviders ?? []).map(validateCodexProvider)
|
|
53
79
|
if (new Set(providers.map(provider=>provider.id)).size !== providers.length) throw new Error('Codex provider IDs must be unique')
|
|
80
|
+
validateOpencodeProviders(agent.opencodeProviders)
|
|
54
81
|
if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
|
|
55
82
|
if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
|
|
56
83
|
if (agent.additionalWorkspaces) {
|
|
@@ -77,7 +104,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
77
104
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
78
105
|
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
|
|
79
106
|
locks.push(lock)
|
|
80
|
-
const models = await
|
|
107
|
+
const models = await freshCatalog(agent)
|
|
81
108
|
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
|
|
82
109
|
const modelsFile=path.join(directory,'models.json')
|
|
83
110
|
await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
|
|
@@ -100,22 +127,23 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
100
127
|
while (!signal.aborted) {
|
|
101
128
|
const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
|
|
102
129
|
if(parent) { try { process.kill(parent,0) } catch { break } }
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const models=JSON.stringify(catalogModels)
|
|
107
|
-
if(catalogSeen.get(agent)===models)continue
|
|
108
|
-
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
|
|
109
|
-
const file=path.join(agent.controlDir,'host-executor/models.json')
|
|
110
|
-
await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
|
|
111
|
-
catalogSeen.set(agent,models)
|
|
112
|
-
}
|
|
130
|
+
// Keep transport liveness independent of native catalog I/O.
|
|
131
|
+
for (const agent of installation.agents) await beat(agent)
|
|
132
|
+
if(!catalogRefresh && Date.now()-catalogAt>30000){
|
|
113
133
|
catalogAt=Date.now()
|
|
134
|
+
catalogRefresh=(async()=>{
|
|
135
|
+
for(const agent of installation.agents){
|
|
136
|
+
if(signal.aborted)break
|
|
137
|
+
try {
|
|
138
|
+
await refreshAgentCatalog(agent, freshCatalog, catalogSeen)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('Host catalog refresh failed, keeping last-good models', error instanceof Error ? error.message : error)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})().finally(()=>{catalogRefresh=undefined})
|
|
114
144
|
}
|
|
115
145
|
for (const agent of installation.agents) {
|
|
116
146
|
const directory=path.join(agent.controlDir,'host-executor')
|
|
117
|
-
await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,platform:process.platform,arch:process.arch,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
|
|
118
|
-
await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
|
|
119
147
|
for (const file of await readdir(directory)) {
|
|
120
148
|
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
121
149
|
const id=file.slice(0,-13)
|
|
@@ -161,7 +189,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
161
189
|
if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
|
|
162
190
|
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
163
191
|
throw new Error('Selected Codex provider is required for this model')
|
|
164
|
-
|
|
192
|
+
// Admission and execution must use the same catalog snapshot. A
|
|
193
|
+
// transient native-client probe failure must not reject a model
|
|
194
|
+
// that the host just advertised to the relay and application.
|
|
195
|
+
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
165
196
|
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.controlDir,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
166
197
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
167
198
|
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
@@ -198,6 +229,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
198
229
|
} finally {
|
|
199
230
|
for(const child of active.values())terminateJob(child)
|
|
200
231
|
await Promise.allSettled(tasks)
|
|
232
|
+
await catalogRefresh
|
|
201
233
|
for(const lock of locks)await rm(lock,{force:true})
|
|
202
234
|
}
|
|
203
235
|
}
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
|
|
|
26
26
|
import { loadConfig, type Config } from './config.js'
|
|
27
27
|
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
28
28
|
import { ApprovalStore } from './approval.js'
|
|
29
|
-
import { startExecutorJob, terminateJob } from './executor.js'
|
|
29
|
+
import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
|
|
30
30
|
import { RunStore, type RunRecord } from './runs.js'
|
|
31
31
|
import { splitTelegramText } from './reply.js'
|
|
32
32
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
@@ -71,7 +71,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
71
71
|
let drainTimer: ReturnType<typeof setInterval> | undefined
|
|
72
72
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
73
73
|
const conversationMenu = createConversationMenu(control)
|
|
74
|
-
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome
|
|
74
|
+
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
|
|
75
|
+
undefined, opencodeDataHome(config.controlDir))
|
|
75
76
|
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
76
77
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
77
78
|
|
package/src/menu.ts
CHANGED
|
@@ -30,14 +30,14 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
|
30
30
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
31
31
|
// These are operational settings, not a second conversational/agent loop.
|
|
32
32
|
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
|
|
33
|
-
isInstalled = installed) => {
|
|
33
|
+
isInstalled = installed, opencodeDataHome?: string) => {
|
|
34
34
|
const initial = chatPreset(cli)
|
|
35
35
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
36
36
|
if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
|
|
37
37
|
// Local execution runs Codex with CODEX_HOME set to the agent-bound control
|
|
38
38
|
// directory, but the default catalog probed the process home instead. Bind it
|
|
39
39
|
// to the same agent-bound home so /ai lists the models Codex can actually run.
|
|
40
|
-
else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome)
|
|
40
|
+
else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
|
|
41
41
|
const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
|
|
42
42
|
const validate = async (preset: AiPreset) => {
|
|
43
43
|
assertEffort(preset.effort, preset.model, preset.cli)
|
package/src/message-history.ts
CHANGED
|
@@ -13,7 +13,25 @@ export async function deliveredMessages(controlDir: string, runId: string, optio
|
|
|
13
13
|
if (options.messageId !== undefined && (!Number.isSafeInteger(options.messageId) || options.messageId < 1))
|
|
14
14
|
throw new Error('Message ID must be a positive integer')
|
|
15
15
|
const caller = await requireOwnerExecution(controlDir, runId)
|
|
16
|
-
|
|
16
|
+
const application = caller.application ?? caller.delivery
|
|
17
|
+
if (application) {
|
|
18
|
+
if (options.messageId !== undefined) throw new Error('Message ID filter is only available for Telegram history')
|
|
19
|
+
const owner = (await new ControlStore(controlDir, 900_000).status()).owner
|
|
20
|
+
const runs = new Map((await new RunStore(controlDir).list()).filter(run => {
|
|
21
|
+
const origin = run.application ?? run.delivery
|
|
22
|
+
return origin?.bindingId === application.bindingId && origin.scope === application.scope &&
|
|
23
|
+
!run.external && !run.taskId && ownsRun(owner, run)
|
|
24
|
+
}).map(run => [run.id, run]))
|
|
25
|
+
const messages = sentOutbox(controlDir).flatMap(item => {
|
|
26
|
+
const run = item.runId === undefined ? undefined : runs.get(item.runId)
|
|
27
|
+
return run && (!item.type || item.type === 'message') && typeof item.text === 'string'
|
|
28
|
+
? [{ runId: run.id, id: item.id, text: item.text, createdAt: item.createdAt }]
|
|
29
|
+
: []
|
|
30
|
+
})
|
|
31
|
+
messages.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
|
|
32
|
+
return { scope: application.scope, messages: messages.slice(-limit), hasMore: messages.length > limit,
|
|
33
|
+
note: 'Delivered messages in the current application binding and scope; relay history is bounded and resets on restart.' }
|
|
34
|
+
}
|
|
17
35
|
const owner = (await new ControlStore(controlDir, 900_000).status()).owner
|
|
18
36
|
if (!ownsRun(owner, caller)) throw new Error('Owner binding changed')
|
|
19
37
|
const pairedAt = Date.parse(owner!.pairedAt)
|
package/src/message.ts
CHANGED
|
@@ -13,8 +13,8 @@ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
|
13
13
|
console.log(
|
|
14
14
|
'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
|
|
15
15
|
)
|
|
16
|
-
console.log('History: ezenciel-agents-message history [--limit 1..50] [--message-id ID] (read-only, bound Telegram chat
|
|
17
|
-
console.log(
|
|
16
|
+
console.log('History: ezenciel-agents-message history [--limit 1..50] [--message-id ID] (read-only; current application binding and scope, or bound Telegram chat). --message-id is Telegram-only.')
|
|
17
|
+
console.log('Authenticated run: sends use its bound channel (application inbox or paired Telegram chat). Delivery receipts confirm that destination; uncertain sends must not be retried without readback.')
|
|
18
18
|
console.log('Text: --text decodes \\n as a newline and \\\\ as a literal backslash; --text-file preserves file content.')
|
|
19
19
|
process.exit(0)
|
|
20
20
|
}
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -278,15 +278,14 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
278
278
|
const configured = agent.pluginNetworkBindings;
|
|
279
279
|
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
280
280
|
throw Error('Invalid host network bindings');
|
|
281
|
+
const route = configured?.[record.manifest?.id];
|
|
282
|
+
if (route === undefined) return [];
|
|
281
283
|
if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
|
|
282
|
-
|
|
283
|
-
return [];
|
|
284
|
+
throw Error('Isolated broker cannot use host network bindings');
|
|
284
285
|
}
|
|
285
286
|
if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
286
287
|
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
|
|
287
288
|
throw Error('Invalid host network binding deployment');
|
|
288
|
-
const route = configured?.[record.manifest?.id];
|
|
289
|
-
if (route === undefined) return [];
|
|
290
289
|
keys(route, ['revisions', 'bindings']);
|
|
291
290
|
const trusted = await snapshot(record.source);
|
|
292
291
|
if (record.revision !== trusted.revision || record.source !== trusted.source ||
|
package/src/setup.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const readActiveExecutor = async (envFilePath: string): Promise<string> =
|
|
|
37
37
|
} catch {
|
|
38
38
|
// env file doesn't exist yet
|
|
39
39
|
}
|
|
40
|
-
return process.env.EZ_EXECUTOR_CLI?.trim() || '
|
|
40
|
+
return process.env.EZ_EXECUTOR_CLI?.trim() || 'codex'
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export const setExecutorInEnv = async (envFilePath: string, cliName: string): Promise<void> => {
|
|
@@ -102,9 +102,10 @@ const printStatus = (status: { active: string; items: ExecutorStatus[] }): void
|
|
|
102
102
|
console.log('\nTo switch executor:')
|
|
103
103
|
console.log(' pnpm run setup <executor-name>')
|
|
104
104
|
console.log(' Examples: pnpm run setup claude')
|
|
105
|
-
console.log(' pnpm run setup
|
|
105
|
+
console.log(' pnpm run setup codex')
|
|
106
106
|
console.log(' pnpm run setup grok')
|
|
107
107
|
console.log(' pnpm run setup opencode')
|
|
108
|
+
console.log(' pnpm run setup pi')
|
|
108
109
|
console.log(' pnpm run setup codex-gui\n')
|
|
109
110
|
}
|
|
110
111
|
|
package/test/ai.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
|
-
import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
|
|
7
|
+
import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
|
|
8
8
|
import { createAiMenu } from '../src/menu.js'
|
|
9
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
10
10
|
import { InboxStore } from '../src/inbox.js'
|
|
@@ -287,6 +287,12 @@ test('native executor flags carry the exact model and effort; only structured me
|
|
|
287
287
|
const codex = EXECUTOR_REGISTRY.codex.buildArgs(opts, '', 'fixture')
|
|
288
288
|
assert.ok(codex.includes('model_reasoning_effort="medium"'))
|
|
289
289
|
assert.deepEqual(codex.slice(-3), ['resume', opts.sessionId, '-'])
|
|
290
|
+
// Opencode variants pass through verbatim on every platform; there is no
|
|
291
|
+
// OS-specific effort mapping between the catalog and `run --variant`.
|
|
292
|
+
const opencode = EXECUTOR_REGISTRY.opencode.buildArgs(
|
|
293
|
+
{ workspace: '/tmp/fixture', model: 'opencode/muse-spark-1.3-contributor-free', effort: 'xhigh' }, '', 'fixture')
|
|
294
|
+
assert.equal(opencode[opencode.indexOf('-m') + 1], 'opencode/muse-spark-1.3-contributor-free')
|
|
295
|
+
assert.equal(opencode[opencode.indexOf('--variant') + 1], 'xhigh')
|
|
290
296
|
assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'thread.started', thread_id: opts.sessionId })), opts.sessionId)
|
|
291
297
|
assert.equal(nativeSessionId('opencode', JSON.stringify({ type: 'step_start', sessionID: 'ses_fixture' })), 'ses_fixture')
|
|
292
298
|
assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'text', thread_id: opts.sessionId })), undefined)
|
|
@@ -353,3 +359,181 @@ test('upgrades expose responsive chat without replacing an existing default or q
|
|
|
353
359
|
assert.ok(state.presets.some(p => p.id === 'chat-default'))
|
|
354
360
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
355
361
|
})
|
|
362
|
+
|
|
363
|
+
test('opencode catalog projects installed models with variants as efforts', async () => {
|
|
364
|
+
const verbose = [
|
|
365
|
+
'opencode/big-pickle',
|
|
366
|
+
JSON.stringify({ id: 'big-pickle', providerID: 'opencode', name: 'Big Pickle', variants: {} }),
|
|
367
|
+
'opencode/ling-free',
|
|
368
|
+
JSON.stringify({ id: 'ling-free', providerID: 'opencode', name: 'Ling Free',
|
|
369
|
+
variants: { low: { reasoningEffort: 'low' }, medium: { reasoningEffort: 'medium' }, 'bad option': {} } }),
|
|
370
|
+
'openrouter/~aliased/model',
|
|
371
|
+
JSON.stringify({ id: 'model', providerID: 'openrouter', name: 'Aliased', variants: {} }),
|
|
372
|
+
].join('\n')
|
|
373
|
+
const catalog = await readOpencodeModels(async (args) => {
|
|
374
|
+
assert.deepEqual(args, ['models', '--verbose'])
|
|
375
|
+
return verbose
|
|
376
|
+
})
|
|
377
|
+
assert.deepEqual(catalog, [
|
|
378
|
+
{ cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
|
|
379
|
+
{ cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
|
|
380
|
+
])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('opencode catalog falls back to the plain list, then to the client default', async () => {
|
|
384
|
+
const plain = await readOpencodeModels(async (args) => {
|
|
385
|
+
if (args.includes('--verbose')) throw new Error('no verbose metadata')
|
|
386
|
+
return 'opencode/alpha\nopenrouter/~aliased/model\nnot a model line\n'
|
|
387
|
+
})
|
|
388
|
+
assert.deepEqual(plain, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
|
|
389
|
+
assert.deepEqual(await readOpencodeModels(async () => { throw new Error('opencode unavailable') }), [])
|
|
390
|
+
const fallback = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
|
|
391
|
+
async () => { throw new Error('opencode unavailable') })
|
|
392
|
+
assert.deepEqual(fallback, [{ cli: 'opencode', name: 'opencode · client default', efforts: [] }])
|
|
393
|
+
const listed = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
|
|
394
|
+
async () => 'opencode/alpha\n')
|
|
395
|
+
assert.deepEqual(listed, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('opencode provider allowlist scopes the catalog to Go and never falls back outside it', async () => {
|
|
399
|
+
assert.equal(opencodeProviderAllowlist({}), undefined)
|
|
400
|
+
assert.equal(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' ' }), undefined)
|
|
401
|
+
assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: 'opencode-go' }), ['opencode-go'])
|
|
402
|
+
assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' opencode-go ,openrouter,opencode-go ' }), ['opencode-go', 'openrouter'])
|
|
403
|
+
for (const bad of ['Bad Name!', 'has space', 'UPPER', 'a'.repeat(33), ',,,', 'ok,,bad name'])
|
|
404
|
+
assert.throws(() => opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: bad }), /one to sixteen unique provider IDs/)
|
|
405
|
+
const verbose = [
|
|
406
|
+
'opencode/mimo-free',
|
|
407
|
+
JSON.stringify({ id: 'mimo-free', providerID: 'opencode', name: 'Mimo Free', variants: {} }),
|
|
408
|
+
'opencode-go/muse-spark-1.3-contributor',
|
|
409
|
+
JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor',
|
|
410
|
+
variants: { high: {}, xhigh: {} } }),
|
|
411
|
+
'openrouter/some/model',
|
|
412
|
+
JSON.stringify({ id: 'some/model', providerID: 'openrouter', name: 'Some', variants: {} }),
|
|
413
|
+
].join('\n')
|
|
414
|
+
const runner = async () => verbose
|
|
415
|
+
const prior = process.env.EZ_OPENCODE_PROVIDERS
|
|
416
|
+
try {
|
|
417
|
+
process.env.EZ_OPENCODE_PROVIDERS = 'opencode-go'
|
|
418
|
+
const scoped = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)
|
|
419
|
+
assert.deepEqual(scoped.map((m) => m.model), ['opencode-go/muse-spark-1.3-contributor'])
|
|
420
|
+
await assert.rejects(
|
|
421
|
+
validateSelection({ id: 'free', name: 'Free', cli: 'opencode', model: 'opencode/mimo-free' }, scoped, async () => true),
|
|
422
|
+
/installed client catalog/)
|
|
423
|
+
process.env.EZ_OPENCODE_PROVIDERS = 'missing-provider'
|
|
424
|
+
assert.deepEqual(await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner), [])
|
|
425
|
+
delete process.env.EZ_OPENCODE_PROVIDERS
|
|
426
|
+
assert.equal((await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)).length, 3)
|
|
427
|
+
} finally {
|
|
428
|
+
if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
|
|
429
|
+
else process.env.EZ_OPENCODE_PROVIDERS = prior
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
test('explicit host binding allowlist overrides the process environment', async () => {
|
|
433
|
+
const runner = async () => 'opencode/a\nopencode-go/b\n'
|
|
434
|
+
const only = (models: { cli: string; model?: string }[]) => models.filter((m) => m.cli === 'opencode').map((m) => m.model)
|
|
435
|
+
const prior = process.env.EZ_OPENCODE_PROVIDERS
|
|
436
|
+
try {
|
|
437
|
+
process.env.EZ_OPENCODE_PROVIDERS = 'opencode'
|
|
438
|
+
assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner)), ['opencode/a'])
|
|
439
|
+
assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, ['opencode-go'])), ['opencode-go/b'])
|
|
440
|
+
assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, undefined)), ['opencode/a'])
|
|
441
|
+
} finally {
|
|
442
|
+
if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
|
|
443
|
+
else process.env.EZ_OPENCODE_PROVIDERS = prior
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('opencode model and variant selections validate against the installed catalog', async () => {
|
|
448
|
+
const catalog = [
|
|
449
|
+
{ cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
|
|
450
|
+
{ cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
|
|
451
|
+
]
|
|
452
|
+
const available = async (cli: string) => cli === 'opencode'
|
|
453
|
+
await validateSelection({ id: 'choice', name: 'Ling', cli: 'opencode', model: 'opencode/ling-free', effort: 'low' }, catalog, available)
|
|
454
|
+
await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
|
|
455
|
+
await assert.rejects(
|
|
456
|
+
validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
|
|
457
|
+
/installed client catalog/)
|
|
458
|
+
await assert.rejects(
|
|
459
|
+
validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
|
|
460
|
+
/installed client catalog/)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('pi does not borrow an unrelated OpenCode catalog', async () => {
|
|
464
|
+
const runner = async () => [
|
|
465
|
+
'opencode-go/muse-spark-1.3-contributor',
|
|
466
|
+
JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor', variants: { high: {}, xhigh: {} } }),
|
|
467
|
+
].join('\n')
|
|
468
|
+
const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never, runner)
|
|
469
|
+
assert.deepEqual(catalog, [{ cli: 'pi', name: 'Pi · client default', efforts: [] }])
|
|
470
|
+
assert.equal(isPreset({ id: 'x', name: 'Pi', cli: 'pi' }), true)
|
|
471
|
+
for (const cli of ['unreal-agent', 'antigravity', 'agy'])
|
|
472
|
+
assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli }), false)
|
|
473
|
+
await validateSelection({ id: 'choice', name: 'Default', cli: 'pi' }, catalog, async (cli) => cli === 'pi')
|
|
474
|
+
await assert.rejects(
|
|
475
|
+
validateSelection(
|
|
476
|
+
{ id: 'bad', name: 'Bad', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'max' },
|
|
477
|
+
catalog, async (cli) => cli === 'pi'),
|
|
478
|
+
/installed client catalog/)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('model catalog merges user-curated entries ahead of discovered ones', async () => {
|
|
482
|
+
const control = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-'))
|
|
483
|
+
const home = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-home-'))
|
|
484
|
+
try {
|
|
485
|
+
await mkdir(join(home, '.codex'))
|
|
486
|
+
await writeFile(join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
|
|
487
|
+
{ slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
|
|
488
|
+
supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
489
|
+
] }))
|
|
490
|
+
await writeFile(join(control, 'ai-models.json'), JSON.stringify([
|
|
491
|
+
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
|
|
492
|
+
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Dupe', efforts: [] },
|
|
493
|
+
{ cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
|
|
494
|
+
{ cli: 'nope', model: 'x' },
|
|
495
|
+
'not-an-entry',
|
|
496
|
+
]))
|
|
497
|
+
const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
|
|
498
|
+
const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
|
|
499
|
+
const catalog = await readModels(home, installedOnly, join(home, '.codex'),
|
|
500
|
+
noRunner, undefined, undefined, control)
|
|
501
|
+
assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
|
|
502
|
+
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
|
|
503
|
+
{ cli: 'pi', name: 'Pi · client default', efforts: [] },
|
|
504
|
+
])
|
|
505
|
+
// Curated entries win over the same discovered id and validate for selection.
|
|
506
|
+
assert.deepEqual(catalog.filter((m) => m.cli === 'codex'), [
|
|
507
|
+
{ cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
|
|
508
|
+
])
|
|
509
|
+
await validateSelection(
|
|
510
|
+
{ id: 'choice', name: 'Spark', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
|
|
511
|
+
catalog, async (cli) => cli === 'pi')
|
|
512
|
+
// Missing or malformed curation files fall back to discovery alone.
|
|
513
|
+
assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
|
|
514
|
+
await writeFile(join(control, 'ai-models.json'), 'not json')
|
|
515
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
|
|
516
|
+
noRunner, undefined, undefined, control), [
|
|
517
|
+
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
518
|
+
])
|
|
519
|
+
} finally {
|
|
520
|
+
await rm(control, { recursive: true, force: true })
|
|
521
|
+
await rm(home, { recursive: true, force: true })
|
|
522
|
+
}
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
test('curated models respect installed clients and provider scope', async () => {
|
|
526
|
+
const control = await mkdtemp(join(tmpdir(), 'ez-catalog-scope-'))
|
|
527
|
+
try {
|
|
528
|
+
await writeFile(join(control, 'ai-models.json'), JSON.stringify([
|
|
529
|
+
{ cli: 'pi', model: 'opencode-go/allowed', efforts: ['xhigh'] },
|
|
530
|
+
{ cli: 'pi', model: 'other/blocked' },
|
|
531
|
+
{ cli: 'opencode', model: 'other/blocked' },
|
|
532
|
+
{ cli: 'claude', model: 'uninstalled' },
|
|
533
|
+
]))
|
|
534
|
+
const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never,
|
|
535
|
+
async () => { throw new Error('no OpenCode catalog') }, undefined, ['opencode-go'], control)
|
|
536
|
+
assert.deepEqual(catalog.filter(entry => entry.model).map(entry => entry.model), ['opencode-go/allowed'])
|
|
537
|
+
assert.equal(catalog.some(entry => entry.cli === 'pi' && !entry.model), false)
|
|
538
|
+
} finally { await rm(control, { recursive: true, force: true }) }
|
|
539
|
+
})
|
package/test/config.test.ts
CHANGED
|
@@ -23,9 +23,9 @@ test('rejects malformed timeouts', () => {
|
|
|
23
23
|
assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_PAIRING_TTL_SECONDS: 'bad' }), /positive integer/)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
test('loads executor CLI configuration with
|
|
26
|
+
test('loads executor CLI configuration with codex fallback', () => {
|
|
27
27
|
const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY' })
|
|
28
|
-
assert.equal(defaultConfig.executorCli, '
|
|
28
|
+
assert.equal(defaultConfig.executorCli, 'codex')
|
|
29
29
|
|
|
30
30
|
const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_EXECUTOR_CLI: 'claude' })
|
|
31
31
|
assert.equal(customConfig.executorCli, 'claude')
|
|
@@ -66,7 +66,7 @@ test('titles are bounded and rename is durable; malformed IDs and unsupported me
|
|
|
66
66
|
await assert.rejects(store.listSessions(), /unsupported shape/)
|
|
67
67
|
}))
|
|
68
68
|
|
|
69
|
-
test('legacy sessions retain IDs; unbound
|
|
69
|
+
test('legacy sessions retain IDs; unbound sessions cannot resume a different context', async () => fixture(async (store, dir) => {
|
|
70
70
|
const legacy = await store.ensureActiveSession()
|
|
71
71
|
await store.markSessionStarted(legacy.sessionId)
|
|
72
72
|
await store.captureChoice(initialPreset('grok'))
|
|
@@ -74,11 +74,5 @@ test('legacy sessions retain IDs; unbound and latest-only engines cannot resume
|
|
|
74
74
|
await assert.rejects(store.switchSession(legacy.sessionId), /binding/)
|
|
75
75
|
assert.match(sessionTitle((await store.listSessions()).find(s => s.sessionId === legacy.sessionId)!), /Conversation /)
|
|
76
76
|
await store.archiveSession(legacy.sessionId, true)
|
|
77
|
-
|
|
78
|
-
await store.savePreset(agy)
|
|
79
|
-
await store.selectPreset(agy.id, (await store.getActiveSession())!.sessionId, true)
|
|
80
|
-
const old = await store.captureChoice(agy)
|
|
81
|
-
await store.markSessionStarted(old.sessionId)
|
|
82
|
-
await store.resetSession()
|
|
83
|
-
await assert.rejects(store.switchSession(old.sessionId), /latest conversation/)
|
|
77
|
+
|
|
84
78
|
}))
|
|
@@ -31,13 +31,13 @@ if(args[0]==='app-server'){
|
|
|
31
31
|
}
|
|
32
32
|
if(q.method==='thread/goal/get')send({id:q.id,result:{goal:null}});
|
|
33
33
|
});
|
|
34
|
-
}else capture(args.includes('--prompt-file')?fs.readFileSync(args[args.indexOf('--prompt-file')+1],'utf8'):args.
|
|
34
|
+
}else capture(args.includes('--prompt-file')?fs.readFileSync(args[args.indexOf('--prompt-file')+1],'utf8'):args.includes('--print')||(args[0]==='exec'&&args.at(-1)==='-')?fs.readFileSync(0,'utf8'):args.at(-1));
|
|
35
35
|
`
|
|
36
|
-
for(const name of ['codex','grok','
|
|
36
|
+
for(const name of ['codex','grok','claude','opencode'])await writeFile(path.join(bin,name),fixture,{mode:0o700})
|
|
37
37
|
const previous={PATH:process.env.PATH,TELEGRAM_BOT_TOKEN:process.env.TELEGRAM_BOT_TOKEN,EZ_EXECUTOR_TRANSPORT:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
38
38
|
process.env.PATH=bin+path.delimiter+process.env.PATH;process.env.TELEGRAM_BOT_TOKEN='do-not-inherit';delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
39
39
|
try {
|
|
40
|
-
for(const cli of ['codex','grok','
|
|
40
|
+
for(const cli of ['codex','grok','claude','opencode'])for(const isResume of [false,true])for(const text of [' /goal audit list of files and give me a simple list with filenames\n','--help','-','resume']) {
|
|
41
41
|
const runId='r_'+cli+'_'+String(isResume)+'_'+Buffer.from(text).toString('hex').slice(0,20)
|
|
42
42
|
await ownerRun(controlDir,runId)
|
|
43
43
|
const job=await startExecutorJob([text],{workspace,controlDir,binDir:bin,cli,runId,timeoutMs:5000,isResume,sessionId:'native-existing',repairEnabled:false})
|
|
@@ -51,7 +51,6 @@ if(args[0]==='app-server'){
|
|
|
51
51
|
}
|
|
52
52
|
if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
|
|
53
53
|
if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
|
|
54
|
-
if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
|
|
55
54
|
if(cli==='claude')assert.ok(!captured.args.includes('--append-system-prompt-file'))
|
|
56
55
|
}
|
|
57
56
|
for(const [runId,texts] of [['r_schedule_literal',['/goal audit list of files and give me a simple list with filenames']],['r_batch',['first\nline',' second ']]] as const) {
|