diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..2cfca54c --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-07-30T18:32:11.164Z for PR creation at branch issue-285-531ca23a5284 for issue https://github.com/link-assistant/agent/issues/285 \ No newline at end of file diff --git a/js/.changeset/fix-unknown-model-prompt-branding-version.md b/js/.changeset/fix-unknown-model-prompt-branding-version.md new file mode 100644 index 00000000..5bc8f12e --- /dev/null +++ b/js/.changeset/fix-unknown-model-prompt-branding-version.md @@ -0,0 +1,17 @@ +--- +'@link-assistant/agent': patch +--- + +Fix three defects that made a session's behaviour unattributable (#285): + +- Unknown model ids no longer silently fall back to the without-todo prompt. + Prompt selection is now table-driven with an explicit, logged default + (`anthropic`, which includes the todo/task-tracking discipline). Models that + genuinely break on todo tools can opt out via `AGENT_SYSTEM_PROMPT`. +- The product identity (name, repo, issues and docs URLs) lives in one module + and is substituted into prompts and tool descriptions at render time, so the + agent no longer tells users it is `opencode` or sends bug reports to + `sst/opencode`. +- Session records report the real package version instead of the hard-coded + `agent-cli-1.0.0`; `--version`, the process log and session records now all + read the same value. diff --git a/js/src/branding.ts b/js/src/branding.ts new file mode 100644 index 00000000..586889d4 --- /dev/null +++ b/js/src/branding.ts @@ -0,0 +1,47 @@ +/** + * Single source of truth for the product identity that is exposed to models + * and to users. + * + * Several system prompts in `src/session/prompt/*.txt` are kept byte-identical + * to their upstream (opencode) originals so they can be re-synced without + * conflicts. Instead of editing those files, the identity is substituted at + * render time by {@link applyBranding}, so a rename is one edit here rather + * than one edit per prompt file. See issue #285. + */ +export namespace Branding { + /** Product name as it should appear in prose. */ + export const NAME = 'Agent'; + /** Product name as it appears in a shell (the binary name). */ + export const BINARY = 'agent'; + /** Canonical source repository. */ + export const REPO_URL = 'https://github.com/link-assistant/agent'; + /** Where users should report issues with this tool. */ + export const ISSUES_URL = 'https://github.com/link-assistant/agent/issues'; + /** Where documentation about this tool lives. */ + export const DOCS_URL = 'https://github.com/link-assistant/agent#readme'; + + /** + * Ordered replacements. URLs must be replaced before the bare product name, + * otherwise the name substitution would corrupt the URLs. + */ + const REPLACEMENTS: [RegExp, string][] = [ + [/https:\/\/github\.com\/sst\/opencode\/issues/gi, ISSUES_URL], + [/https:\/\/github\.com\/sst\/opencode/gi, REPO_URL], + [/https:\/\/opencode\.ai\/docs/gi, DOCS_URL], + [/https:\/\/opencode\.ai/gi, DOCS_URL], + [/sst\/opencode/gi, 'link-assistant/agent'], + [/opencode/gi, NAME], + ]; + + /** + * Replace every upstream product reference in `text` with this product's + * identity. Safe to call on text that contains no references. + */ + export function apply(text: string): string { + let result = text; + for (const [pattern, replacement] of REPLACEMENTS) { + result = result.replace(pattern, replacement); + } + return result; + } +} diff --git a/js/src/index.js b/js/src/index.js index fe867b5e..65829d78 100755 --- a/js/src/index.js +++ b/js/src/index.js @@ -46,22 +46,12 @@ import { outputInput, } from './cli/output.ts'; import stripAnsi from 'strip-ansi'; -import { createRequire } from 'module'; -import { readFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; - -const require = createRequire(import.meta.url); -let pkg; -try { - pkg = require('../package.json'); -} catch (_e) { - // Fallback: read package.json directly - const __dirname = dirname(fileURLToPath(import.meta.url)); - const pkgPath = join(__dirname, '../package.json'); - const pkgContent = readFileSync(pkgPath, 'utf8'); - pkg = JSON.parse(pkgContent); -} +import { resolve as resolvePath } from 'path'; +import { VERSION } from './version.ts'; + +// Version comes from the shared module so that `--version`, the process log +// and stored session records can never disagree (#285). +const pkg = { version: VERSION }; // Track if any errors occurred during execution let hasError = false; @@ -226,7 +216,7 @@ async function readSystemMessages(argv) { let appendSystemMessage = argv['append-system-message']; if (argv['system-message-file']) { - const resolvedPath = require('path').resolve( + const resolvedPath = resolvePath( process.cwd(), argv['system-message-file'] ); @@ -242,7 +232,7 @@ async function readSystemMessages(argv) { } if (argv['append-system-message-file']) { - const resolvedPath = require('path').resolve( + const resolvedPath = resolvePath( process.cwd(), argv['append-system-message-file'] ); diff --git a/js/src/session/index.ts b/js/src/session/index.ts index abe98901..69fd697f 100644 --- a/js/src/session/index.ts +++ b/js/src/session/index.ts @@ -14,6 +14,7 @@ import { SessionPrompt } from './prompt'; import { fn } from '../util/fn'; import { Command } from '../command'; import { Snapshot } from '../snapshot'; +import { VERSION } from '../version'; export namespace Session { const log = Log.create({ service: 'session' }); @@ -226,7 +227,7 @@ export namespace Session { }) { const result: Info = { id: Identifier.descending('session', input.id), - version: 'agent-cli-1.0.0', + version: VERSION, projectID: Instance.project.id, directory: input.directory, parentID: input.parentID, diff --git a/js/src/session/system.ts b/js/src/session/system.ts index a6c02956..947d1d56 100644 --- a/js/src/session/system.ts +++ b/js/src/session/system.ts @@ -18,27 +18,105 @@ import PROMPT_SUMMARIZE from './prompt/summarize.txt'; import PROMPT_TITLE from './prompt/title.txt'; import PROMPT_CODEX from './prompt/codex.txt'; import PROMPT_GROK_CODE from './prompt/grok-code.txt'; +import { Branding } from '../branding'; +import { Log } from '../util/log'; export namespace SystemPrompt { + const log = Log.create({ service: 'system-prompt' }); + export function header(providerID: string) { if (providerID.includes('anthropic')) return [PROMPT_ANTHROPIC_SPOOF.trim()]; return []; } + /** Identifiers of the selectable system prompts. */ + export type PromptID = + | 'anthropic' + | 'anthropic-without-todo' + | 'beast' + | 'codex' + | 'gemini' + | 'grok-code' + | 'polaris'; + + const RAW: Record = { + anthropic: PROMPT_ANTHROPIC, + 'anthropic-without-todo': PROMPT_ANTHROPIC_WITHOUT_TODO, + beast: PROMPT_BEAST, + codex: PROMPT_CODEX, + gemini: PROMPT_GEMINI, + 'grok-code': PROMPT_GROK_CODE, + polaris: PROMPT_POLARIS, + }; + + /** + * The prompt used for any model that is not matched by an explicit rule. + * It is the full prompt (with todo/task-tracking discipline); models that + * genuinely break on todo tools must opt out explicitly via the + * `AGENT_SYSTEM_PROMPT` environment variable. See issue #285. + */ + export const DEFAULT_PROMPT_ID: PromptID = 'anthropic'; + + const RULES: { matches: (modelID: string) => boolean; id: PromptID }[] = [ + { matches: (m) => m.includes('gpt-5'), id: 'codex' }, + { + matches: (m) => + m.includes('gpt-') || m.includes('o1') || m.includes('o3'), + id: 'beast', + }, + { matches: (m) => m.includes('gemini-'), id: 'gemini' }, + { matches: (m) => m.includes('claude'), id: 'anthropic' }, + { matches: (m) => m.includes('polaris-alpha'), id: 'polaris' }, + { matches: (m) => m.includes('grok-code'), id: 'grok-code' }, + ]; + + export function isPromptID(value: string): value is PromptID { + return value in RAW; + } + + /** + * Resolve which prompt a model gets, and why. + * + * Resolution order: + * 1. explicit override via the `AGENT_SYSTEM_PROMPT` environment variable; + * 2. an explicit rule matching the model id; + * 3. {@link DEFAULT_PROMPT_ID}. + */ + export function resolve(modelID: string): { id: PromptID; reason: string } { + const override = process.env['AGENT_SYSTEM_PROMPT']?.trim(); + if (override) { + if (isPromptID(override)) + return { id: override, reason: 'AGENT_SYSTEM_PROMPT override' }; + log.warn(() => ({ + message: 'unknown AGENT_SYSTEM_PROMPT value, ignoring', + value: override, + known: Object.keys(RAW), + })); + } + for (const rule of RULES) { + if (rule.matches(modelID)) + return { id: rule.id, reason: `matched model id ${modelID}` }; + } + return { + id: DEFAULT_PROMPT_ID, + reason: `default for unknown model ${modelID}`, + }; + } + + /** Get a prompt by id, with the product identity substituted in (#285). */ + export function text(id: PromptID): string { + return Branding.apply(RAW[id]); + } + export function provider(modelID: string) { - if (modelID.includes('gpt-5')) return [PROMPT_CODEX]; - if ( - modelID.includes('gpt-') || - modelID.includes('o1') || - modelID.includes('o3') - ) - return [PROMPT_BEAST]; - if (modelID.includes('gemini-')) return [PROMPT_GEMINI]; - if (modelID.includes('claude')) return [PROMPT_ANTHROPIC]; - if (modelID.includes('polaris-alpha')) return [PROMPT_POLARIS]; - if (modelID.includes('grok-code')) return [PROMPT_GROK_CODE]; - return [PROMPT_ANTHROPIC_WITHOUT_TODO]; + const resolved = resolve(modelID); + log.info(() => ({ + message: `system prompt: ${resolved.id} (${resolved.reason})`, + prompt: resolved.id, + model: modelID, + })); + return [text(resolved.id)]; } export async function environment() { @@ -149,18 +227,21 @@ export namespace SystemPrompt { export function summarize(providerID: string) { switch (providerID) { case 'anthropic': - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE]; + return [ + PROMPT_ANTHROPIC_SPOOF.trim(), + Branding.apply(PROMPT_SUMMARIZE), + ]; default: - return [PROMPT_SUMMARIZE]; + return [Branding.apply(PROMPT_SUMMARIZE)]; } } export function title(providerID: string) { switch (providerID) { case 'anthropic': - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE]; + return [PROMPT_ANTHROPIC_SPOOF.trim(), Branding.apply(PROMPT_TITLE)]; default: - return [PROMPT_TITLE]; + return [Branding.apply(PROMPT_TITLE)]; } } } diff --git a/js/src/tool/bash.ts b/js/src/tool/bash.ts index 96dad028..18334958 100644 --- a/js/src/tool/bash.ts +++ b/js/src/tool/bash.ts @@ -2,6 +2,7 @@ import z from 'zod'; import { spawn } from 'child_process'; import { Tool } from './tool'; import DESCRIPTION from './bash.txt'; +import { Branding } from '../branding'; import { Log } from '../util/log'; import { Instance } from '../project/instance'; import { lazy } from '../util/lazy'; @@ -53,7 +54,7 @@ const parser = lazy(async () => { }); export const BashTool = Tool.define('bash', { - description: DESCRIPTION, + description: Branding.apply(DESCRIPTION), parameters: z.object({ command: z.string().describe('The command to execute'), timeout: z.number().describe('Optional timeout in milliseconds').optional(), diff --git a/js/src/version.ts b/js/src/version.ts new file mode 100644 index 00000000..ace4d321 --- /dev/null +++ b/js/src/version.ts @@ -0,0 +1,25 @@ +/** + * Single source of truth for the running version. + * + * The version is read from the package manifest, so `--version`, the process + * log and stored session records can never disagree. See issue #285. + */ +import { createRequire } from 'module'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +function read(): string { + try { + const require = createRequire(import.meta.url); + return require('../package.json').version; + } catch { + // Fallback: read package.json directly (e.g. when `require` of JSON is + // unavailable in the current runtime configuration). + const here = dirname(fileURLToPath(import.meta.url)); + return JSON.parse(readFileSync(join(here, '../package.json'), 'utf8')) + .version; + } +} + +export const VERSION: string = read(); diff --git a/js/tests/system-prompt.ts b/js/tests/system-prompt.ts new file mode 100644 index 00000000..bf6c2190 --- /dev/null +++ b/js/tests/system-prompt.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, afterEach } from 'bun:test'; +import { SystemPrompt } from '../src/session/system.ts'; +import { Branding } from '../src/branding.ts'; + +/** + * Regression tests for issue #285: + * + * 1. an unknown model id must not silently get the without-todo prompt; + * 2. no rendered system prompt may tell the model it is `opencode`. + */ + +const UNKNOWN_MODELS = [ + 'formalai/formal-ai', + 'my-org/self-hosted-7b', + 'llama-3.3-70b', +]; + +afterEach(() => { + delete process.env['AGENT_SYSTEM_PROMPT']; +}); + +describe('system prompt selection', () => { + test('unknown model ids resolve to the default (full) prompt', () => { + for (const model of UNKNOWN_MODELS) { + const resolved = SystemPrompt.resolve(model); + expect(resolved.id).toBe(SystemPrompt.DEFAULT_PROMPT_ID); + expect(resolved.reason).toContain('default for unknown model'); + } + }); + + test('unknown model ids get a prompt that includes todo instructions', () => { + for (const model of UNKNOWN_MODELS) { + const [prompt] = SystemPrompt.provider(model); + expect(prompt.toLowerCase()).toContain('todo'); + } + }); + + test('known model ids keep their dedicated prompt', () => { + expect(SystemPrompt.resolve('gpt-5').id).toBe('codex'); + expect(SystemPrompt.resolve('gpt-4o').id).toBe('beast'); + expect(SystemPrompt.resolve('o3-mini').id).toBe('beast'); + expect(SystemPrompt.resolve('gemini-2.5-pro').id).toBe('gemini'); + expect(SystemPrompt.resolve('claude-opus-4').id).toBe('anthropic'); + expect(SystemPrompt.resolve('polaris-alpha').id).toBe('polaris'); + expect(SystemPrompt.resolve('grok-code-fast-1').id).toBe('grok-code'); + }); + + test('AGENT_SYSTEM_PROMPT allows an explicit opt-out', () => { + process.env['AGENT_SYSTEM_PROMPT'] = 'anthropic-without-todo'; + const resolved = SystemPrompt.resolve('formalai/formal-ai'); + expect(resolved.id).toBe('anthropic-without-todo'); + expect(resolved.reason).toContain('override'); + }); + + test('an invalid AGENT_SYSTEM_PROMPT value is ignored', () => { + process.env['AGENT_SYSTEM_PROMPT'] = 'does-not-exist'; + expect(SystemPrompt.resolve('formalai/formal-ai').id).toBe( + SystemPrompt.DEFAULT_PROMPT_ID + ); + }); +}); + +describe('system prompt api surface', () => { + test('header still returns the OAuth spoof for anthropic providers', () => { + const header = SystemPrompt.header('anthropic'); + expect(header).toHaveLength(1); + expect(header[0]).toContain('Claude Code'); + expect(SystemPrompt.header('openai')).toEqual([]); + }); +}); + +describe('product identity in rendered prompts', () => { + const PROMPT_IDS = [ + 'anthropic', + 'anthropic-without-todo', + 'beast', + 'codex', + 'gemini', + 'grok-code', + 'polaris', + ] as const; + + const FORBIDDEN = [/opencode/i, /sst\/opencode/i, /opencode\.ai/i]; + + test('no rendered prompt mentions the upstream product', () => { + for (const id of PROMPT_IDS) { + const rendered = SystemPrompt.text(id); + for (const pattern of FORBIDDEN) { + expect(rendered).not.toMatch(pattern); + } + } + }); + + test('rendered prompts point at this repository for issue reports', () => { + const rendered = SystemPrompt.text('anthropic-without-todo'); + expect(rendered).toContain(Branding.ISSUES_URL); + }); + + test('summarize and title prompts are branded', () => { + for (const prompt of [ + ...SystemPrompt.summarize('openai'), + ...SystemPrompt.title('openai'), + ]) { + expect(prompt).not.toMatch(/opencode/i); + } + }); + + test('branding substitution leaves unrelated text untouched', () => { + expect(Branding.apply('nothing to replace here')).toBe( + 'nothing to replace here' + ); + expect(Branding.apply('https://github.com/sst/opencode/issues')).toBe( + Branding.ISSUES_URL + ); + }); +}); diff --git a/js/tests/version.ts b/js/tests/version.ts new file mode 100644 index 00000000..d7563bea --- /dev/null +++ b/js/tests/version.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { VERSION } from '../src/version.ts'; +import pkg from '../package.json'; + +/** + * Regression test for issue #285: session records used to report a hard-coded + * `agent-cli-1.0.0` while the process log reported the real version. + */ +describe('version', () => { + test('matches the package manifest', () => { + expect(VERSION).toBe(pkg.version); + }); + + test('is a semver-looking string, not a placeholder', () => { + expect(VERSION).toMatch(/^\d+\.\d+\.\d+/); + expect(VERSION).not.toBe('agent-cli-1.0.0'); + }); + + test('session records use the shared version', async () => { + const source = await Bun.file( + new URL('../src/session/index.ts', import.meta.url) + ).text(); + expect(source).toContain('version: VERSION,'); + expect(source).not.toContain('agent-cli-1.0.0'); + }); +});