@marcusrbrown/infra 0.7.0 → 0.8.1

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,223 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {z} from 'zod'
4
+
5
+ export type SmokeResult =
6
+ | {kind: 'pass'; message: string; runUrl: string}
7
+ | {kind: 'fail'; message: string; runUrl: string}
8
+ | {kind: 'unverified'; message: string; runUrl?: string}
9
+
10
+ // Zod schemas for gh CLI JSON output — single source of truth.
11
+ const baselineRunSchema = z.array(z.object({databaseId: z.number()}))
12
+
13
+ const ghRunEntrySchema = z.object({
14
+ databaseId: z.number(),
15
+ status: z.string(),
16
+ conclusion: z.string().nullable(),
17
+ url: z.string(),
18
+ createdAt: z.string(),
19
+ })
20
+
21
+ // Exported for tests only.
22
+ export type GhRunEntry = z.infer<typeof ghRunEntrySchema>
23
+
24
+ // Exported for tests only. Override poll delays and trigger time.
25
+ export interface SmokeTestInternals {
26
+ /** Override per-poll delay in ms (default: real backoff schedule). */
27
+ _testDelayMs?: number
28
+ /** Override the trigger timestamp used for createdAt heuristic. */
29
+ _testTriggerTime?: Date
30
+ }
31
+
32
+ /**
33
+ * Run an optional post-mutation smoke test by triggering `fro-bot.yaml` and
34
+ * polling for completion. Returns a non-blocking SmokeResult — never throws.
35
+ *
36
+ * Race-safe: captures the highest existing run ID before triggering, then
37
+ * filters poll results to runs with databaseId > baselineId. When no prior
38
+ * runs exist (baselineId=null), falls back to createdAt > triggerTime.
39
+ *
40
+ * Known edge case: if a concurrent contributor's run appears before ours,
41
+ * we pick the highest databaseId above baseline — this may misattribute
42
+ * the concurrent run as ours. This is the best heuristic available without
43
+ * a run-specific correlation ID from `gh workflow run`.
44
+ */
45
+ export async function runSmokeTest(
46
+ repo: string,
47
+ _model: string,
48
+ internals: SmokeTestInternals = {},
49
+ ): Promise<SmokeResult> {
50
+ const BACKOFF_MS = [5_000, 15_000, 30_000, 60_000, 60_000]
51
+ const delayFn = async (ms: number): Promise<void> => {
52
+ if (internals._testDelayMs !== undefined) {
53
+ if (internals._testDelayMs > 0) {
54
+ await new Promise(resolve => setTimeout(resolve, internals._testDelayMs))
55
+ }
56
+ return
57
+ }
58
+ await new Promise(resolve => setTimeout(resolve, ms))
59
+ }
60
+
61
+ const repoUrl = `https://github.com/${repo}`
62
+
63
+ // ── Step 1: Capture baseline run ID ──────────────────────────────────────
64
+ let baselineId: number | null = null
65
+ try {
66
+ const baselineChild = Bun.spawn(
67
+ ['gh', 'run', 'list', '--workflow=fro-bot.yaml', '--repo', repo, '--limit', '1', '--json', 'databaseId'],
68
+ {stdout: 'pipe', stderr: 'pipe', env: process.env},
69
+ )
70
+ const [baselineStdout, , baselineExit] = await Promise.all([
71
+ new Response(baselineChild.stdout).text(),
72
+ new Response(baselineChild.stderr).text(),
73
+ baselineChild.exited,
74
+ ])
75
+ if (baselineExit === 0) {
76
+ const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout))
77
+ if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
78
+ baselineId = parseResult.data[0].databaseId
79
+ }
80
+ // If schema validation fails, baselineId stays null — we'll use createdAt heuristic
81
+ }
82
+ // If baseline call fails, baselineId stays null — we'll use createdAt heuristic
83
+ } catch {
84
+ // Network/parse error — continue with null baseline
85
+ }
86
+
87
+ // ── Step 2: Trigger the workflow ──────────────────────────────────────────
88
+ const triggerTime = internals._testTriggerTime ?? new Date()
89
+
90
+ const triggerChild = Bun.spawn(
91
+ ['gh', 'workflow', 'run', 'fro-bot.yaml', '--repo', repo, '-f', 'prompt=reply with exactly: ack'],
92
+ {stdout: 'pipe', stderr: 'pipe', env: process.env},
93
+ )
94
+ const [, triggerStderr, triggerExit] = await Promise.all([
95
+ new Response(triggerChild.stdout).text(),
96
+ new Response(triggerChild.stderr).text(),
97
+ triggerChild.exited,
98
+ ])
99
+
100
+ if (triggerExit !== 0) {
101
+ const redacted = triggerStderr.slice(0, 200)
102
+ return {kind: 'unverified', message: `gh workflow run failed: ${redacted}`}
103
+ }
104
+
105
+ // ── Step 3: Poll for the new run ──────────────────────────────────────────
106
+ let latestMatchedRun: GhRunEntry | undefined
107
+
108
+ for (const BACKOFF_M of BACKOFF_MS) {
109
+ await delayFn(BACKOFF_M ?? 60_000)
110
+
111
+ let pollRuns: GhRunEntry[] = []
112
+ try {
113
+ const pollChild = Bun.spawn(
114
+ [
115
+ 'gh',
116
+ 'run',
117
+ 'list',
118
+ '--workflow=fro-bot.yaml',
119
+ '--repo',
120
+ repo,
121
+ '--limit',
122
+ '5',
123
+ '--json',
124
+ 'databaseId,status,conclusion,url,createdAt',
125
+ ],
126
+ {stdout: 'pipe', stderr: 'pipe', env: process.env},
127
+ )
128
+ const [pollStdout, , pollExit] = await Promise.all([
129
+ new Response(pollChild.stdout).text(),
130
+ new Response(pollChild.stderr).text(),
131
+ pollChild.exited,
132
+ ])
133
+ if (pollExit === 0) {
134
+ const rawParsed: unknown = JSON.parse(pollStdout)
135
+ if (Array.isArray(rawParsed)) {
136
+ // Validate each entry independently so a single malformed row does not
137
+ // discard the whole batch — dropping a legitimate matching run would be
138
+ // worse than skipping the bad entry.
139
+ pollRuns = rawParsed.flatMap(entry => {
140
+ const entryResult = ghRunEntrySchema.safeParse(entry)
141
+ return entryResult.success ? [entryResult.data] : []
142
+ })
143
+ }
144
+ // Non-array payload or all entries malformed → pollRuns stays [] — retry on next poll
145
+ }
146
+ } catch {
147
+ // Parse/network error — retry on next poll
148
+ continue
149
+ }
150
+
151
+ // Filter to runs triggered after our baseline
152
+ const candidates = pollRuns.filter(run => {
153
+ if (baselineId !== null) {
154
+ return run.databaseId > baselineId
155
+ }
156
+ // No baseline: use createdAt heuristic
157
+ return new Date(run.createdAt) > triggerTime
158
+ })
159
+
160
+ if (candidates.length === 0) {
161
+ // Our run not visible yet — keep polling
162
+ continue
163
+ }
164
+
165
+ // Pick the highest databaseId from candidates (most likely ours)
166
+ const matched = candidates.reduce((best, run) => (run.databaseId > best.databaseId ? run : best))
167
+ latestMatchedRun = matched
168
+
169
+ const {status, conclusion, url: runUrl} = matched
170
+
171
+ // Environment approval gate (simplified — the pending+approval branch was dead).
172
+ // When status=pending, gh returns conclusion=null, so /approval/i.test('') = false.
173
+ // Only status=waiting triggers the env-approval gate.
174
+ if (status === 'waiting') {
175
+ return {kind: 'unverified', message: `Workflow requires environment approval at ${runUrl}`, runUrl}
176
+ }
177
+
178
+ if (status === 'completed') {
179
+ if (conclusion === 'success') {
180
+ // Best-effort log grep for "ack"
181
+ let logNote = ''
182
+ try {
183
+ const logChild = Bun.spawn(['gh', 'run', 'view', String(matched.databaseId), '--log', '--repo', repo], {
184
+ stdout: 'pipe',
185
+ stderr: 'pipe',
186
+ env: process.env,
187
+ })
188
+ const [logStdout, , logExit] = await Promise.all([
189
+ new Response(logChild.stdout).text(),
190
+ new Response(logChild.stderr).text(),
191
+ logChild.exited,
192
+ ])
193
+ if (logExit !== 0) {
194
+ logNote = ' (log fetch failed, but run conclusion is success)'
195
+ } else if (!/\back\b/i.test(logStdout)) {
196
+ logNote = ' (log fetch succeeded but "ack" not found in output)'
197
+ }
198
+ } catch {
199
+ logNote = ' (log fetch failed, but run conclusion is success)'
200
+ }
201
+ return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
202
+ }
203
+
204
+ return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
205
+ }
206
+
207
+ // Still in progress (queued, in_progress, pending) — continue polling
208
+ }
209
+
210
+ // All polls exhausted
211
+ if (latestMatchedRun) {
212
+ return {
213
+ kind: 'unverified',
214
+ message: `Smoke test did not complete in 5 minutes; check ${latestMatchedRun.url}`,
215
+ runUrl: latestMatchedRun.url,
216
+ }
217
+ }
218
+
219
+ return {
220
+ kind: 'unverified',
221
+ message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`,
222
+ }
223
+ }
@@ -0,0 +1,358 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {describe, expect, it} from 'bun:test'
4
+
5
+ import {
6
+ collectCollisions,
7
+ formatTemplateSummary,
8
+ getHarnessTemplate,
9
+ harnessSchema,
10
+ stripTrailingSlash,
11
+ type HarnessTemplate,
12
+ type SecretAssignment,
13
+ type VariableAssignment,
14
+ } from './templates'
15
+
16
+ // ── stripTrailingSlash (new unit tests — RED first) ──────────────────────────
17
+
18
+ describe('stripTrailingSlash', () => {
19
+ it('removes a trailing slash', () => {
20
+ expect(stripTrailingSlash('https://example.com/')).toBe('https://example.com')
21
+ })
22
+
23
+ it('is a no-op when there is no trailing slash', () => {
24
+ expect(stripTrailingSlash('https://example.com')).toBe('https://example.com')
25
+ })
26
+
27
+ it('returns empty string for empty input', () => {
28
+ expect(stripTrailingSlash('')).toBe('')
29
+ })
30
+
31
+ it('removes only one trailing slash', () => {
32
+ expect(stripTrailingSlash('https://example.com//')).toBe('https://example.com/')
33
+ })
34
+ })
35
+
36
+ // ── harnessSchema (new unit test) ────────────────────────────────────────────
37
+
38
+ describe('harnessSchema', () => {
39
+ it('parses valid harness values', () => {
40
+ expect(harnessSchema.parse('opencode')).toBe('opencode')
41
+ expect(harnessSchema.parse('claude-code')).toBe('claude-code')
42
+ expect(harnessSchema.parse('generic')).toBe('generic')
43
+ })
44
+
45
+ it('throws on invalid harness value', () => {
46
+ expect(() => harnessSchema.parse('unknown')).toThrow()
47
+ })
48
+ })
49
+
50
+ // ── collectCollisions (new unit tests — RED first) ───────────────────────────
51
+
52
+ describe('collectCollisions', () => {
53
+ const template: HarnessTemplate = {
54
+ secrets: [
55
+ {name: 'OPENCODE_AUTH_JSON', value: 'x'},
56
+ {name: 'OPENCODE_CONFIG', value: 'y'},
57
+ ],
58
+ variables: [{name: 'FRO_BOT_MODEL', value: 'z'}],
59
+ }
60
+
61
+ it('returns empty array when no collisions', () => {
62
+ expect(collectCollisions(template, [], [])).toEqual([])
63
+ })
64
+
65
+ it('detects a colliding secret', () => {
66
+ const result = collectCollisions(template, ['OPENCODE_AUTH_JSON'], [])
67
+ expect(result).toContain('secret OPENCODE_AUTH_JSON')
68
+ })
69
+
70
+ it('detects a colliding variable', () => {
71
+ const result = collectCollisions(template, [], ['FRO_BOT_MODEL'])
72
+ expect(result).toContain('variable FRO_BOT_MODEL')
73
+ })
74
+
75
+ it('detects multiple collisions', () => {
76
+ const result = collectCollisions(template, ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG'], ['FRO_BOT_MODEL'])
77
+ expect(result).toHaveLength(3)
78
+ expect(result).toContain('secret OPENCODE_AUTH_JSON')
79
+ expect(result).toContain('secret OPENCODE_CONFIG')
80
+ expect(result).toContain('variable FRO_BOT_MODEL')
81
+ })
82
+
83
+ it('ignores non-colliding existing names', () => {
84
+ const result = collectCollisions(template, ['SOME_OTHER_SECRET'], ['SOME_OTHER_VAR'])
85
+ expect(result).toEqual([])
86
+ })
87
+ })
88
+
89
+ // ── formatTemplateSummary (new unit tests — RED first) ───────────────────────
90
+
91
+ describe('formatTemplateSummary', () => {
92
+ it('lists secrets and variables with their prefixes', () => {
93
+ const template: HarnessTemplate = {
94
+ secrets: [{name: 'OPENCODE_AUTH_JSON', value: 'x'}],
95
+ variables: [{name: 'FRO_BOT_MODEL', value: 'y'}],
96
+ }
97
+ const summary = formatTemplateSummary(template)
98
+ expect(summary).toContain('secret OPENCODE_AUTH_JSON')
99
+ expect(summary).toContain('variable FRO_BOT_MODEL')
100
+ })
101
+
102
+ it('returns only secret lines when no variables', () => {
103
+ const template: HarnessTemplate = {
104
+ secrets: [{name: 'ANTHROPIC_API_KEY', value: 'x'}],
105
+ variables: [],
106
+ }
107
+ const summary = formatTemplateSummary(template)
108
+ expect(summary).toBe('- secret ANTHROPIC_API_KEY')
109
+ })
110
+
111
+ it('returns empty string for empty template', () => {
112
+ const template: HarnessTemplate = {secrets: [], variables: []}
113
+ expect(formatTemplateSummary(template)).toBe('')
114
+ })
115
+ })
116
+
117
+ // ── getHarnessTemplate (pure-move from setup.test.ts L162) ───────────────────
118
+
119
+ describe('getHarnessTemplate', () => {
120
+ it('returns the expected OpenCode secret and variable names', () => {
121
+ const template = getHarnessTemplate('opencode')
122
+
123
+ expect(template.secrets.map((entry: SecretAssignment) => entry.name)).toEqual([
124
+ 'OPENCODE_AUTH_JSON',
125
+ 'OPENCODE_CONFIG',
126
+ 'OMO_PROVIDERS',
127
+ ])
128
+ expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
129
+ })
130
+
131
+ it('uses a provider-prefixed FRO_BOT_MODEL default value', () => {
132
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
133
+ const modelEntry = template.variables.find((entry: VariableAssignment) => entry.name === 'FRO_BOT_MODEL')
134
+
135
+ expect(modelEntry?.value).toMatch(/^anthropic\//)
136
+ })
137
+
138
+ it('uses the expected OMO_PROVIDERS default value', () => {
139
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
140
+ const providersEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OMO_PROVIDERS')
141
+
142
+ expect(providersEntry?.value).toBe('claude-max20')
143
+ })
144
+
145
+ it('writes an OPENCODE_CONFIG baseURL with the /v1 suffix', () => {
146
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
147
+ const configEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_CONFIG')
148
+ const parsed = JSON.parse(configEntry?.value ?? '{}')
149
+
150
+ expect(parsed.provider.anthropic.options.baseURL).toMatch(/\/v1$/)
151
+ })
152
+
153
+ it('writes OPENCODE_AUTH_JSON with type=api and the supplied key', () => {
154
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test-key'})
155
+ const authEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_AUTH_JSON')
156
+ const parsed = JSON.parse(authEntry?.value ?? '{}')
157
+
158
+ expect(parsed.anthropic).toEqual({type: 'api', key: 'sk-test-key'})
159
+ })
160
+ })
161
+
162
+ // ── getHarnessTemplate provider-aware (pure-move from setup.test.ts L508) ────
163
+
164
+ describe('getHarnessTemplate provider-aware', () => {
165
+ // Frozen byte-identical string for the anthropic-only regression test.
166
+ // This is the EXACT output of getHarnessTemplate('opencode', {keyValue: 'test-key'})
167
+ // baseline anthropic-only output. Any change to this string is a breaking regression.
168
+ const ANTHROPIC_ONLY_AUTH_JSON = '{"anthropic":{"type":"api","key":"test-key"}}'
169
+ const ANTHROPIC_ONLY_CONFIG = '{"provider":{"anthropic":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}'
170
+
171
+ describe('regression — anthropic-only (byte-identical)', () => {
172
+ it('no providers/model args → OPENCODE_AUTH_JSON is byte-identical to baseline', () => {
173
+ const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
174
+ const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
175
+
176
+ expect(authEntry?.value).toBe(ANTHROPIC_ONLY_AUTH_JSON)
177
+ })
178
+
179
+ it('no providers/model args → OPENCODE_CONFIG is byte-identical to baseline', () => {
180
+ const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
181
+ const configEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
182
+
183
+ expect(configEntry?.value).toBe(ANTHROPIC_ONLY_CONFIG)
184
+ })
185
+
186
+ it('no providers/model args → OMO_PROVIDERS is claude-max20', () => {
187
+ const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
188
+ const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
189
+
190
+ expect(entry?.value).toBe('claude-max20')
191
+ })
192
+
193
+ it('no providers/model args → FRO_BOT_MODEL is anthropic/claude-sonnet-4-6', () => {
194
+ const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
195
+ const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
196
+
197
+ expect(entry?.value).toBe('anthropic/claude-sonnet-4-6')
198
+ })
199
+
200
+ it("explicit providers: ['anthropic'] → byte-identical to no-providers output", () => {
201
+ const baseline = getHarnessTemplate('opencode', {keyValue: 'test-key'})
202
+ const explicit = getHarnessTemplate('opencode', {keyValue: 'test-key', providers: ['anthropic']})
203
+
204
+ const baselineAuth = baseline.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
205
+ const explicitAuth = explicit.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
206
+ expect(explicitAuth?.value).toBe(baselineAuth?.value)
207
+
208
+ const baselineConfig = baseline.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
209
+ const explicitConfig = explicit.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
210
+ expect(explicitConfig?.value).toBe(baselineConfig?.value)
211
+ })
212
+ })
213
+
214
+ describe('openai-only provider', () => {
215
+ it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → correct OPENCODE_AUTH_JSON", () => {
216
+ const template = getHarnessTemplate('opencode', {
217
+ keyValue: 'sk-openai-key',
218
+ providers: ['openai'],
219
+ model: 'openai/gpt-5.4-mini',
220
+ })
221
+ const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
222
+
223
+ expect(authEntry?.value).toBe('{"openai":{"type":"api","key":"sk-openai-key"}}')
224
+ })
225
+
226
+ it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → correct OPENCODE_CONFIG", () => {
227
+ const template = getHarnessTemplate('opencode', {
228
+ keyValue: 'sk-openai-key',
229
+ providers: ['openai'],
230
+ model: 'openai/gpt-5.4-mini',
231
+ })
232
+ const configEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
233
+
234
+ expect(configEntry?.value).toBe('{"provider":{"openai":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}')
235
+ })
236
+
237
+ it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → OMO_PROVIDERS is openai", () => {
238
+ const template = getHarnessTemplate('opencode', {
239
+ keyValue: 'sk-openai-key',
240
+ providers: ['openai'],
241
+ model: 'openai/gpt-5.4-mini',
242
+ })
243
+ const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
244
+
245
+ expect(entry?.value).toBe('openai')
246
+ })
247
+
248
+ it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → FRO_BOT_MODEL is openai/gpt-5.4-mini", () => {
249
+ const template = getHarnessTemplate('opencode', {
250
+ keyValue: 'sk-openai-key',
251
+ providers: ['openai'],
252
+ model: 'openai/gpt-5.4-mini',
253
+ })
254
+ const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
255
+
256
+ expect(entry?.value).toBe('openai/gpt-5.4-mini')
257
+ })
258
+
259
+ it("providers: ['openai'] with no model → uses PROVIDER_DEFAULTS openai/gpt-5.4-mini", () => {
260
+ const template = getHarnessTemplate('opencode', {
261
+ keyValue: 'sk-openai-key',
262
+ providers: ['openai'],
263
+ })
264
+ const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
265
+
266
+ expect(entry?.value).toBe('openai/gpt-5.4-mini')
267
+ })
268
+ })
269
+
270
+ describe('dual-provider (anthropic + openai)', () => {
271
+ it("providers: ['anthropic', 'openai'] → OPENCODE_AUTH_JSON has anthropic-first key order", () => {
272
+ const template = getHarnessTemplate('opencode', {
273
+ keyValue: 'sk-dual',
274
+ providers: ['anthropic', 'openai'],
275
+ model: 'openai/gpt-5.4-mini',
276
+ })
277
+ const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
278
+
279
+ expect(authEntry?.value).toBe(
280
+ '{"anthropic":{"type":"api","key":"sk-dual"},"openai":{"type":"api","key":"sk-dual"}}',
281
+ )
282
+ })
283
+
284
+ it("providers: ['anthropic', 'openai'] → OPENCODE_CONFIG has anthropic-first key order", () => {
285
+ const template = getHarnessTemplate('opencode', {
286
+ keyValue: 'sk-dual',
287
+ providers: ['anthropic', 'openai'],
288
+ model: 'openai/gpt-5.4-mini',
289
+ })
290
+ const configEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
291
+
292
+ expect(configEntry?.value).toBe(
293
+ '{"provider":{"anthropic":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}},"openai":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}',
294
+ )
295
+ })
296
+
297
+ it("providers: ['anthropic', 'openai'] → OMO_PROVIDERS is claude-max20,openai", () => {
298
+ const template = getHarnessTemplate('opencode', {
299
+ keyValue: 'sk-dual',
300
+ providers: ['anthropic', 'openai'],
301
+ model: 'openai/gpt-5.4-mini',
302
+ })
303
+ const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
304
+
305
+ expect(entry?.value).toBe('claude-max20,openai')
306
+ })
307
+
308
+ it("providers: ['anthropic', 'openai'] → FRO_BOT_MODEL is the supplied model", () => {
309
+ const template = getHarnessTemplate('opencode', {
310
+ keyValue: 'sk-dual',
311
+ providers: ['anthropic', 'openai'],
312
+ model: 'openai/gpt-5.4-mini',
313
+ })
314
+ const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
315
+
316
+ expect(entry?.value).toBe('openai/gpt-5.4-mini')
317
+ })
318
+
319
+ it("providers: ['openai', 'anthropic'] (openai first) → output is still anthropic-first in JSON", () => {
320
+ const template = getHarnessTemplate('opencode', {
321
+ keyValue: 'sk-dual',
322
+ providers: ['openai', 'anthropic'],
323
+ model: 'openai/gpt-5.4-mini',
324
+ })
325
+ const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
326
+
327
+ expect(authEntry?.value).toBe(
328
+ '{"anthropic":{"type":"api","key":"sk-dual"},"openai":{"type":"api","key":"sk-dual"}}',
329
+ )
330
+ })
331
+
332
+ it('multiple providers with no model → throws "model required when multiple providers selected"', () => {
333
+ expect(() =>
334
+ getHarnessTemplate('opencode', {
335
+ keyValue: 'sk-dual',
336
+ providers: ['anthropic', 'openai'],
337
+ }),
338
+ ).toThrow('model required when multiple providers selected')
339
+ })
340
+ })
341
+
342
+ describe('edge cases', () => {
343
+ it('keyValue: undefined → auth-json key is sk-placeholder', () => {
344
+ const template = getHarnessTemplate('opencode', {providers: ['anthropic']})
345
+ const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
346
+ const parsed = JSON.parse(authEntry?.value ?? '{}')
347
+
348
+ expect(parsed.anthropic.key).toBe('sk-placeholder')
349
+ })
350
+
351
+ it('claude-code harness is unaffected by providers/model args', () => {
352
+ const template = getHarnessTemplate('claude-code', {keyValue: 'sk-cc'})
353
+
354
+ expect(template.secrets).toHaveLength(1)
355
+ expect(template.secrets[0]?.name).toBe('ANTHROPIC_API_KEY')
356
+ })
357
+ })
358
+ })