From 2a0aaef6d6ae9c1224dad78d7e5cd22de1deab63 Mon Sep 17 00:00:00 2001 From: suzuke Date: Mon, 27 Apr 2026 09:50:12 +0800 Subject: [PATCH] fix(#55): generalize fleet context dual-injection fix across backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change every non-Claude backend received the same fleet context (identity, role, workflow, decisions, custom prompt) twice: once via the workspace project doc the CLI auto-loads (GEMINI.md, AGENTS.md, .kiro/steering/) and once via the MCP `instructions` capability emitted by mcp-server.ts. This wasted tokens and diluted attention. Issue #55 originally scoped this to Gemini, but the same pattern affects Codex, Kiro, and (latently) OpenCode. Approach: single declarative flag on each backend says how it natively injects fleet instructions; the daemon uses it to decide whether the MCP `instructions` capability still fires. - Add `nativeInstructionsMechanism: 'append-flag' | 'project-doc' | 'none'` to `CliBackend`. Tag claude-code/opencode as `append-flag`, gemini-cli/codex/kiro-cli as `project-doc`, mock as `none`. - `Daemon.buildBackendConfig`: when the flag is non-`none`, drop the fleet-context env vars (`AGEND_DISPLAY_NAME`, `AGEND_DESCRIPTION`, `AGEND_WORKFLOW`, `AGEND_CUSTOM_PROMPT`, `AGEND_DECISIONS`) from the MCP server's env and set `AGEND_DISABLE_MCP_INSTRUCTIONS=1`. - `mcp-server.ts`: omit the `instructions` capability when `AGEND_DISABLE_MCP_INSTRUCTIONS=1` is set. Fallback path (capability active) preserved for backends with no native injection. - The backend's `writeConfig()` always receives the assembled instructions string for native injection, so identity/workflow/etc. still reach the model — just through one channel, not two. Notes: - Claude was never doubly-injected (it uses an instance-dir file via `--append-system-prompt-file`, not a workspace doc) but we still tag it explicitly so the rule is uniform. - `AGEND_DECISIONS` was the second silent dual-injection source: it was both passed to the daemon-side `buildFleetInstructions()` (which embeds decisions in the project doc) and re-passed through mcpEnv to rebuild instructions on the MCP side. Now gated together with the rest. - All existing `writeConfig()` paths are idempotent (`appendWithMarker` for project-doc backends, plain overwrite for the others), so upgrading existing instances does not require a migration. The MCP server is respawned on every CLI launch, so it picks up the new capability shape automatically. - `instructionsReloadedOnResume` and the daemon's force-new-session detection are unaffected: this fix changes only the *delivery channel*, not the instructions text content. Tests: - New `tests/backend/native-instructions-mechanism.test.ts` asserts the flag value on every backend. - New `tests/daemon-build-backend-config.test.ts` exercises `buildBackendConfig` against all 6 backends and verifies fleet context env vars / `AGEND_DISABLE_MCP_INSTRUCTIONS` follow the gate. - `npx tsc --noEmit` clean. `npx vitest run` 62 files / 517 tests pass. Docs: - New `docs/fleet-instructions-injection.md` explains per-backend delivery channels and how to verify a new backend. Closes #55. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fleet-instructions-injection.md | 52 ++++++++ src/backend/claude-code.ts | 1 + src/backend/codex.ts | 1 + src/backend/gemini-cli.ts | 1 + src/backend/kiro.ts | 1 + src/backend/mock.ts | 1 + src/backend/opencode.ts | 1 + src/backend/types.ts | 16 +++ src/channel/mcp-server.ts | 17 ++- src/daemon.ts | 31 +++-- .../native-instructions-mechanism.test.ts | 42 +++++++ tests/daemon-build-backend-config.test.ts | 112 ++++++++++++++++++ 12 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 docs/fleet-instructions-injection.md create mode 100644 tests/backend/native-instructions-mechanism.test.ts create mode 100644 tests/daemon-build-backend-config.test.ts diff --git a/docs/fleet-instructions-injection.md b/docs/fleet-instructions-injection.md new file mode 100644 index 00000000..73689546 --- /dev/null +++ b/docs/fleet-instructions-injection.md @@ -0,0 +1,52 @@ +# Fleet Instructions Injection — per-backend behaviour + +How AgEnD delivers fleet context (identity, role, workflow, decisions, custom prompt) into each CLI backend's prompt, and how that interacts with the MCP `instructions` capability. + +Background: see issue [#55](https://github.com/suzuke/AgEnD/issues/55) — before this fix, every non-Claude backend received the same fleet context twice (once via a workspace project doc, once via the MCP `initialize` response's `instructions` field), wasting tokens and diluting attention. + +## The MCP `instructions` capability + +The MCP spec lets a server return an `instructions` string in its `InitializeResult`. The client may surface that string to the model as system-level guidance. Whether the client actually does so — and how it merges with the user's prompt — is **per-CLI implementation**, not guaranteed by the spec. So we cannot rely on it as a sole delivery channel without per-backend evidence. + +## The `nativeInstructionsMechanism` flag + +Each backend declares one of three values in `src/backend/.ts`: + +| Value | Meaning | +|----------------|------------------------------------------------------------------------------------------| +| `append-flag` | CLI flag or config field points at a file outside the workspace. No workspace artefact. | +| `project-doc` | Workspace markdown auto-loaded by the CLI. | +| `none` | No native injection — daemon falls back to MCP `instructions` capability. | + +The daemon (`src/daemon.ts:buildBackendConfig`) reads this flag: + +- **`append-flag` / `project-doc`** → set `AGEND_DISABLE_MCP_INSTRUCTIONS=1` in the MCP server's env, drop the fleet-context env vars (`AGEND_DISPLAY_NAME`, `AGEND_DESCRIPTION`, `AGEND_WORKFLOW`, `AGEND_CUSTOM_PROMPT`, `AGEND_DECISIONS`). The MCP server (`src/channel/mcp-server.ts`) then omits its `instructions` capability entirely. +- **`none`** → keep all fleet-context env vars. The MCP server emits `instructions: buildMcpInstructions()` so the model still receives fleet context. + +Either way the backend's `writeConfig()` always receives the assembled `instructions` string in its `CliBackendConfig`; whether and how to write it is the backend's choice. + +## Per-backend behaviour + +| Backend | Mechanism | Where the fleet context lands | MCP `instructions` capability | +|---------------|----------------|---------------------------------------------------------------------|-------------------------------| +| `claude-code` | `append-flag` | `/fleet-instructions.md`, loaded via `--append-system-prompt-file`. Claude re-reads the file on `--resume`. | omitted | +| `opencode` | `append-flag` | `/fleet-instructions.md`, listed in `opencode.json:instructions`. | omitted | +| `gemini-cli` | `project-doc` | `/GEMINI.md`, marker block keyed by instance name. | omitted | +| `codex` | `project-doc` | `/AGENTS.md`, marker block keyed by instance name. Codex enforces a 32 KiB limit on this file — the backend warns if exceeded. | omitted | +| `kiro-cli` | `project-doc` | `/.kiro/steering/agend-.md`. | omitted | +| `mock` | `none` | Not written anywhere. | active (fallback) | + +## Verifying surface behaviour for a new backend + +When adding a backend that has neither a flag nor a project-doc convention, `nativeInstructionsMechanism: "none"` is the safe default — the model still sees fleet context via MCP `instructions`, **but** that requires the CLI to surface the capability into its model prompt. To verify: + +1. Start the instance with `customPrompt: "Reply with exactly the string FLEET_OK if you see fleet context in your system prompt."`. +2. Send any user message. +3. If the agent replies `FLEET_OK`, the CLI surfaces MCP `instructions`. Promote it to `none`. +4. If it does not, the CLI ignores the field and you must add a native injection mechanism (write a project doc or use a `--append-system-prompt`-style flag) and tag the backend `project-doc` / `append-flag`. + +If a CLI exposes a CLI flag that points at a file (the cleanest option), prefer `append-flag` — workspace markdown leaves an artefact users may find surprising. + +## Resume behaviour + +Some backends do not re-read their instructions source on session resume; the `instructionsReloadedOnResume` flag captures this. The daemon's `trySpawn()` watches `prev-instructions` and forces a fresh session when the instructions text changes. This is independent of `nativeInstructionsMechanism`: changing only the delivery channel (e.g. via this fix) does not change the instructions text, so existing sessions are not invalidated by the upgrade itself. diff --git a/src/backend/claude-code.ts b/src/backend/claude-code.ts index 14ca67b5..fdc31335 100644 --- a/src/backend/claude-code.ts +++ b/src/backend/claude-code.ts @@ -7,6 +7,7 @@ import { type CliBackend, type CliBackendConfig, type ErrorPattern, type Runtime export class ClaudeCodeBackend implements CliBackend { readonly binaryName = "claude"; readonly instructionsReloadedOnResume = true; + readonly nativeInstructionsMechanism = "append-flag" as const; private binaryPath: string; constructor(private instanceDir: string) { diff --git a/src/backend/codex.ts b/src/backend/codex.ts index 3593f54f..cab8a32b 100644 --- a/src/backend/codex.ts +++ b/src/backend/codex.ts @@ -9,6 +9,7 @@ const CODEX_PROJECT_DOC_MAX_BYTES = 32_768; export class CodexBackend implements CliBackend { readonly binaryName = "codex"; + readonly nativeInstructionsMechanism = "project-doc" as const; private binaryPath: string; constructor(private instanceDir: string) { diff --git a/src/backend/gemini-cli.ts b/src/backend/gemini-cli.ts index 57c9862d..75a9a4cb 100644 --- a/src/backend/gemini-cli.ts +++ b/src/backend/gemini-cli.ts @@ -6,6 +6,7 @@ import { appendWithMarker, removeMarker } from "./marker-utils.js"; export class GeminiCliBackend implements CliBackend { readonly binaryName = "gemini"; + readonly nativeInstructionsMechanism = "project-doc" as const; private binaryPath: string; constructor(private instanceDir: string) { diff --git a/src/backend/kiro.ts b/src/backend/kiro.ts index 8c2c7348..3d8fafed 100644 --- a/src/backend/kiro.ts +++ b/src/backend/kiro.ts @@ -4,6 +4,7 @@ import { type CliBackend, type CliBackendConfig, type ErrorPattern, type Startup export class KiroBackend implements CliBackend { readonly binaryName = "kiro-cli"; + readonly nativeInstructionsMechanism = "project-doc" as const; private binaryPath: string; constructor(private instanceDir: string) { diff --git a/src/backend/mock.ts b/src/backend/mock.ts index fec5142e..5b6c806d 100644 --- a/src/backend/mock.ts +++ b/src/backend/mock.ts @@ -20,6 +20,7 @@ const __dirname = dirname(__filename); */ export class MockBackend implements CliBackend { readonly binaryName = "node"; + readonly nativeInstructionsMechanism = "none" as const; constructor(private instanceDir: string) {} diff --git a/src/backend/opencode.ts b/src/backend/opencode.ts index c044b31c..801e1936 100644 --- a/src/backend/opencode.ts +++ b/src/backend/opencode.ts @@ -4,6 +4,7 @@ import { type CliBackend, type CliBackendConfig, type ErrorPattern, type Startup export class OpenCodeBackend implements CliBackend { readonly binaryName = "opencode"; + readonly nativeInstructionsMechanism = "append-flag" as const; private binaryPath: string; constructor(private instanceDir: string) { diff --git a/src/backend/types.ts b/src/backend/types.ts index 44878bdc..21df69d9 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -87,6 +87,22 @@ export interface CliBackend { /** Whether this backend re-reads instruction files on --resume (e.g. Claude Code's --append-system-prompt-file). */ readonly instructionsReloadedOnResume?: boolean; + /** + * How this backend natively injects fleet instructions into the CLI's prompt: + * - `'append-flag'`: dedicated CLI flag / config field pointing at a file outside + * the workspace (e.g. Claude `--append-system-prompt-file`, OpenCode + * `instructions: [path]`). Cleanest, no workspace-visible artefact. + * - `'project-doc'`: workspace markdown the CLI auto-loads (e.g. Gemini + * `GEMINI.md`, Codex `AGENTS.md`, Kiro `.kiro/steering/`). + * - `'none'`: no native mechanism — daemon must rely on the MCP `instructions` + * capability to deliver fleet context. + * + * Daemon uses this to gate the MCP `instructions` capability: backends that + * already inject natively must NOT also receive the same content via MCP, or + * the model sees it twice (Bug #55). + */ + readonly nativeInstructionsMechanism: "append-flag" | "project-doc" | "none"; + /** Pre-approve a working directory to skip trust dialogs on startup. */ preTrust?(workingDirectory: string): void; diff --git a/src/channel/mcp-server.ts b/src/channel/mcp-server.ts index a9afc921..7d5ff28a 100644 --- a/src/channel/mcp-server.ts +++ b/src/channel/mcp-server.ts @@ -182,9 +182,16 @@ function ipcRequest( // --------------------------------------------------------------------------- // MCP instructions — thin wrapper around shared buildFleetInstructions(). -// Phase 1 (dual track): MCP instructions kept as fallback for backends that -// read the MCP instructions field. Content delegates to the shared module -// to avoid drift between MCP and additive system prompt paths. +// +// The daemon owns the policy: when the backend has a native injection +// mechanism (CLI flag, project doc, etc.) the same content is delivered into +// the model's prompt that way, so emitting it here as the MCP `instructions` +// capability would duplicate it (Bug #55). The daemon signals that case by +// setting `AGEND_DISABLE_MCP_INSTRUCTIONS=1` and omitting the fleet-context +// env vars; we honour that by skipping the capability altogether. Backends +// that opt into MCP instructions (e.g. future backends with no native +// mechanism, or operators running this server standalone) leave that env var +// unset and get the previous behaviour. // --------------------------------------------------------------------------- function buildMcpInstructions(): string { @@ -209,13 +216,15 @@ function buildMcpInstructions(): string { }); } +const mcpInstructionsDisabled = process.env.AGEND_DISABLE_MCP_INSTRUCTIONS === "1"; + const mcp = new Server( { name: "agend", version: "0.3.0" }, { capabilities: { tools: {}, }, - instructions: buildMcpInstructions(), + ...(mcpInstructionsDisabled ? {} : { instructions: buildMcpInstructions() }), }, ); diff --git a/src/daemon.ts b/src/daemon.ts index 7b3547fd..65329347 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1007,19 +1007,36 @@ export class Daemon extends EventEmitter { } } - // ── MCP server env (dual-track: still passes env vars for MCP instructions fallback) ── + // ── MCP server env ── + // Fleet-context env vars (display_name, description, workflow, custom + // prompt, decisions) feed mcp-server.ts:buildMcpInstructions(), which is + // exposed via the MCP `instructions` capability. Backends with their own + // native injection (Claude `--append-system-prompt-file`, Gemini + // `GEMINI.md`, Codex `AGENTS.md`, Kiro `.kiro/steering/`, OpenCode + // `opencode.json` instructions array) already deliver the same content + // into the model's context, so passing it through MCP would inject the + // identical text twice (Bug #55). Gate on the backend's + // `nativeInstructionsMechanism`: when the backend injects natively, we + // drop the env vars AND set AGEND_DISABLE_MCP_INSTRUCTIONS=1 so the MCP + // server omits its `instructions` capability entirely. + const nativeMech = this.backend?.nativeInstructionsMechanism ?? "none"; + const mcpInstructionsActive = nativeMech === "none"; const mcpEnv: Record = { AGEND_SOCKET_PATH: sockPath, AGEND_INSTANCE_NAME: this.name, AGEND_WORKING_DIR: this.config.working_directory, }; if (this.config.tool_set) mcpEnv.AGEND_TOOL_SET = this.config.tool_set; - if (this.config.display_name) mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name; - if (this.config.description) mcpEnv.AGEND_DESCRIPTION = this.config.description; - if (resolvedWorkflow === false) mcpEnv.AGEND_WORKFLOW = "false"; - else if (resolvedWorkflow) mcpEnv.AGEND_WORKFLOW = resolvedWorkflow; - if (resolvedCustomPrompt) mcpEnv.AGEND_CUSTOM_PROMPT = resolvedCustomPrompt; - if (process.env.AGEND_DECISIONS) mcpEnv.AGEND_DECISIONS = process.env.AGEND_DECISIONS; + if (mcpInstructionsActive) { + if (this.config.display_name) mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name; + if (this.config.description) mcpEnv.AGEND_DESCRIPTION = this.config.description; + if (resolvedWorkflow === false) mcpEnv.AGEND_WORKFLOW = "false"; + else if (resolvedWorkflow) mcpEnv.AGEND_WORKFLOW = resolvedWorkflow; + if (resolvedCustomPrompt) mcpEnv.AGEND_CUSTOM_PROMPT = resolvedCustomPrompt; + if (process.env.AGEND_DECISIONS) mcpEnv.AGEND_DECISIONS = process.env.AGEND_DECISIONS; + } else { + mcpEnv.AGEND_DISABLE_MCP_INSTRUCTIONS = "1"; + } // ── Fleet instructions for additive system prompt injection ── let instructions: string; diff --git a/tests/backend/native-instructions-mechanism.test.ts b/tests/backend/native-instructions-mechanism.test.ts new file mode 100644 index 00000000..12cb9d2d --- /dev/null +++ b/tests/backend/native-instructions-mechanism.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { ClaudeCodeBackend } from "../../src/backend/claude-code.js"; +import { GeminiCliBackend } from "../../src/backend/gemini-cli.js"; +import { CodexBackend } from "../../src/backend/codex.js"; +import { KiroBackend } from "../../src/backend/kiro.js"; +import { OpenCodeBackend } from "../../src/backend/opencode.js"; +import { MockBackend } from "../../src/backend/mock.js"; + +// Bug #55: every backend must declare how it natively delivers fleet +// instructions. Daemon uses this to decide whether to also expose the MCP +// `instructions` capability — otherwise the same content is injected twice. +describe("CliBackend.nativeInstructionsMechanism (Bug #55)", () => { + it("Claude uses --append-system-prompt-file (append-flag)", () => { + const b = new ClaudeCodeBackend("/tmp/test-instance"); + expect(b.nativeInstructionsMechanism).toBe("append-flag"); + }); + + it("OpenCode uses opencode.json instructions array (append-flag)", () => { + const b = new OpenCodeBackend("/tmp/test-instance"); + expect(b.nativeInstructionsMechanism).toBe("append-flag"); + }); + + it("Gemini writes GEMINI.md (project-doc)", () => { + const b = new GeminiCliBackend("/tmp/test-instance"); + expect(b.nativeInstructionsMechanism).toBe("project-doc"); + }); + + it("Codex writes AGENTS.md (project-doc)", () => { + const b = new CodexBackend("/tmp/test-instance"); + expect(b.nativeInstructionsMechanism).toBe("project-doc"); + }); + + it("Kiro writes .kiro/steering/ (project-doc)", () => { + const b = new KiroBackend("/tmp/test-instance"); + expect(b.nativeInstructionsMechanism).toBe("project-doc"); + }); + + it("Mock backend has no native injection (none)", () => { + const b = new MockBackend("/tmp/test-instance"); + expect(b.nativeInstructionsMechanism).toBe("none"); + }); +}); diff --git a/tests/daemon-build-backend-config.test.ts b/tests/daemon-build-backend-config.test.ts new file mode 100644 index 00000000..3f8c922b --- /dev/null +++ b/tests/daemon-build-backend-config.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Daemon } from "../src/daemon.js"; +import type { InstanceConfig } from "../src/types.js"; +import { ClaudeCodeBackend } from "../src/backend/claude-code.js"; +import { GeminiCliBackend } from "../src/backend/gemini-cli.js"; +import { CodexBackend } from "../src/backend/codex.js"; +import { KiroBackend } from "../src/backend/kiro.js"; +import { OpenCodeBackend } from "../src/backend/opencode.js"; +import { MockBackend } from "../src/backend/mock.js"; +import type { CliBackend, CliBackendConfig } from "../src/backend/types.js"; + +// Bug #55 regression: verify daemon.buildBackendConfig only injects fleet +// context env vars (display_name, description, workflow, custom prompt, +// decisions) into the MCP server when the backend has no native injection +// mechanism. Backends with native injection should receive +// AGEND_DISABLE_MCP_INSTRUCTIONS=1 instead, so the MCP server omits its +// `instructions` capability. + +const FLEET_CONTEXT_KEYS = [ + "AGEND_DISPLAY_NAME", + "AGEND_DESCRIPTION", + "AGEND_WORKFLOW", + "AGEND_CUSTOM_PROMPT", + "AGEND_DECISIONS", +] as const; + +function makeConfig(overrides?: Partial): InstanceConfig { + return { + working_directory: "/tmp/test-bb-config", + display_name: "TestAgent", + description: "for testing dual-injection gate", + systemPrompt: "Be terse.", + workflow: "## inline workflow", + restart_policy: { max_retries: 1, backoff: "exponential", reset_after: 60 }, + context_guardian: { grace_period_ms: 0, max_age_hours: 1 }, + log_level: "warn", + ...overrides, + } as InstanceConfig; +} + +interface BackendCase { + label: string; + make: (instanceDir: string) => CliBackend; + expectedMech: "append-flag" | "project-doc" | "none"; +} + +const cases: BackendCase[] = [ + { label: "claude-code", make: (d) => new ClaudeCodeBackend(d), expectedMech: "append-flag" }, + { label: "opencode", make: (d) => new OpenCodeBackend(d), expectedMech: "append-flag" }, + { label: "gemini-cli", make: (d) => new GeminiCliBackend(d), expectedMech: "project-doc" }, + { label: "codex", make: (d) => new CodexBackend(d), expectedMech: "project-doc" }, + { label: "kiro-cli", make: (d) => new KiroBackend(d), expectedMech: "project-doc" }, + { label: "mock", make: (d) => new MockBackend(d), expectedMech: "none" }, +]; + +describe("Daemon.buildBackendConfig — Bug #55 fleet context gate", () => { + let instanceDir: string; + let prevDecisions: string | undefined; + + beforeEach(() => { + instanceDir = join(tmpdir(), `ccd-bb-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(instanceDir, { recursive: true }); + // Simulate fleet-manager.ts:440 setting AGEND_DECISIONS for the daemon + prevDecisions = process.env.AGEND_DECISIONS; + process.env.AGEND_DECISIONS = JSON.stringify([{ title: "scope", content: "test scope" }]); + }); + + afterEach(() => { + rmSync(instanceDir, { recursive: true, force: true }); + if (prevDecisions === undefined) delete process.env.AGEND_DECISIONS; + else process.env.AGEND_DECISIONS = prevDecisions; + }); + + for (const c of cases) { + it(`${c.label}: native=${c.expectedMech} → MCP env follows the gate`, () => { + const backend = c.make(instanceDir); + const daemon = new Daemon("test-bb", makeConfig(), instanceDir, false, backend); + const cfg: CliBackendConfig = (daemon as unknown as { buildBackendConfig(): CliBackendConfig }).buildBackendConfig(); + const env = cfg.mcpServers["agend"]?.env ?? {}; + + // Identity / operational vars are always present + expect(env.AGEND_INSTANCE_NAME).toBe("test-bb"); + expect(env.AGEND_WORKING_DIR).toBe("/tmp/test-bb-config"); + expect(env.AGEND_SOCKET_PATH).toBeTruthy(); + + // Backend always receives the assembled instructions string for native injection + expect(cfg.instructions).toBeTruthy(); + expect(cfg.instructions).toContain("test-bb"); + + if (c.expectedMech === "none") { + // No native injection → MCP instructions capability stays active. + expect(env.AGEND_DISABLE_MCP_INSTRUCTIONS).toBeUndefined(); + // All fleet context env vars present so MCP server can rebuild instructions. + expect(env.AGEND_DISPLAY_NAME).toBe("TestAgent"); + expect(env.AGEND_DESCRIPTION).toBe("for testing dual-injection gate"); + expect(env.AGEND_WORKFLOW).toBeTruthy(); + expect(env.AGEND_CUSTOM_PROMPT).toBe("Be terse."); + expect(env.AGEND_DECISIONS).toBeTruthy(); + } else { + // Native injection present → MCP instructions capability disabled, fleet + // context env vars dropped to avoid duplicate injection. + expect(env.AGEND_DISABLE_MCP_INSTRUCTIONS).toBe("1"); + for (const key of FLEET_CONTEXT_KEYS) { + expect(env[key], `${c.label}: ${key} must NOT be passed when backend injects natively`).toBeUndefined(); + } + } + }); + } +});