@jc_stack/ez-agents 0.1.0-beta.51 → 0.1.0-beta.58

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.
@@ -0,0 +1,358 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { spawn, spawnSync } from 'node:child_process'
7
+ import { once } from 'node:events'
8
+ import { fileURLToPath } from 'node:url'
9
+ import type { Update } from 'grammy/types'
10
+ import { createRelay } from '../src/index.js'
11
+ import { ControlStore } from '../src/control-state.js'
12
+ import { RunStore, activeSetup } from '../src/runs.js'
13
+ import { initialPreset, resolveFallbacks, type AiPreset } from '../src/ai.js'
14
+ import type { ExecutorOptions } from '../src/executor.js'
15
+ import { telemetryObserver, validTelemetry } from '../src/run-telemetry.js'
16
+
17
+ 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') }
18
+ const message = (id: number, text: string): Update => ({ update_id: id, message: { message_id: id, date: 0, text,
19
+ from: { id: 101, is_bot: false, first_name: 'Owner' }, chat: { id: 101, type: 'private', first_name: 'Owner' } } })
20
+ const opus: AiPreset = { id: 'opus', name: 'Opus', cli: 'claude', model: 'opus', effort: 'high' }
21
+ const logins = ['claude2', 'claude3', 'claude4']
22
+ const codex: AiPreset = { id: 'codex-sol', name: 'Codex', cli: 'codex', model: 'gpt-6.1-sol', effort: 'xhigh' }
23
+ const chain: AiPreset[] = [...logins.map(authProfile => ({ id: authProfile, name: authProfile, cli: 'claude', authProfile })), codex]
24
+ const quota = { type: 'assistant', error: 'rate_limit' }
25
+ const work = { type: 'assistant', message: { content: [{ type: 'text', text: 'working' }] } }
26
+
27
+ type Script = { lines?: unknown[]; code?: number; hold?: boolean; throws?: boolean; beforeExit?: () => Promise<void> }
28
+ const fixture = async (name: string, scripts: Script[], options: { chain?: AiPreset[]; telemetry?: boolean } = {}) => {
29
+ const dir = await mkdtemp(join(tmpdir(), `ez-chat-${name}-`)), control = new ControlStore(dir, 1000), runs = new RunStore(dir)
30
+ const launches: ExecutorOptions[] = [], prompts: string[][] = [], admitted: (AiPreset | undefined)[] = [], sent: string[] = []
31
+ const relay = createRelay({ workspace: dir, controlDir: dir, pairingTtlMs: 1000, executorTimeoutMs: 0, executorCli: 'claude', telegramBotToken: 'fixture' }, async (texts, launch) => {
32
+ const script = scripts[launches.length] ?? { code: 0 }
33
+ launches.push(launch); prompts.push(texts)
34
+ const run = (await runs.get(launch.runId))!
35
+ admitted.push(run.attempts ? activeSetup(run) : run.execution?.preset)
36
+ await script.beforeExit?.()
37
+ if (script.throws) throw new Error('spawn failed after submission')
38
+ const out = (script.lines ?? []).map(l => typeof l === 'string' ? l : JSON.stringify(l)).join('\n')
39
+ 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' })
40
+ await once(child, 'spawn'); return { child, cleanup: async () => {}, stdout: '' }
41
+ })
42
+ relay.bot.botInfo = { id: 999, is_bot: true, first_name: 'Fixture', username: 'fixture_bot' } as typeof relay.bot.botInfo
43
+ relay.bot.api.config.use(async (_prev, method, payload) => { if (method === 'sendMessage') sent.push((payload as { text: string }).text); return { ok: true, result: { message_id: 42 } } as never })
44
+ await control.requestPairing(101, 101); await control.approveOwner(101)
45
+ await control.aiState(initialPreset('claude')); await control.savePreset(opus)
46
+ await control.selectPreset(opus.id, (await control.status()).activeSession?.sessionId ?? null, true)
47
+ if (options.chain !== undefined) { if (options.chain.length) await control.setChatFallbacks(options.chain) } else await control.setChatFallbacks(chain)
48
+ if (options.telemetry) await control.setRunTelemetry(true)
49
+ let update = 0
50
+ const turn = async (text = 'Owner request') => {
51
+ const before = (await runs.list()).length
52
+ await relay.bot.handleUpdate(message(++update, text)); await relay.drainInbox(true)
53
+ await until(async () => (await runs.list()).length > before)
54
+ const run = (await runs.list()).at(-1)!
55
+ await until(async () => ['completed', 'failed', 'cancelled'].includes((await runs.get(run.id))!.status))
56
+ return (await runs.get(run.id))!
57
+ }
58
+ const session = async () => (await control.status()).activeSession!
59
+ return { dir, control, runs, relay, launches, prompts, admitted, sent, turn, session,
60
+ close: async () => { await relay.stop(); await rm(dir, { recursive: true, force: true }) } }
61
+ }
62
+
63
+ test('the chat chain tries Claude default, Claude 2–4 and Codex in order, preserving the role model and effort', async () => {
64
+ const f = await fixture('five', [{ lines: [quota], code: 1 }, { lines: [quota], code: 1 }, { lines: [quota], code: 1 }, { lines: [quota], code: 1 }, { lines: [work], code: 0 }])
65
+ try {
66
+ const run = await f.turn()
67
+ assert.equal(run.status, 'completed')
68
+ assert.deepEqual(f.launches.map(l => [l.cli, l.authProfile, l.model, l.effort, l.runId]), [
69
+ ['claude', undefined, 'opus', 'high', run.id], ...logins.map(p => ['claude', p, 'opus', 'high', run.id]), ['codex', undefined, 'gpt-6.1-sol', 'xhigh', run.id]])
70
+ assert.deepEqual(f.admitted.map(p => [p?.cli, p?.authProfile]), f.launches.map(l => [l.cli, l.authProfile]))
71
+ assert.deepEqual(run.attempts!.map(a => [a.quotaScope, a.outcome, a.category]), [
72
+ ['claude', 'failed', 'quota'], ['claude@claude2', 'failed', 'quota'], ['claude@claude3', 'failed', 'quota'], ['claude@claude4', 'failed', 'quota'], ['codex', 'completed', undefined]])
73
+ assert.ok(f.launches.every(l => l.structuredOutput === true && !l.nativeSession))
74
+ assert.ok(f.prompts.every(p => JSON.stringify(p) === JSON.stringify(f.prompts[0])), 'nothing ran, so no continuation fact is added')
75
+ assert.equal((await f.runs.list()).length, 1)
76
+ } finally { await f.close() }
77
+ })
78
+
79
+ test('queued work keeps the chain and choice captured when it was queued; roles keep their own model and effort', async () => {
80
+ const f = await fixture('queued', [{ lines: [quota], code: 1 }, { lines: [work], code: 0 }])
81
+ try {
82
+ await f.relay.bot.handleUpdate(message(1, 'Queued request'))
83
+ // Edits after admission apply to later work only.
84
+ await f.control.setChatFallbacks([codex])
85
+ const sonnet = { id: 'sonnet', name: 'Sonnet', cli: 'claude', model: 'sonnet', effort: 'medium' }
86
+ await f.control.savePreset(sonnet); await f.control.selectPreset(sonnet.id, (await f.session()).sessionId)
87
+ await f.relay.drainInbox(true)
88
+ await until(async () => (await f.runs.list())[0]?.status === 'completed')
89
+ const run = (await f.runs.list())[0]
90
+ assert.deepEqual(run.execution!.fallbacks!.map(p => [p.authProfile, p.model, p.effort]), [...logins.map(p => [p, 'opus', 'high']), [undefined, 'gpt-6.1-sol', 'xhigh']])
91
+ assert.deepEqual(f.launches.map(l => [l.authProfile, l.model, l.effort]), [[undefined, 'opus', 'high'], ['claude2', 'opus', 'high']])
92
+ // An application role with its own model gets the same logins at that model.
93
+ const role = { id: 'role', name: 'Role', cli: 'claude', model: 'haiku', effort: 'low' }
94
+ await f.control.setChatFallbacks(chain)
95
+ const captured = await f.control.captureApplicationChoice(initialPreset('claude'), 'a'.repeat(64), false, role)
96
+ assert.deepEqual(captured.fallbacks!.map(p => [p.cli, p.authProfile, p.model, p.effort]), [...logins.map(p => ['claude', p, 'haiku', 'low']), ['codex', undefined, 'gpt-6.1-sol', 'xhigh']])
97
+ assert.deepEqual(resolveFallbacks(opus, [{ ...opus, id: 'copy' }, ...chain, chain[0]]), resolveFallbacks(opus, chain), 'setups equal to an earlier one are dropped')
98
+ } finally { await f.close() }
99
+ })
100
+
101
+ test('each login keeps its own native session for the conversation', async () => {
102
+ const f = await fixture('sessions', [{ lines: [quota], code: 1 }, { lines: [work], code: 0 }, { lines: [work], code: 0 }, { lines: [work], code: 0 }], { chain: [chain[0]] })
103
+ try {
104
+ const conversation = (await f.session()).sessionId
105
+ const first = await f.turn()
106
+ assert.equal(first.status, 'completed')
107
+ const [defaultLaunch, claude2Launch] = f.launches
108
+ assert.equal(defaultLaunch.sessionId, conversation); assert.equal(defaultLaunch.isResume, false)
109
+ assert.equal(claude2Launch.authProfile, 'claude2'); assert.notEqual(claude2Launch.sessionId, conversation); assert.equal(claude2Launch.isResume, false)
110
+ let state = await f.session()
111
+ assert.deepEqual(state.linked, [{ cli: 'claude', authProfile: 'claude2', sessionId: claude2Launch.sessionId, hasStarted: true }])
112
+ assert.equal(state.hasStarted, false)
113
+ // The rejected default login may have claimed its ID; its next attempt uses a fresh one.
114
+ assert.ok(state.nativeSessionId && state.nativeSessionId !== conversation)
115
+ // The default login is cooling down: the next turn resumes the claude2 session.
116
+ const second = await f.turn()
117
+ assert.deepEqual(second.attempts!.map(a => [a.quotaScope, a.outcome]), [['claude', 'skipped'], ['claude@claude2', 'completed']])
118
+ assert.deepEqual([f.launches[2].authProfile, f.launches[2].sessionId, f.launches[2].isResume], ['claude2', claude2Launch.sessionId, true])
119
+ // Once the default login is available again it starts its own session, never claude2's.
120
+ await f.control.clearQuotaWait((await f.control.status()).owner!, ['claude'])
121
+ const third = await f.turn()
122
+ assert.equal(third.attempts!.length, 1)
123
+ assert.deepEqual([f.launches[3].authProfile, f.launches[3].sessionId, f.launches[3].isResume], [undefined, state.nativeSessionId, false])
124
+ state = await f.session()
125
+ assert.equal(state.hasStarted, true); assert.equal(state.cli, 'claude'); assert.equal(state.authProfile, undefined)
126
+ } finally { await f.close() }
127
+ })
128
+
129
+ test('owner stop cancels the turn and never advances to another login', async () => {
130
+ const f = await fixture('stop', [{ lines: [quota], hold: true }])
131
+ try {
132
+ await f.relay.bot.handleUpdate(message(1, 'Long request')); await f.relay.drainInbox(true)
133
+ await until(async () => Boolean((await f.runs.list())[0]?.pid))
134
+ await f.relay.bot.handleUpdate(message(2, '/stop'))
135
+ await until(async () => ['completed', 'failed', 'cancelled'].includes((await f.runs.list())[0].status))
136
+ const run = (await f.runs.list())[0]
137
+ assert.equal(run.status, 'cancelled'); assert.equal(f.launches.length, 1)
138
+ assert.deepEqual(run.attempts!.map(a => a.outcome), ['cancelled'])
139
+ } finally { await f.close() }
140
+ })
141
+
142
+ test('an exhausted chain fails the turn and leaves the chat free for the next turn', async () => {
143
+ const f = await fixture('exhausted', [{ lines: [quota], code: 1 }, { lines: [quota], code: 1 }, { lines: [work], code: 0 }], { chain: [chain[0]] })
144
+ try {
145
+ const first = await f.turn()
146
+ assert.equal(first.status, 'failed'); assert.equal(first.failureReason, 'setups-unavailable')
147
+ assert.match(f.sent.join('\n'), /could not start\. Every task setup was unavailable/)
148
+ // Both logins are cooling down; a present owner still gets them tried in order.
149
+ const second = await f.turn()
150
+ assert.equal(second.status, 'completed'); assert.equal(f.launches.length, 3)
151
+ } finally { await f.close() }
152
+ })
153
+
154
+ test('a stop that arrives while the next login is launching stops that login', async () => {
155
+ const stop = { relay: undefined as ReturnType<typeof createRelay> | undefined }
156
+ const f = await fixture('stop-switch', [{ lines: [quota], code: 1 }, { lines: [work], hold: true, beforeExit: async () => { await stop.relay!.bot.handleUpdate(message(99, '/stop')) } }])
157
+ stop.relay = f.relay
158
+ try {
159
+ const run = await f.turn()
160
+ assert.equal(run.status, 'cancelled'); assert.equal(f.launches.length, 2)
161
+ assert.deepEqual(run.attempts!.map(a => a.outcome), ['failed', 'cancelled'])
162
+ } finally { await f.close() }
163
+ })
164
+
165
+ test('access denial, revoked owner authority and uncertain outcomes stop the chain without replay', async () => {
166
+ const cases: [string, Script, string | undefined][] = [
167
+ ['denied', { lines: [{ type: 'assistant', error: 'oauth_org_not_allowed' }, quota], code: 1 }, 'access-denied'],
168
+ ['partial', { lines: [work, { type: 'assistant', error: 'overloaded' }], code: 1 }, 'after-work-began'],
169
+ ['silent', { code: 1 }, 'uncertain'],
170
+ ['spawn', { throws: true }, 'uncertain'],
171
+ ]
172
+ for (const [name, script, category] of cases) {
173
+ const f = await fixture(name, [script])
174
+ try {
175
+ const run = await f.turn()
176
+ assert.equal(run.status, 'failed', name); assert.equal(f.launches.length, 1, name)
177
+ assert.deepEqual(run.attempts!.map(a => [a.preset.authProfile, a.outcome, a.category]), [[undefined, 'failed', category]], name)
178
+ } finally { await f.close() }
179
+ }
180
+ // An eligible rejection still cannot advance once the owner binding is revoked.
181
+ const revoke = { control: undefined as ControlStore | undefined }
182
+ const f = await fixture('revoked', [{ lines: [quota], code: 1, beforeExit: async () => { await revoke.control!.revokeOwner() } }])
183
+ revoke.control = f.control
184
+ try {
185
+ await f.relay.bot.handleUpdate(message(1, 'Request')); await f.relay.drainInbox(true)
186
+ await until(async () => ['failed', 'cancelled', 'completed'].includes((await f.runs.list())[0]?.status ?? ''))
187
+ assert.equal(f.launches.length, 1)
188
+ assert.equal((await f.runs.list())[0].status, 'failed')
189
+ } finally { await f.close() }
190
+ })
191
+
192
+ test('a usage limit after work began continues on the next login, told that work may be partly applied', async () => {
193
+ const limit = { type: 'rate_limit_event', rate_limit_info: { status: 'rejected', resetsAt: 1791460800 } }
194
+ const f = await fixture('partial-limit', [{ lines: [work, limit, quota], code: 1 }, { lines: [work], code: 0 }])
195
+ try {
196
+ const run = await f.turn()
197
+ assert.equal(run.status, 'completed')
198
+ assert.deepEqual(run.attempts!.map(a => [a.quotaScope, a.category]), [['claude', 'quota-after-work-began'], ['claude@claude2', undefined]])
199
+ assert.match(f.prompts[1].at(-1)!, /began work in native session .* That work may be partly applied\.\]$/)
200
+ // The default session began work, so it resumes rather than being replaced.
201
+ assert.equal((await f.session()).hasStarted, true)
202
+ } finally { await f.close() }
203
+ })
204
+
205
+ test('a usage limit after work began on the last setup fails the turn as started work, not a start failure', async () => {
206
+ const f = await fixture('last-limit', [{ lines: [work, quota], code: 1 }], { chain: [], telemetry: true })
207
+ try {
208
+ const run = await f.turn()
209
+ assert.equal(run.status, 'failed'); assert.equal(run.failureReason, 'executor-exit'); assert.equal(run.exitCode, 1)
210
+ assert.deepEqual(run.attempts!.map(a => a.category), ['quota-after-work-began'])
211
+ assert.doesNotMatch(f.sent.join('\n'), /could not start/)
212
+ } finally { await f.close() }
213
+ })
214
+
215
+ test('without a chain or telemetry, chat behavior is unchanged', async () => {
216
+ const f = await fixture('plain', [{ lines: [quota], code: 1 }], { chain: [] })
217
+ try {
218
+ const run = await f.turn()
219
+ assert.equal(run.status, 'failed'); assert.equal(run.attempts, undefined); assert.equal(run.telemetry, undefined)
220
+ assert.equal(f.launches.length, 1); assert.equal(f.launches[0].structuredOutput, false)
221
+ } finally { await f.close() }
222
+ })
223
+
224
+ const secret = 'SECRET-PROMPT-TOOL-ARG-RESULT-THOUGHT'
225
+ const claudeTurn = [
226
+ { type: 'system', subtype: 'init', model: 'claude-opus-4-9', session_id: 's', tools: ['Bash'], cwd: '/private' },
227
+ { type: 'user', message: { content: [{ type: 'text', text: secret }] } },
228
+ { type: 'assistant', message: { id: 'msg_1', content: [{ type: 'thinking', thinking: secret }], usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100, cache_creation_input_tokens: 7 } } },
229
+ { type: 'assistant', message: { id: 'msg_1', content: [{ type: 'tool_use', id: 'tool_1', name: 'Bash', input: { command: secret } }], usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100, cache_creation_input_tokens: 7 } } },
230
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tool_1', content: secret, is_error: true }] } },
231
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tool_1', content: secret }] } },
232
+ { type: 'assistant', message: { id: 'msg_2', content: [{ type: 'text', text: secret }], usage: { input_tokens: 3, output_tokens: 9, cache_read_input_tokens: 110, cache_creation_input_tokens: 0 } } },
233
+ { type: 'result', subtype: 'success', is_error: false, result: secret, total_cost_usd: 1.23, usage: { input_tokens: 13, output_tokens: 14, cache_read_input_tokens: 210, cache_creation_input_tokens: 7 } },
234
+ ]
235
+
236
+ test('telemetry deduplicates repeated and cumulative events and keeps only counts, names and categories', () => {
237
+ let clock = 1000
238
+ const observer = telemetryObserver({}, () => (clock += 250))
239
+ for (const event of claudeTurn) observer.write(JSON.stringify(event) + '\n')
240
+ const claude = observer.result()
241
+ assert.deepEqual(claude, { coverage: 'complete', model: 'claude-opus-4-9',
242
+ tokens: { input: 13, output: 14, cacheRead: 210, cacheWrite: 7, source: 'native-total' },
243
+ tools: { calls: 1, completed: 1, errors: 1, durationMs: 250, byName: { Bash: { calls: 1, completed: 1, errors: 1, durationMs: 250 } } } })
244
+ assert.ok(!JSON.stringify(claude).includes('SECRET') && !JSON.stringify(claude).includes('cost') && !JSON.stringify(claude).includes('1.23'))
245
+ // Without a native total, repeated per-message usage is counted once and labelled partial.
246
+ const partial = telemetryObserver()
247
+ partial.write(claudeTurn.slice(0, 4).map(e => JSON.stringify(e)).join('\n') + '\n')
248
+ assert.deepEqual(partial.result(), { coverage: 'partial', gaps: ['tokens-without-native-total', 'tools-incomplete'], model: 'claude-opus-4-9',
249
+ tokens: { input: 10, output: 5, cacheRead: 100, cacheWrite: 7, source: 'message-sum' },
250
+ tools: { calls: 1, completed: 0, errors: 0, durationMs: 0, byName: { Bash: { calls: 1, completed: 0, errors: 0, durationMs: 0 } } } })
251
+ // Codex exec: repeated items count once; the last cumulative total replaces earlier ones.
252
+ const codexEvents = [{ type: 'thread.started', thread_id: 't' }, { type: 'turn.started' },
253
+ { type: 'item.started', item: { id: 'i1', type: 'command_execution', command: secret } },
254
+ { type: 'item.started', item: { id: 'i1', type: 'command_execution', command: secret } },
255
+ { type: 'item.completed', item: { id: 'i1', type: 'command_execution', command: secret, aggregated_output: secret, status: 'failed' } },
256
+ { type: 'item.completed', item: { id: 'i2', type: 'mcp_tool_call', server: 'aifit', tool: 'workout_show', arguments: secret, status: 'completed' } },
257
+ { type: 'turn.completed', usage: { input_tokens: 100, cached_input_tokens: 60, output_tokens: 5 } },
258
+ { type: 'turn.completed', usage: { input_tokens: 150, cached_input_tokens: 80, output_tokens: 9 } }]
259
+ const codexObserver = telemetryObserver({ cumulativeSession: true }, () => (clock += 100))
260
+ codexObserver.write(codexEvents.map(e => JSON.stringify(e)).join('\n') + '\n')
261
+ const codexResult = codexObserver.result()
262
+ assert.deepEqual(codexResult.tokens, { input: 70, output: 9, cacheRead: 80, cacheWrite: 0, source: 'native-total' })
263
+ assert.deepEqual(codexResult.gaps, ['tokens-may-include-earlier-session-turns', 'reported-model', 'tool-durations-partial'])
264
+ assert.deepEqual(codexResult.tools!.byName, { command: { calls: 1, completed: 1, errors: 1, durationMs: 100 }, 'mcp:aifit/workout_show': { calls: 1, completed: 1, errors: 0, durationMs: 0 } })
265
+ assert.ok(!JSON.stringify(codexResult).includes('SECRET'))
266
+ // A forwarded summary replaces local observation; a malformed one is ignored.
267
+ const forwarded = telemetryObserver()
268
+ forwarded.write(JSON.stringify({ type: 'thread.started', thread_id: 't' }) + '\n' + JSON.stringify({ type: 'ez.telemetry', telemetry: claude }) + '\n')
269
+ assert.deepEqual(forwarded.result(), claude)
270
+ assert.equal(validTelemetry({ ...claude, model: secret + ' with spaces' }), undefined)
271
+ assert.equal(validTelemetry({ ...claude, tokens: { ...claude.tokens, input: -1 } }), undefined)
272
+ assert.equal(telemetryObserver().result().coverage, 'none')
273
+ })
274
+
275
+ test('opt-in run telemetry records each attempt without prompts, tool data or costs; default off', async () => {
276
+ const f = await fixture('telemetry', [{ lines: [quota], code: 1 }, { lines: claudeTurn, code: 0 }], { chain: [chain[0]], telemetry: true })
277
+ try {
278
+ const run = await f.turn(`Owner says ${secret}`)
279
+ assert.equal(run.status, 'completed'); assert.equal(run.telemetry, true)
280
+ assert.ok(f.launches.every(l => l.telemetry === true))
281
+ const [rejected, served] = run.attempts!
282
+ assert.deepEqual(rejected.telemetry!.errors, ['rate_limit']); assert.equal(rejected.telemetry!.coverage, 'partial')
283
+ assert.deepEqual([served.preset.authProfile, served.telemetry!.model, served.telemetry!.tokens?.output, served.telemetry!.tools?.calls], ['claude2', 'claude-opus-4-9', 14, 1])
284
+ assert.ok(!JSON.stringify(run.attempts).includes('SECRET'))
285
+ await f.control.setRunTelemetry(false)
286
+ const off = await f.turn()
287
+ assert.equal(off.telemetry, undefined); assert.ok(off.attempts!.every(a => a.telemetry === undefined))
288
+ assert.ok(f.launches.at(-1)!.telemetry === false)
289
+ } finally { await f.close() }
290
+ })
291
+
292
+ test('host transport admits only the active chat attempt and forwards Claude summaries without content', async () => {
293
+ const root = await mkdtemp(join(tmpdir(), 'ez-chat-host-')), workspace = join(root, 'mind'), controlDir = join(root, 'control'), directory = join(controlDir, 'host-executor')
294
+ const { serveHostExecutor } = await import('../src/host-executor.js')
295
+ const abort = new AbortController(), launched: ExecutorOptions[] = [], oldPath = process.env.PATH
296
+ let server: Promise<void> | undefined
297
+ try {
298
+ await mkdir(workspace, { recursive: true }); await mkdir(directory, { recursive: true }); await mkdir(join(root, 'bin'))
299
+ await mkdir(join(controlDir, 'cli', 'profiles', 'claude2', 'claude'), { recursive: true })
300
+ await writeFile(join(root, 'bin', 'claude'), '#!/bin/sh\nexit 0\n', { mode: 0o700 })
301
+ process.env.PATH = join(root, 'bin') + ':' + oldPath
302
+ const control = new ControlStore(controlDir, 900_000); await control.requestPairing(101, 101); await control.approveOwner(101)
303
+ const runs = new RunStore(controlDir), id = 'r_chat_fallback'
304
+ const claude2 = { ...opus, id: 'claude2', name: 'claude2', authProfile: 'claude2' }
305
+ await runs.create({ id, chatId: 101, telegramUserId: 101, texts: ['work'], execution: { sessionId: '00000000-0000-4000-8000-000000000000', preset: opus, fallbacks: [claude2] } })
306
+ await runs.patch(id, { status: 'running', telemetry: true, attempts: [{ preset: opus, quotaScope: 'claude', outcome: 'failed', category: 'quota' }, { preset: claude2, quotaScope: 'claude@claude2', outcome: 'running' }] })
307
+ server = serveHostExecutor({ cli: 'claude', agents: [{ name: 't', workspace, controlDir, binDir: join(root, 'bin') }] }, abort.signal, async (_texts, options) => {
308
+ launched.push(options)
309
+ const child = spawn(process.execPath, ['-e', `process.stdout.write(${JSON.stringify(claudeTurn.map(e => JSON.stringify(e)).join('\n') + '\n')});process.exit(0)`])
310
+ return { child, cleanup: async () => {}, stdout: '' }
311
+ }, async () => [{ cli: 'claude', model: 'opus', name: 'Opus', efforts: ['high'] }, { cli: 'claude', authProfile: 'claude2', model: 'opus', name: 'Opus', efforts: ['high'] }])
312
+ const events = async () => { for (let n = 0; n < 150; n++) { try { const text = await readFile(join(directory, id + '.events'), 'utf8'); if (text.includes('"stream":"exit"')) return text } catch {}; await new Promise(r => setTimeout(r, 20)) } throw new Error('No exit event') }
313
+ const request = (options: object) => writeFile(join(directory, id + '.request.json'), JSON.stringify({ texts: ['work'], options }))
314
+ for (let n = 0; n < 100 && !await readFile(join(directory, 'heartbeat.json')).then(() => true, () => false); n++) await new Promise(r => setTimeout(r, 20))
315
+ const { rm: remove } = await import('node:fs/promises')
316
+ // The rejected default login is no longer admitted for this run.
317
+ await request({ cli: 'claude', model: 'opus', effort: 'high' })
318
+ assert.match(await events(), /auth profile does not match its captured AI selection/); await remove(join(directory, id + '.events'))
319
+ await request({ cli: 'claude', model: 'sonnet', effort: 'high', authProfile: 'claude2' })
320
+ assert.match(await events(), /does not match its captured AI selection/); await remove(join(directory, id + '.events'))
321
+ await request({ cli: 'claude', model: 'opus', effort: 'high', authProfile: 'claude2' })
322
+ const output = await events()
323
+ assert.equal(launched.length, 1)
324
+ assert.deepEqual([launched[0].authProfile, launched[0].structuredOutput, launched[0].telemetry, launched[0].nativeSession], ['claude2', true, true, false])
325
+ const lines = output.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)))
326
+ assert.ok(lines.every((e: { type: string }) => ['ez.startup', 'ez.telemetry'].includes(e.type)))
327
+ assert.equal(lines.filter((e: { type: string }) => e.type === 'ez.telemetry').at(-1).telemetry.tokens.output, 14)
328
+ assert.doesNotMatch(output, /SECRET/)
329
+ } finally { abort.abort(); await server; process.env.PATH = oldPath; await rm(root, { recursive: true, force: true }) }
330
+ })
331
+
332
+ test('AI CLI saves, shows and clears the chat chain and toggles telemetry', async () => {
333
+ const root = await mkdtemp(join(tmpdir(), 'ez-ai-chain-'))
334
+ try {
335
+ const controlDir = join(root, 'control'), binDir = join(root, 'bin')
336
+ await mkdir(join(controlDir, 'host-executor'), { recursive: true }); await mkdir(binDir)
337
+ for (const cli of ['claude', 'codex']) await writeFile(join(binDir, cli), '#!/bin/sh\nexit 0\n', { mode: 0o700 })
338
+ for (const profile of logins) await mkdir(join(controlDir, 'cli', 'profiles', profile, 'claude'), { recursive: true })
339
+ await writeFile(join(controlDir, 'host-executor', 'models.json'), JSON.stringify([
340
+ { cli: 'claude', model: 'opus', name: 'Opus', efforts: ['high'] }, ...logins.map(authProfile => ({ cli: 'claude', authProfile, model: 'opus', name: 'Opus', efforts: ['high'] })),
341
+ { cli: 'codex', model: 'gpt-6.1-sol', name: 'Sol', efforts: ['xhigh'] }]))
342
+ const store = new ControlStore(controlDir, 900000); await store.aiState(initialPreset('claude')); await store.savePreset(opus)
343
+ await store.selectPreset(opus.id, null, true)
344
+ const env = { ...process.env, HOME: root, PATH: binDir + ':' + process.env.PATH, EZ_CONTROL_DIR: controlDir, EZ_AI_CATALOG: 'host' }
345
+ const bin = fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs', import.meta.url))
346
+ const call = (...args: string[]) => spawnSync(process.execPath, [bin, ...args], { env, encoding: 'utf8' })
347
+ const set = call('fallbacks', ...logins.flatMap(p => ['--fallback', `cli=claude,auth-profile=${p}`]), '--fallback', 'cli=codex,model=gpt-6.1-sol,effort=xhigh')
348
+ assert.equal(set.status, 0, set.stderr)
349
+ assert.deepEqual(JSON.parse(set.stdout).order, ['claude · opus · high', ...logins.map(p => `claude@${p} · opus · high`), 'codex · gpt-6.1-sol · xhigh'])
350
+ assert.notEqual(call('fallbacks', '--fallback', 'cli=claude,auth-profile=missing').status, 0, 'an unprovisioned login is refused')
351
+ assert.notEqual(call('fallbacks', '--fallback', 'cli=codex,model=unknown').status, 0, 'an unadvertised model is refused')
352
+ assert.equal((await store.status()).ai!.fallbacks!.length, 4)
353
+ assert.equal(JSON.parse(call('telemetry').stdout).runTelemetry, 'off')
354
+ assert.equal(JSON.parse(call('telemetry', 'on').stdout).runTelemetry, 'on'); assert.equal((await store.status()).runTelemetry, true)
355
+ assert.equal(JSON.parse(call('telemetry', 'off').stdout).runTelemetry, 'off')
356
+ assert.equal(call('fallbacks', '--clear').status, 0); assert.equal((await store.status()).ai!.fallbacks, undefined)
357
+ } finally { await rm(root, { recursive: true, force: true }) }
358
+ })
@@ -16,7 +16,7 @@ const start=id=>event('turn/started',{turn:{id,status:'inProgress'}});
16
16
  const end=id=>event('turn/completed',{turn:{id,status:'completed'}});
