opencode-pilot 0.25.1 → 0.27.0
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/Formula/opencode-pilot.rb +2 -2
- package/examples/config.yaml +8 -1
- package/package.json +1 -1
- package/service/actions.js +98 -8
- package/service/poll-service.js +24 -7
- package/service/repo-config.js +13 -1
- package/test/unit/actions.test.js +173 -0
- package/test/unit/poll-service.test.js +102 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
class OpencodePilot < Formula
|
|
2
2
|
desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
|
|
3
3
|
homepage "https://github.com/athal7/opencode-pilot"
|
|
4
|
-
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.
|
|
5
|
-
sha256 "
|
|
4
|
+
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.26.0.tar.gz"
|
|
5
|
+
sha256 "2dc50f9d92e16bcbc3e5956f26bf53d3613c0f3ce756204ea050bf0faba4d9f4"
|
|
6
6
|
license "MIT"
|
|
7
7
|
|
|
8
8
|
depends_on "node"
|
package/examples/config.yaml
CHANGED
|
@@ -38,7 +38,7 @@ sources:
|
|
|
38
38
|
# PR presets have detect_stacks: true by default, enabling session reuse
|
|
39
39
|
# across stacked PRs (where one PR's head branch = another's base branch)
|
|
40
40
|
- preset: github/review-requests
|
|
41
|
-
# Per-source model override (takes precedence over defaults.model)
|
|
41
|
+
# Per-source model override (takes precedence over defaults.model and repos.*.model)
|
|
42
42
|
# model: anthropic/claude-haiku-3.5
|
|
43
43
|
|
|
44
44
|
# PRs needing attention (conflicts OR human feedback)
|
|
@@ -94,9 +94,16 @@ sources:
|
|
|
94
94
|
|
|
95
95
|
# Explicit repo mappings (overrides repos_dir auto-discovery)
|
|
96
96
|
# Only needed if a repo isn't in repos_dir or needs custom settings
|
|
97
|
+
# Repos can also specify per-directory overrides for model, agent, and prompt.
|
|
98
|
+
# Priority: source override > repo override > defaults
|
|
97
99
|
# repos:
|
|
98
100
|
# myorg/backend:
|
|
99
101
|
# path: ~/code/backend
|
|
102
|
+
# # Per-directory model override (overrides defaults.model, overridden by source.model)
|
|
103
|
+
# # model: anthropic/claude-sonnet-4-20250514
|
|
104
|
+
# myorg/legacy-service:
|
|
105
|
+
# path: ~/code/legacy-service
|
|
106
|
+
# model: anthropic/claude-haiku-3.5 # use cheaper model for this repo
|
|
100
107
|
|
|
101
108
|
# Cleanup config (optional, sensible defaults)
|
|
102
109
|
# cleanup:
|
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -35,6 +35,62 @@ import os from "os";
|
|
|
35
35
|
// These are generous upper bounds — if exceeded, the server is genuinely stuck.
|
|
36
36
|
export const HEADER_TIMEOUT_MS = 60_000;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Read model configuration from a target directory's OpenCode config file.
|
|
40
|
+
*
|
|
41
|
+
* Looks for `.opencode/opencode.json` (or `.jsonc`) in `dir`. If an `agentName`
|
|
42
|
+
* is provided, the per-agent model (`.agent.<name>.model`) takes precedence
|
|
43
|
+
* over the global `.model` value. Returns `{}` when no file is found or the
|
|
44
|
+
* file cannot be parsed.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} dir - Absolute path to the target project directory
|
|
47
|
+
* @param {string} [agentName] - Optional agent name for per-agent model lookup
|
|
48
|
+
* @returns {{ model?: string }} Extracted config fields
|
|
49
|
+
*/
|
|
50
|
+
export function readTargetOpencodeConfig(dir, agentName) {
|
|
51
|
+
const candidates = [
|
|
52
|
+
path.join(dir, '.opencode', 'opencode.json'),
|
|
53
|
+
path.join(dir, '.opencode', 'opencode.jsonc'),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
let raw;
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
if (existsSync(candidate)) {
|
|
59
|
+
try {
|
|
60
|
+
raw = readFileSync(candidate, 'utf-8');
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!raw) return {};
|
|
69
|
+
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
// Strip single-line comments for .jsonc support before parsing
|
|
73
|
+
const stripped = raw.replace(/\/\/[^\n]*/g, '');
|
|
74
|
+
parsed = JSON.parse(stripped);
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = {};
|
|
80
|
+
|
|
81
|
+
// Per-agent model takes priority over global model
|
|
82
|
+
const agentModel = agentName && parsed.agent?.[agentName]?.model;
|
|
83
|
+
const globalModel = parsed.model;
|
|
84
|
+
|
|
85
|
+
if (agentModel) {
|
|
86
|
+
result.model = agentModel;
|
|
87
|
+
} else if (globalModel) {
|
|
88
|
+
result.model = globalModel;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
38
94
|
/**
|
|
39
95
|
* Parse a slash command from the beginning of a prompt
|
|
40
96
|
* Returns null if the prompt doesn't start with a slash command
|
|
@@ -363,13 +419,34 @@ export function buildPromptFromTemplate(templateName, item, templatesDir) {
|
|
|
363
419
|
|
|
364
420
|
/**
|
|
365
421
|
* Merge source, repo config, and defaults into action config
|
|
366
|
-
* Priority: source > repo > defaults
|
|
367
|
-
* @param {object} source - Source configuration
|
|
422
|
+
* Priority: explicit source > repo > defaults
|
|
423
|
+
* @param {object} source - Source configuration (may include _explicit tracking from normalizeSource)
|
|
368
424
|
* @param {object} repoConfig - Repository configuration
|
|
369
425
|
* @param {object} defaults - Default configuration
|
|
426
|
+
* @param {object} [targetDirConfig] - Optional model config read from the target dir's opencode config.
|
|
427
|
+
* Used as a low-priority fallback: only applied when no model is set by source, repo, or defaults.
|
|
370
428
|
* @returns {object} Merged action config
|
|
371
429
|
*/
|
|
372
|
-
export function getActionConfig(source, repoConfig, defaults) {
|
|
430
|
+
export function getActionConfig(source, repoConfig, defaults, targetDirConfig = {}) {
|
|
431
|
+
// _explicit tracks fields set directly on the source (not inherited from defaults).
|
|
432
|
+
// When _explicit is absent (e.g., tests constructing source objects directly), fall
|
|
433
|
+
// back to treating non-undefined source fields as explicit.
|
|
434
|
+
const explicit = source._explicit;
|
|
435
|
+
|
|
436
|
+
const resolveField = (field) => {
|
|
437
|
+
if (explicit) {
|
|
438
|
+
if (explicit[field] !== undefined) return explicit[field];
|
|
439
|
+
if (repoConfig[field] !== undefined) return repoConfig[field];
|
|
440
|
+
if (defaults[field] !== undefined) return defaults[field];
|
|
441
|
+
return targetDirConfig[field];
|
|
442
|
+
}
|
|
443
|
+
// No tracking: source wins, then repo, then defaults, then target dir
|
|
444
|
+
if (source[field] !== undefined) return source[field];
|
|
445
|
+
if (repoConfig[field] !== undefined) return repoConfig[field];
|
|
446
|
+
if (defaults[field] !== undefined) return defaults[field];
|
|
447
|
+
return targetDirConfig[field];
|
|
448
|
+
};
|
|
449
|
+
|
|
373
450
|
return {
|
|
374
451
|
// Defaults first
|
|
375
452
|
...defaults,
|
|
@@ -380,11 +457,11 @@ export function getActionConfig(source, repoConfig, defaults) {
|
|
|
380
457
|
...(defaults.session || {}),
|
|
381
458
|
...(repoConfig.session || {}),
|
|
382
459
|
},
|
|
383
|
-
//
|
|
384
|
-
...(
|
|
385
|
-
...(
|
|
386
|
-
...(
|
|
387
|
-
...(
|
|
460
|
+
// Operational fields with correct priority: explicit source > repo > defaults
|
|
461
|
+
...(resolveField('prompt') && { prompt: resolveField('prompt') }),
|
|
462
|
+
...(resolveField('agent') && { agent: resolveField('agent') }),
|
|
463
|
+
...(resolveField('model') && { model: resolveField('model') }),
|
|
464
|
+
...(resolveField('working_dir') && { working_dir: resolveField('working_dir') }),
|
|
388
465
|
};
|
|
389
466
|
}
|
|
390
467
|
|
|
@@ -960,6 +1037,8 @@ async function executeInDirectory(serverUrl, sessionCtx, item, config, options =
|
|
|
960
1037
|
command: apiCommand,
|
|
961
1038
|
directory: cwd,
|
|
962
1039
|
dryRun: true,
|
|
1040
|
+
...(config.model && { model: config.model }),
|
|
1041
|
+
...(config.agent && { agent: config.agent }),
|
|
963
1042
|
};
|
|
964
1043
|
}
|
|
965
1044
|
|
|
@@ -1005,6 +1084,17 @@ export async function executeAction(item, config, options = {}) {
|
|
|
1005
1084
|
}
|
|
1006
1085
|
|
|
1007
1086
|
const baseCwd = expandPath(workingDir);
|
|
1087
|
+
|
|
1088
|
+
// Apply target directory's opencode config as a low-priority fallback for model.
|
|
1089
|
+
// This reads <baseCwd>/.opencode/opencode.json and uses its model settings only
|
|
1090
|
+
// when no model is specified in the pilot config (source > repo > defaults).
|
|
1091
|
+
if (!config.model) {
|
|
1092
|
+
const targetDirConfig = readTargetOpencodeConfig(baseCwd, config.agent);
|
|
1093
|
+
if (targetDirConfig.model) {
|
|
1094
|
+
debug(`executeAction: using target dir model=${targetDirConfig.model} from ${baseCwd}/.opencode/opencode.json`);
|
|
1095
|
+
config = { ...config, model: targetDirConfig.model };
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1008
1098
|
|
|
1009
1099
|
// Discover running opencode server for this directory
|
|
1010
1100
|
const discoverFn = options.discoverServer || discoverOpencodeServer;
|
package/service/poll-service.js
CHANGED
|
@@ -32,12 +32,29 @@ export function hasToolConfig(source) {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Build action config from source and repo config
|
|
35
|
-
*
|
|
36
|
-
* @param {object} source - Source configuration
|
|
35
|
+
* Priority for operational fields: explicit source > repo > defaults (baked into source)
|
|
36
|
+
* @param {object} source - Source configuration (may include _explicit tracking of explicitly-set fields)
|
|
37
37
|
* @param {object} repoConfig - Repository configuration
|
|
38
38
|
* @returns {object} Merged action config
|
|
39
39
|
*/
|
|
40
40
|
export function buildActionConfigFromSource(source, repoConfig) {
|
|
41
|
+
// _explicit tracks fields set directly on the source (not inherited from defaults).
|
|
42
|
+
// When _explicit is absent (e.g., tests constructing source objects directly), fall
|
|
43
|
+
// back to treating source fields as explicit.
|
|
44
|
+
const explicit = source._explicit;
|
|
45
|
+
|
|
46
|
+
// Resolve each operational field using priority: explicit source > repo > defaults (source)
|
|
47
|
+
const resolveField = (field) => {
|
|
48
|
+
if (explicit) {
|
|
49
|
+
// Normalization tracked explicit fields: use them in priority order
|
|
50
|
+
if (explicit[field] !== undefined) return explicit[field];
|
|
51
|
+
if (repoConfig[field] !== undefined) return repoConfig[field];
|
|
52
|
+
return source[field]; // defaults (baked into source)
|
|
53
|
+
}
|
|
54
|
+
// No tracking available (e.g., raw source in tests): source wins, then repo
|
|
55
|
+
return source[field] !== undefined ? source[field] : repoConfig[field];
|
|
56
|
+
};
|
|
57
|
+
|
|
41
58
|
return {
|
|
42
59
|
// Repo config as base
|
|
43
60
|
...repoConfig,
|
|
@@ -45,11 +62,11 @@ export function buildActionConfigFromSource(source, repoConfig) {
|
|
|
45
62
|
repo_path: source.working_dir || repoConfig.path || repoConfig.repo_path,
|
|
46
63
|
// Session from source or repo
|
|
47
64
|
session: source.session || repoConfig.session || {},
|
|
48
|
-
//
|
|
49
|
-
...(
|
|
50
|
-
...(
|
|
51
|
-
...(
|
|
52
|
-
...(
|
|
65
|
+
// Operational fields with correct priority
|
|
66
|
+
...(resolveField('prompt') && { prompt: resolveField('prompt') }),
|
|
67
|
+
...(resolveField('agent') && { agent: resolveField('agent') }),
|
|
68
|
+
...(resolveField('model') && { model: resolveField('model') }),
|
|
69
|
+
...(resolveField('working_dir') && { working_dir: resolveField('working_dir') }),
|
|
53
70
|
...(source.worktree_name && { worktree_name: source.worktree_name }),
|
|
54
71
|
};
|
|
55
72
|
}
|
package/service/repo-config.js
CHANGED
|
@@ -230,10 +230,22 @@ function normalizeSource(source, defaults) {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
// Apply defaults (source values take precedence)
|
|
233
|
-
|
|
233
|
+
const merged = {
|
|
234
234
|
...defaults,
|
|
235
235
|
...normalized,
|
|
236
236
|
};
|
|
237
|
+
|
|
238
|
+
// Track which operational fields were explicitly set in the source (not inherited from defaults).
|
|
239
|
+
// This allows downstream config builders to apply the correct priority:
|
|
240
|
+
// explicit source > repo > defaults
|
|
241
|
+
merged._explicit = {};
|
|
242
|
+
for (const field of ['model', 'agent', 'prompt', 'working_dir']) {
|
|
243
|
+
if (normalized[field] !== undefined) {
|
|
244
|
+
merged._explicit[field] = normalized[field];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return merged;
|
|
237
249
|
}
|
|
238
250
|
|
|
239
251
|
/**
|
|
@@ -281,6 +281,121 @@ Check for bugs and security issues.`;
|
|
|
281
281
|
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
describe('readTargetOpencodeConfig', () => {
|
|
285
|
+
test('returns global model from .opencode/opencode.json', async () => {
|
|
286
|
+
const { readTargetOpencodeConfig } = await import('../../service/actions.js');
|
|
287
|
+
|
|
288
|
+
const opencodeDir = join(tempDir, '.opencode');
|
|
289
|
+
mkdirSync(opencodeDir);
|
|
290
|
+
writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
|
|
291
|
+
model: 'anthropic/claude-haiku-3.5'
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
const result = readTargetOpencodeConfig(tempDir);
|
|
295
|
+
assert.strictEqual(result.model, 'anthropic/claude-haiku-3.5');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('returns per-agent model from .opencode/opencode.json', async () => {
|
|
299
|
+
const { readTargetOpencodeConfig } = await import('../../service/actions.js');
|
|
300
|
+
|
|
301
|
+
const opencodeDir = join(tempDir, '.opencode');
|
|
302
|
+
mkdirSync(opencodeDir);
|
|
303
|
+
writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
|
|
304
|
+
model: 'anthropic/claude-sonnet-4-20250514',
|
|
305
|
+
agent: {
|
|
306
|
+
plan: { model: 'anthropic/claude-haiku-3.5' }
|
|
307
|
+
}
|
|
308
|
+
}));
|
|
309
|
+
|
|
310
|
+
const result = readTargetOpencodeConfig(tempDir, 'plan');
|
|
311
|
+
assert.strictEqual(result.model, 'anthropic/claude-haiku-3.5');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('falls back to global model when agent has no model', async () => {
|
|
315
|
+
const { readTargetOpencodeConfig } = await import('../../service/actions.js');
|
|
316
|
+
|
|
317
|
+
const opencodeDir = join(tempDir, '.opencode');
|
|
318
|
+
mkdirSync(opencodeDir);
|
|
319
|
+
writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
|
|
320
|
+
model: 'anthropic/claude-opus-4',
|
|
321
|
+
agent: {
|
|
322
|
+
plan: { temperature: 0.5 }
|
|
323
|
+
}
|
|
324
|
+
}));
|
|
325
|
+
|
|
326
|
+
const result = readTargetOpencodeConfig(tempDir, 'plan');
|
|
327
|
+
assert.strictEqual(result.model, 'anthropic/claude-opus-4');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('returns empty object when no .opencode/opencode.json exists', async () => {
|
|
331
|
+
const { readTargetOpencodeConfig } = await import('../../service/actions.js');
|
|
332
|
+
|
|
333
|
+
const result = readTargetOpencodeConfig(tempDir);
|
|
334
|
+
assert.deepStrictEqual(result, {});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('returns empty object when .opencode/opencode.json is malformed', async () => {
|
|
338
|
+
const { readTargetOpencodeConfig } = await import('../../service/actions.js');
|
|
339
|
+
|
|
340
|
+
const opencodeDir = join(tempDir, '.opencode');
|
|
341
|
+
mkdirSync(opencodeDir);
|
|
342
|
+
writeFileSync(join(opencodeDir, 'opencode.json'), 'not valid json {{{');
|
|
343
|
+
|
|
344
|
+
const result = readTargetOpencodeConfig(tempDir);
|
|
345
|
+
assert.deepStrictEqual(result, {});
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('getActionConfig with target dir opencode config', () => {
|
|
350
|
+
test('uses target-dir model when no pilot model is set', async () => {
|
|
351
|
+
const { getActionConfig } = await import('../../service/actions.js');
|
|
352
|
+
|
|
353
|
+
const source = { name: 'my-issues' };
|
|
354
|
+
const repoConfig = { path: tempDir };
|
|
355
|
+
const defaults = {};
|
|
356
|
+
const targetDirConfig = { model: 'anthropic/claude-haiku-3.5' };
|
|
357
|
+
|
|
358
|
+
const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
|
|
359
|
+
assert.strictEqual(config.model, 'anthropic/claude-haiku-3.5');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('pilot model overrides target-dir model', async () => {
|
|
363
|
+
const { getActionConfig } = await import('../../service/actions.js');
|
|
364
|
+
|
|
365
|
+
const source = { name: 'my-issues', model: 'anthropic/claude-opus-4' };
|
|
366
|
+
const repoConfig = { path: tempDir };
|
|
367
|
+
const defaults = {};
|
|
368
|
+
const targetDirConfig = { model: 'anthropic/claude-haiku-3.5' };
|
|
369
|
+
|
|
370
|
+
const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
|
|
371
|
+
assert.strictEqual(config.model, 'anthropic/claude-opus-4');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('defaults model overrides target-dir model', async () => {
|
|
375
|
+
const { getActionConfig } = await import('../../service/actions.js');
|
|
376
|
+
|
|
377
|
+
const source = { name: 'my-issues' };
|
|
378
|
+
const repoConfig = { path: tempDir };
|
|
379
|
+
const defaults = { model: 'anthropic/claude-sonnet-4-20250514' };
|
|
380
|
+
const targetDirConfig = { model: 'anthropic/claude-haiku-3.5' };
|
|
381
|
+
|
|
382
|
+
const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
|
|
383
|
+
assert.strictEqual(config.model, 'anthropic/claude-sonnet-4-20250514');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('target-dir model used when only defaults have no model', async () => {
|
|
387
|
+
const { getActionConfig } = await import('../../service/actions.js');
|
|
388
|
+
|
|
389
|
+
const source = { name: 'my-issues' };
|
|
390
|
+
const repoConfig = { path: tempDir };
|
|
391
|
+
const defaults = {};
|
|
392
|
+
const targetDirConfig = { model: 'openai/gpt-4o' };
|
|
393
|
+
|
|
394
|
+
const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
|
|
395
|
+
assert.strictEqual(config.model, 'openai/gpt-4o');
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
284
399
|
describe('buildCommand', () => {
|
|
285
400
|
test('builds display string for API call', async () => {
|
|
286
401
|
const { buildCommand } = await import('../../service/actions.js');
|
|
@@ -989,6 +1104,64 @@ Check for bugs and security issues.`;
|
|
|
989
1104
|
assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
|
|
990
1105
|
'Result should include worktree directory');
|
|
991
1106
|
});
|
|
1107
|
+
|
|
1108
|
+
test('uses target dir opencode config model when no pilot model set (dry run)', async () => {
|
|
1109
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
1110
|
+
|
|
1111
|
+
// Write .opencode/opencode.json into the temp project dir
|
|
1112
|
+
const opencodeDir = join(tempDir, '.opencode');
|
|
1113
|
+
mkdirSync(opencodeDir, { recursive: true });
|
|
1114
|
+
writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
|
|
1115
|
+
model: 'anthropic/claude-haiku-3.5'
|
|
1116
|
+
}));
|
|
1117
|
+
|
|
1118
|
+
const item = { number: 1, title: 'Fix bug' };
|
|
1119
|
+
const config = {
|
|
1120
|
+
path: tempDir,
|
|
1121
|
+
prompt: 'default',
|
|
1122
|
+
agent: 'plan',
|
|
1123
|
+
// No model set in pilot config
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
1127
|
+
const result = await executeAction(item, config, {
|
|
1128
|
+
dryRun: true,
|
|
1129
|
+
discoverServer: mockDiscoverServer,
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
assert.ok(result.dryRun);
|
|
1133
|
+
assert.strictEqual(result.model, 'anthropic/claude-haiku-3.5',
|
|
1134
|
+
'Should use target dir model as fallback');
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test('target dir opencode config model does not override pilot model (dry run)', async () => {
|
|
1138
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
1139
|
+
|
|
1140
|
+
// Write .opencode/opencode.json into the temp project dir
|
|
1141
|
+
const opencodeDir = join(tempDir, '.opencode');
|
|
1142
|
+
mkdirSync(opencodeDir, { recursive: true });
|
|
1143
|
+
writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
|
|
1144
|
+
model: 'anthropic/claude-haiku-3.5'
|
|
1145
|
+
}));
|
|
1146
|
+
|
|
1147
|
+
const item = { number: 1, title: 'Fix bug' };
|
|
1148
|
+
const config = {
|
|
1149
|
+
path: tempDir,
|
|
1150
|
+
prompt: 'default',
|
|
1151
|
+
agent: 'plan',
|
|
1152
|
+
model: 'anthropic/claude-opus-4', // Pilot model is set — should win
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
1156
|
+
const result = await executeAction(item, config, {
|
|
1157
|
+
dryRun: true,
|
|
1158
|
+
discoverServer: mockDiscoverServer,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
assert.ok(result.dryRun);
|
|
1162
|
+
assert.strictEqual(result.model, 'anthropic/claude-opus-4',
|
|
1163
|
+
'Pilot model should override target dir model');
|
|
1164
|
+
});
|
|
992
1165
|
});
|
|
993
1166
|
|
|
994
1167
|
describe('createSessionViaApi', () => {
|
|
@@ -378,5 +378,107 @@ sources:
|
|
|
378
378
|
// Should use source working_dir since repo not in config
|
|
379
379
|
assert.strictEqual(actionConfig.working_dir, '~/default/path');
|
|
380
380
|
});
|
|
381
|
+
|
|
382
|
+
test('repo model overrides defaults model', async () => {
|
|
383
|
+
const config = `
|
|
384
|
+
defaults:
|
|
385
|
+
model: anthropic/claude-haiku-3.5
|
|
386
|
+
|
|
387
|
+
repos:
|
|
388
|
+
myorg/backend:
|
|
389
|
+
path: ~/code/backend
|
|
390
|
+
model: anthropic/claude-sonnet-4-20250514
|
|
391
|
+
|
|
392
|
+
sources:
|
|
393
|
+
- preset: github/my-issues
|
|
394
|
+
`;
|
|
395
|
+
writeFileSync(configPath, config);
|
|
396
|
+
|
|
397
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
398
|
+
const { buildActionConfigForItem } = await import('../../service/poll-service.js');
|
|
399
|
+
loadRepoConfig(configPath);
|
|
400
|
+
const sources = getSources();
|
|
401
|
+
|
|
402
|
+
const item = {
|
|
403
|
+
repository: { nameWithOwner: 'myorg/backend' },
|
|
404
|
+
number: 123,
|
|
405
|
+
title: 'Fix bug',
|
|
406
|
+
url: 'https://github.com/myorg/backend/issues/123'
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const actionConfig = buildActionConfigForItem(sources[0], item);
|
|
410
|
+
|
|
411
|
+
// Repo model should override the default model
|
|
412
|
+
assert.strictEqual(actionConfig.model, 'anthropic/claude-sonnet-4-20250514',
|
|
413
|
+
'repo model should override defaults.model');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('explicit source model overrides repo model', async () => {
|
|
417
|
+
const config = `
|
|
418
|
+
defaults:
|
|
419
|
+
model: anthropic/claude-haiku-3.5
|
|
420
|
+
|
|
421
|
+
repos:
|
|
422
|
+
myorg/backend:
|
|
423
|
+
path: ~/code/backend
|
|
424
|
+
model: anthropic/claude-sonnet-4-20250514
|
|
425
|
+
|
|
426
|
+
sources:
|
|
427
|
+
- preset: github/my-issues
|
|
428
|
+
model: anthropic/claude-opus-4
|
|
429
|
+
`;
|
|
430
|
+
writeFileSync(configPath, config);
|
|
431
|
+
|
|
432
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
433
|
+
const { buildActionConfigForItem } = await import('../../service/poll-service.js');
|
|
434
|
+
loadRepoConfig(configPath);
|
|
435
|
+
const sources = getSources();
|
|
436
|
+
|
|
437
|
+
const item = {
|
|
438
|
+
repository: { nameWithOwner: 'myorg/backend' },
|
|
439
|
+
number: 123,
|
|
440
|
+
title: 'Fix bug',
|
|
441
|
+
url: 'https://github.com/myorg/backend/issues/123'
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const actionConfig = buildActionConfigForItem(sources[0], item);
|
|
445
|
+
|
|
446
|
+
// Explicit source model should win over repo and defaults
|
|
447
|
+
assert.strictEqual(actionConfig.model, 'anthropic/claude-opus-4',
|
|
448
|
+
'explicit source model should override repo model and defaults');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('defaults model used when neither source nor repo specifies model', async () => {
|
|
452
|
+
const config = `
|
|
453
|
+
defaults:
|
|
454
|
+
model: anthropic/claude-haiku-3.5
|
|
455
|
+
|
|
456
|
+
repos:
|
|
457
|
+
myorg/backend:
|
|
458
|
+
path: ~/code/backend
|
|
459
|
+
|
|
460
|
+
sources:
|
|
461
|
+
- preset: github/my-issues
|
|
462
|
+
`;
|
|
463
|
+
writeFileSync(configPath, config);
|
|
464
|
+
|
|
465
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
466
|
+
const { buildActionConfigForItem } = await import('../../service/poll-service.js');
|
|
467
|
+
loadRepoConfig(configPath);
|
|
468
|
+
const sources = getSources();
|
|
469
|
+
|
|
470
|
+
const item = {
|
|
471
|
+
repository: { nameWithOwner: 'myorg/backend' },
|
|
472
|
+
number: 123,
|
|
473
|
+
title: 'Fix bug',
|
|
474
|
+
url: 'https://github.com/myorg/backend/issues/123'
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const actionConfig = buildActionConfigForItem(sources[0], item);
|
|
478
|
+
|
|
479
|
+
// Should fall back to defaults.model
|
|
480
|
+
assert.strictEqual(actionConfig.model, 'anthropic/claude-haiku-3.5',
|
|
481
|
+
'defaults model should be used when neither source nor repo sets model');
|
|
482
|
+
});
|
|
381
483
|
});
|
|
382
484
|
});
|