diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index b6631082..e3468c16 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -37,10 +37,11 @@ export function resolveJuliaProject(configValue: string): string { } /** Build the OPENCODE_CONFIG_CONTENT value: a config object that injects the - * amico AGENTS.md as a top-level `instructions` entry AND auto-allows the + * amico AGENTS.md as a top-level `instructions` entry, PINS the team model + * (AMICODE_MODEL — never the user's global model), and auto-allows the * permissions the solve workflow needs. opencode MERGES this over the user's - * global config (model/provider preserved) for every session, independent of - * the session's working directory. + * global config (non-conflicting user keys preserved) for every session, + * independent of the session's working directory. * * Why the `permission` block: the agent reads the bundled template at an * absolute path *outside* the session's working dir and writes scratch to @@ -71,10 +72,21 @@ export function resolveJuliaProject(configValue: string): string { * `webfetch` is intentionally NOT set — the solve flow never fetches a URL. */ const SCRATCH_DIR = "/tmp/amicode-work"; // matches AGENTS.md step 2/3 +/** The ONE model every amicode session runs — pinned, never read from the + * user's global opencode config. Rationale: sessions were silently falling + * back to opencode's free anonymous model (`big-pickle`) on machines with an + * empty global config, which is exactly the "free-model phrasing variance" + * behind protocol breaks (invented Other options, JSON drift) — and two + * teammates debugging the same interview must be running the same model. + * Requires provider auth (`opencode auth login`); with none, sessions fail + * loudly instead of silently degrading. */ +export const AMICODE_MODEL = "anthropic/claude-sonnet-5"; + export function buildOpencodeConfigContent(agentsPath: string, templatePath: string, runsRoot: string): string { const templatesDir = path.dirname(templatePath); return JSON.stringify({ $schema: "https://opencode.ai/config.json", + model: AMICODE_MODEL, // pinned team-wide — overrides any user global model (deep-merge, ours wins) instructions: [agentsPath], permission: { bash: "allow", diff --git a/packages/extension/test/opencode_config.test.ts b/packages/extension/test/opencode_config.test.ts index f1961796..997a3570 100644 --- a/packages/extension/test/opencode_config.test.ts +++ b/packages/extension/test/opencode_config.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from import { tmpdir, homedir } from 'node:os' import { join } from 'node:path' import { execFileSync } from 'node:child_process' -import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from '../src/opencode_config' +import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, AMICODE_MODEL } from '../src/opencode_config' function fakeExtRoot(): string { const root = mkdtempSync(join(tmpdir(), 'extroot-')) @@ -87,11 +87,12 @@ describe('buildOpencodeConfigContent', () => { // binary isn't present (e.g. minimal CI before `fetch:opencode`). const OC_BIN = join(__dirname, '..', 'vendor', 'opencode', `${process.platform}-${process.arch}`, 'opencode') describe.skipIf(!existsSync(OC_BIN))('opencode config injection + merge (1.17.3)', () => { - it('injects instructions/permission AND preserves the user global model + permission', () => { + it('injects instructions/permission, PINS the team model over the user global, preserves other user keys', () => { const home = mkdtempSync(join(tmpdir(), 'ochome-')) mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) - // A user global config with a distinctive model + permission key — both must - // survive the deep-merge under OPENCODE_CONFIG_CONTENT. + // A user global config with a CONFLICTING model + a distinctive permission + // key: the model must LOSE to the pin (one team, one model — no silent + // big-pickle fallback, no per-machine drift); the permission key must survive. writeFileSync(join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ model: 'anthropic/claude-sonnet-4-6', permission: { doom_loop: 'deny' } })) const agentsPath = join(home, 'AGENTS.md') // the exact file our `instructions` must point at @@ -108,8 +109,9 @@ describe.skipIf(!existsSync(OC_BIN))('opencode config injection + merge (1.17.3) // the runs-root grant survives the real deep-merge — the agent's post-solve // FINISHED/result.toml/run.log read-backs must not "ask" on every run: expect(cfg.permission.external_directory[join(home, '.amico', 'runs', 'default') + '/**']).toBe('allow') - // the user's global config SURVIVED the deep-merge: - expect(cfg.model).toBe('anthropic/claude-sonnet-4-6') // provider/model preserved (Q129 needs this) + // the team model pin BEATS the user's global model (verified on the real binary): + expect(cfg.model).toBe(AMICODE_MODEL) + // …while non-conflicting user keys still survive the deep-merge: expect(cfg.permission.doom_loop).toBe('deny') // user permission key preserved (#22) }) })