17
17
  let reads=0;
18
18
  rl.on('line',line=>{const q=JSON.parse(line);if(!q.id)return;
19
- if(q.method==='initialize')return send({id:q.id,result:{}});
19
+ if(q.method==='initialize' || q.method==='account/rateLimits/read')return send({id:q.id,result:{}});
20
20
  if(q.method==='thread/start')return send({id:q.id,result:{thread:{id:'native-test'}}});
21
21
  if(q.method==='thread/goal/set')return send({id:q.id,error:{message:'Transport must not create goals'}});
22
22
  if(q.method==='turn/start'){
@@ -135,3 +135,65 @@ test('steady-state owner checks and session pins write nothing to control/', asy
135
135
  assert.equal(after.mtimeMs, before.mtimeMs)
136
136
  assert.equal(after.size, before.size)
137
137
  }))
138
+
139
+ test('scheduled login health is atomic, owner-bound and retained without changing AI selection',async()=>fixture(async(store,advance,directory)=>{
140
+ const {initialPreset}=await import('../src/ai.js')
141
+ await store.requestPairing(101,101);const owner=await store.approveOwner(101)
142
+ await store.aiState(initialPreset('claude'))
143
+ const before=(await store.status()).ai,preset={...initialPreset('claude'),model:'opus',authProfile:'claude2'}
144
+ await store.recordSetupOutcome(owner,{preset,quotaScope:'forged',outcome:'completed'})
145
+ const resetAt='2026-01-01T01:00:00.000Z'
146
+ await store.recordSetupOutcome(owner,{preset,quotaScope:'forged',outcome:'failed',category:'quota-after-work-began',resetAt})
147
+ advance(1000)
148
+ await store.recordSetupOutcome(owner,{preset,quotaScope:'forged',outcome:'completed'})
149
+ let health=await store.scheduledSetupHealth(owner)
150
+ assert.equal(health['claude@claude2'].resetAt,resetAt,'a concurrent older success cannot clear a future reset')
151
+ assert.equal(health.forged,undefined,'scope is derived from the saved native setup')
152
+ assert.deepEqual((await store.status()).ai,before)
153
+ assert.deepEqual(await new ControlStore(directory,60_000).scheduledSetupHealth(owner),health)
154
+ advance(3600_000)
155
+ await store.recordSetupOutcome(owner,{preset,quotaScope:'claude@claude2',outcome:'completed'})
156
+ health=await store.scheduledSetupHealth(owner);assert.equal(health['claude@claude2'].resetAt,undefined)
157
+ await store.revokeOwner()
158
+ await assert.rejects(store.recordSetupOutcome(owner,{preset,quotaScope:'claude@claude2',outcome:'completed'}),/revoked/)
159
+ assert.equal((await store.status()).setupHealth,undefined)
160
+ }))
161
+
162
+
163
+ test('unknown quota resets back off per login and explicit retry clears the owner cooldown', async t => {
164
+ const dir=await mkdtemp(path.join(tmpdir(),'ez-quota-backoff-'))
165
+ t.after(()=>rm(dir,{recursive:true,force:true}))
166
+ let now=Date.now()
167
+ const store=new ControlStore(dir,1000,()=>now)
168
+ await store.requestPairing(101,101);await store.approveOwner(101)
169
+ const owner=(await store.status()).owner!
170
+ const preset={id:'c',name:'Claude',cli:'claude',model:'opus'}
171
+ for (const minutes of [5,15,30,60,60]) {
172
+ await store.recordSetupOutcome(owner,{preset,quotaScope:'claude',outcome:'failed',category:'quota'})
173
+ const health=(await store.scheduledSetupHealth(owner)).claude
174
+ assert.equal(Date.parse(health.retryAt!)-now,minutes*60000)
175
+ await store.recordSetupOutcome(owner,{preset,quotaScope:'claude',outcome:'failed',category:'quota'})
176
+ assert.equal((await store.scheduledSetupHealth(owner)).claude.retryAt,health.retryAt,'concurrent failures share the same check')
177
+ now=Date.parse(health.retryAt!)+1
178
+ }
179
+ await assert.rejects(store.clearQuotaWait({...owner,generation:'00000000-0000-4000-8000-000000000000'},['claude']),/revoked/)
180
+ await store.clearQuotaWait(owner,['claude'])
181
+ assert.equal((await store.scheduledSetupHealth(owner)).claude.retryAt,undefined)
182
+ })
183
+
184
+
185
+ test('a reset elapsed during shutdown gets one prompt probe, then stale reset reports back off', async () => fixture(async (store,advance) => {
186
+ await store.requestPairing(101,101);await store.approveOwner(101)
187
+ const owner=(await store.status()).owner!,preset={id:'c',name:'Codex',cli:'codex',model:'gpt-6-sol'}
188
+ const now=Date.parse('2026-01-01T00:00:00.000Z'),resetAt=new Date(now-1000).toISOString()
189
+ const attempt={preset,quotaScope:'codex',outcome:'failed' as const,category:'quota' as const,resetAt}
190
+ await store.recordSetupOutcome(owner,attempt)
191
+ let health=(await store.scheduledSetupHealth(owner)).codex
192
+ assert.equal(Date.parse(health.retryAt!)-now,1000)
193
+ assert.equal(health.quotaFailures,0)
194
+ advance(1000)
195
+ await store.recordSetupOutcome(owner,attempt)
196
+ health=(await store.scheduledSetupHealth(owner)).codex
197
+ assert.equal(Date.parse(health.retryAt!)-now-1000,5*60000)
198
+ assert.equal(health.quotaFailures,1)
199
+ }))
@@ -406,7 +406,7 @@ test('Claude runs with agent-bound configuration, shared login and the agent dir
406
406
  t.after(() => rm(root, { recursive: true, force: true }))
407
407
  const bin = path.join(root, 'bin'), workspace = path.join(root, 'mind'), controlDir = path.join(root, 'control'), shared = path.join(root, 'shared')
408
408
  await mkdir(bin); await mkdir(workspace)
409
- await writeFile(path.join(bin, 'claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('observed.json',JSON.stringify({args:process.argv.slice(2),config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,prompt:require('fs').readFileSync(0,'utf8')}))`, { mode: 0o700 })
409
+ await writeFile(path.join(bin, 'claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('observed.json',JSON.stringify({args:process.argv.slice(2),config:process.env.CLAUDE_CONFIG_DIR,storage:process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,token:process.env.CLAUDE_CODE_OAUTH_TOKEN,bgWait:process.env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS,prompt:require('fs').readFileSync(0,'utf8')}))`, { mode: 0o700 })
410
410
  const previous = { PATH: process.env.PATH, EZ_ISOLATION: process.env.EZ_ISOLATION, EZ_EXECUTOR_TRANSPORT: process.env.EZ_EXECUTOR_TRANSPORT }
411
411
  t.after(() => { for (const [key, value] of Object.entries(previous)) value === undefined ? delete process.env[key] : process.env[key] = value })
412
412
  process.env.PATH = bin + path.delimiter + process.env.PATH
@@ -420,6 +420,7 @@ test('Claude runs with agent-bound configuration, shared login and the agent dir
420
420
  const observed = JSON.parse(await readFile(path.join(workspace, 'observed.json'), 'utf8'))
421
421
  assert.equal(observed.config, path.join(controlDir, 'cli', 'claude'))
422
422
  assert.equal(observed.storage, isolation ? undefined : '')
423
+ assert.equal(observed.bgWait, '0')
423
424
  assert.equal(observed.prompt, 'hello')
424
425
  for (const directory of [controlDir, shared]) assert.equal(observed.args[observed.args.indexOf(directory) - 1], '--add-dir')
425
426
  assert.deepEqual(observed.args.slice(-4), ['--model', 'opus', '--effort', 'high'])
@@ -49,6 +49,47 @@ test('isolated installations cannot start host transport', async () => {
49
49
  abort.abort()
50
50
  })
51
51
 
52
+ test('isolated Codex execution uses the bound sandbox setting, never a job override', async () => {
53
+ const previous = { container:process.env.EZ_CONTAINER_EXECUTOR, hostname:process.env.HOSTNAME, sandbox:process.env.EZ_CODEX_SANDBOX }
54
+ try {
55
+ for (const [isolation, container, sandbox, cli, expected] of [
56
+ ['isolated','1','external','codex','external'],
57
+ ['isolated','1','','codex',undefined],
58
+ ['host-capable','','external','codex',undefined],
59
+ ['isolated','1','external','grok',undefined],
60
+ ] as const) {
61
+ process.env.EZ_CONTAINER_EXECUTOR=container;process.env.HOSTNAME='fixture-container';process.env.EZ_CODEX_SANDBOX=sandbox
62
+ const root=await mkdtemp(path.join(tmpdir(),'ez-bound-sandbox-')),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
63
+ const directory=path.join(controlDir,'host-executor'),abort=new AbortController()
64
+ let server:Promise<void>|undefined,captured:Parameters<typeof EXECUTOR_REGISTRY.codex.buildArgs>[0]|undefined
65
+ try {
66
+ await mkdir(workspace);await mkdir(directory,{recursive:true});await ownerRun(controlDir,'tg_42')
67
+ server=serveHostExecutor({cli,isolation,agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,async(_texts,options)=>{
68
+ captured=options
69
+ const child=spawn(process.execPath,['-e','setTimeout(()=>process.exit(0),20)'],{stdio:['pipe','pipe','pipe']})
70
+ return {child,cleanup:async()=>{},stdout:''}
71
+ },async()=>[])
72
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
73
+ await readFile(path.join(directory,'heartbeat.json'))
74
+ await writeFile(path.join(directory,'tg_42.request.json'),JSON.stringify({texts:['test'],options:{cli,codexSandbox:'external'}}))
75
+ let events=''
76
+ for(let n=0;n<300;n++){try{events=await readFile(path.join(directory,'tg_42.events'),'utf8');if(events.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
77
+ assert.match(events,/"stream":"exit","code":0/)
78
+ assert.ok(captured)
79
+ assert.equal(captured.codexSandbox,expected,`${isolation}/${cli}/${sandbox || 'unset'}`)
80
+ if(cli==='codex'){
81
+ const args=EXECUTOR_REGISTRY.codex.buildArgs(captured,'','test')
82
+ assert.equal(args[args.indexOf('--sandbox')+1],expected ? 'danger-full-access' : 'workspace-write')
83
+ }
84
+ } finally {abort.abort();await server;await rm(root,{recursive:true,force:true})}
85
+ }
86
+ } finally {
87
+ for(const [key,value] of [['EZ_CONTAINER_EXECUTOR',previous.container],['HOSTNAME',previous.hostname],['EZ_CODEX_SANDBOX',previous.sandbox]]){
88
+ if(value===undefined)delete process.env[key!];else process.env[key!]=value
89
+ }
90
+ }
91
+ })
92
+
52
93
  test('host bindings reject invalid opencode provider scopes before touching state', async () => {
53
94
  const abort=new AbortController()
54
95
  for (const opencodeProviders of [['BAD NAME'], [], ['ok', 'ok'], new Array(17).fill('ok')]) {
@@ -2,6 +2,8 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { appendFile, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
+ import { spawn } from 'node:child_process'
6
+ import { once } from 'node:events'
5
7
  import { join } from 'node:path'
6
8
  import type { Update } from 'grammy/types'
7
9
  import { createRelay } from '../src/index.js'
@@ -134,3 +136,55 @@ test('adoption fails closed for unknown, invalid, stale or corrupt handoff recor
134
136
  assert.equal(await runs.get('r_schedule_local'), null)
135
137
  } finally { await local.stop() }
136
138
  })
139
+
140
+ test('an adopted scheduled run continues a terminal quota limit on its next saved login', async t => {
141
+ const { root, dir, next, host, files, owner } = await fixture(t)
142
+ const scheduler = new Scheduler(dir), first = new RunStore(dir)
143
+ const primary = { ...initialPreset('claude'), model: 'opus', effort: 'xhigh' }
144
+ const profiles = ['claude2', 'claude3'].map((authProfile, i) => ({ ...primary, id: `fallback-${i + 1}`, authProfile }))
145
+ const saved = await scheduler.save({ id: 'quota', name: 'Quota', text: 'Saved work', owner, enabled: true,
146
+ trigger: { at: '2027-01-01T00:00:00Z' }, execution: { sessionId: '00000000-0000-4000-8000-000000000000', preset: primary, fallbacks: profiles } }, true)
147
+ const run = await scheduler.trigger(saved.id, saved.revision, 'adopt-quota', owner, first)
148
+ const before = createRelay(config(dir, root))
149
+ t.after(() => before.stop())
150
+ before.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
151
+ for (const profile of profiles) await mkdir(join(dir, 'cli', 'profiles', profile.authProfile, 'claude'), { recursive: true })
152
+ const event = (text: unknown) => JSON.stringify({ stream: 'stdout', text: JSON.stringify(text) + '\n' }) + '\n'
153
+ await before.drainSources()
154
+ await until(async () => (await files()).includes(run.id + '.request.json'))
155
+ await rename(join(host, run.id + '.request.json'), join(host, run.id + '.running.json'))
156
+ await appendFile(join(host, run.id + '.events'), event({ type: 'assistant', error: 'rate_limit' }) + JSON.stringify({ stream: 'exit', code: 1 }) + '\n')
157
+ await rm(join(host, run.id + '.running.json'))
158
+ await until(async () => (await first.get(run.id))?.attempts?.length === 2 && (await files()).includes(run.id + '.request.json'))
159
+ await rename(join(host, run.id + '.request.json'), join(host, run.id + '.running.json'))
160
+ await appendFile(join(host, run.id + '.events'), event({ type: 'assistant', message: { content: [{ type: 'tool_use' }] } }))
161
+ const partialSession = (await first.get(run.id))!.attempts![1].sessionId!
162
+ await before.stop()
163
+
164
+ const launches: { authProfile?: string; model?: string; effort?: string; runId: string; sessionId?: string; texts: string[] }[] = []
165
+ const after = createRelay(config(next, root), async (texts, options) => {
166
+ launches.push({ authProfile: options.authProfile, model: options.model, effort: options.effort, runId: options.runId, sessionId: options.sessionId, texts })
167
+ const child = spawn(process.execPath, ['-e', 'process.stdout.write(JSON.stringify({type:"ez.startup",workBegan:true})+String.fromCharCode(10));setTimeout(()=>process.exit(0),20)'])
168
+ await once(child, 'spawn')
169
+ return { child, cleanup: async () => {}, stdout: '' }
170
+ }), runs = new RunStore(next)
171
+ after.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
172
+ try {
173
+ await after.adoptHandoff()
174
+ assert.equal(launches.length, 0, 'adoption only attaches to the existing native session')
175
+ await appendFile(join(host, run.id + '.events'), event({ type: 'rate_limit_event', rate_limit_info: { status: 'rejected', resetsAt: 2114380800 } }) +
176
+ event({ type: 'assistant', error: 'rate_limit' }) + JSON.stringify({ stream: 'exit', code: 1 }) + '\n')
177
+ await rm(join(host, run.id + '.running.json'))
178
+ await until(async () => ['completed', 'failed'].includes((await runs.get(run.id))!.status))
179
+ const ended = (await runs.get(run.id))!
180
+ assert.equal(ended.status, 'completed')
181
+ assert.deepEqual(ended.attempts!.map(a => [a.quotaScope, a.outcome, a.category]), [
182
+ ['claude', 'failed', 'quota'], ['claude@claude2', 'failed', 'quota-after-work-began'], ['claude@claude3', 'completed', undefined],
183
+ ])
184
+ assert.equal(launches.length, 1)
185
+ assert.deepEqual([launches[0].authProfile, launches[0].model, launches[0].effort, launches[0].runId], ['claude3', 'opus', 'xhigh', run.id])
186
+ assert.notEqual(launches[0].sessionId, partialSession)
187
+ assert.match(launches[0].texts.at(-1)!, new RegExp(`began work in native session ${partialSession}.*That work may be partly applied`))
188
+ assert.equal((await runs.list()).length, 1)
189
+ } finally { await after.stop() }
190
+ })