From 83edf72d6f86bca56471dc68ae2cb95f5a380e3d Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Mon, 27 Jul 2026 18:14:52 -0700 Subject: [PATCH 01/28] feat(agent-mode): make AGENTS.md canonical instructions --- designdocs/AGENT_HOME_ARCHITECTURE.md | 23 +- docs/projects.md | 21 +- docs/system-prompts.md | 64 ++-- docs/troubleshooting-and-faq.md | 22 +- src/agentMode/backends/claude/descriptor.ts | 2 +- .../backends/codex/CodexBackend.test.ts | 17 +- src/agentMode/backends/codex/CodexBackend.ts | 15 +- .../backends/opencode/OpencodeBackend.test.ts | 11 +- .../backends/opencode/OpencodeBackend.ts | 9 +- .../backends/shared/agentSystemPrompt.test.ts | 293 ++++++++---------- .../backends/shared/agentSystemPrompt.ts | 62 ++-- src/agentMode/index.ts | 18 +- .../sdk/ClaudeSdkBackendProcess.test.ts | 127 +------- src/agentMode/sdk/ClaudeSdkBackendProcess.ts | 53 +--- .../session/AgentSessionManager.test.ts | 5 - src/agentMode/session/AgentSessionManager.ts | 61 +--- src/agentMode/session/descriptor.ts | 8 +- src/agentMode/session/types.ts | 35 +-- src/agentMode/ui/ProjectInfoPopover.test.tsx | 83 ++--- src/agentMode/ui/ProjectInfoPopover.tsx | 115 +++---- src/agentMode/ui/ProjectSystemPromptModal.tsx | 106 ------- .../modals/project/AddProjectModal.tsx | 35 +-- src/context/projectContentTracker.ts | 2 +- src/instructions/agentsFile.test.ts | 105 +++++++ src/instructions/agentsFile.ts | 109 +++++++ src/projects/ProjectFileManager.test.ts | 5 - src/projects/ProjectFileManager.ts | 30 +- src/projects/constants.ts | 20 +- src/projects/ensureAgentsMirror.test.ts | 238 -------------- src/projects/ensureAgentsMirror.ts | 255 --------------- src/projects/legacyAgentsResidue.test.ts | 45 +++ src/projects/legacyAgentsResidue.ts | 35 +-- src/projects/projectContextSignature.ts | 17 +- src/projects/projectPaths.test.ts | 2 +- src/projects/projectPaths.ts | 7 +- src/projects/projectRegister.ts | 3 +- src/projects/projectSystemPrompt.test.ts | 101 ------ src/projects/projectSystemPrompt.ts | 71 ----- src/projects/projectUtils.test.ts | 2 +- src/projects/projectUtils.ts | 6 +- src/projects/state.ts | 6 +- src/search/searchUtils.test.ts | 27 ++ src/search/searchUtils.ts | 14 +- .../v2/components/AdvancedSettings.tsx | 77 +---- .../agentTodoPlanningSteering.test.ts | 3 +- .../agentTodoPlanningSteering.ts | 8 +- 46 files changed, 691 insertions(+), 1682 deletions(-) delete mode 100644 src/agentMode/ui/ProjectSystemPromptModal.tsx create mode 100644 src/instructions/agentsFile.test.ts create mode 100644 src/instructions/agentsFile.ts delete mode 100644 src/projects/ensureAgentsMirror.test.ts delete mode 100644 src/projects/ensureAgentsMirror.ts create mode 100644 src/projects/legacyAgentsResidue.test.ts delete mode 100644 src/projects/projectSystemPrompt.test.ts delete mode 100644 src/projects/projectSystemPrompt.ts diff --git a/designdocs/AGENT_HOME_ARCHITECTURE.md b/designdocs/AGENT_HOME_ARCHITECTURE.md index 9d63c5d73..f5f54e9bb 100644 --- a/designdocs/AGENT_HOME_ARCHITECTURE.md +++ b/designdocs/AGENT_HOME_ARCHITECTURE.md @@ -347,16 +347,19 @@ and are left untouched. Source kinds are a single source of truth, `MATERIALIZED_SOURCE_TYPES` (`web` / `youtube` / `file`), so adding a kind updates the filename patterns and marker-pruning regex together. -## Project instructions (`project.md` + `AGENTS.md` mirror) - -`project.md` is the single source of truth for a project's config and -instructions. A **marker-gated `AGENTS.md` mirror** is generated alongside it by -`ensureAgentsMirror`: codex and opencode auto-discover `AGENTS.md` from the -session cwd, and claude receives the same composed instructions via -`getProjectProfile`. A built-in project policy is layered into each project's -instructions — for claude always; for codex/opencode through the generated -mirror, which **yields to a user-authored, unmarked `AGENTS.md`** (the mirror only -manages the file it owns, so it never clobbers a hand-written one). +## Project instructions (`project.md` + `AGENTS.md`) + +`project.md` remains the project metadata/config record. `AGENTS.md` is the +canonical user-and-agent-editable instruction file and is never regenerated by +Copilot. The project info popover opens that file directly. + +For an older project with no `AGENTS.md`, the open action initializes it once +from the legacy `project.md` body. A marker-owned generated mirror is converted +to that raw body on first open; unmarked user files are preserved. No bulk +migration rewrites existing projects. +Codex and OpenCode discover `AGENTS.md` from the session cwd; a sibling +`CLAUDE.md` imports `@AGENTS.md` so Claude reads the same file. Vault-root files +provide the broader layer and project files provide the more-specific layer. ## History scope diff --git a/docs/projects.md b/docs/projects.md index 41c4db7c7..85561aaec 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -1,6 +1,8 @@ # Projects -Projects are focused AI workspaces. Each project has its own model, system prompt, context sources, and completely isolated chat history. Use projects to keep separate AI conversations per client, topic, or area of work. +Projects are focused AI workspaces. Each project has its own instructions, context sources, and +isolated chat history. Use projects to keep separate AI conversations per client, topic, or area +of work. Projects support **50+ file types** beyond markdown, including PDFs, Word documents, PowerPoint, Excel, images, and more — making them ideal for analyzing large or diverse document collections. @@ -14,7 +16,7 @@ In regular chat, all conversations share the same settings and model. Projects l - **A specific context** — Specific notes, folders, URLs, or YouTube videos the AI always has access to - **A dedicated model** — Different projects can use different AI models -- **A custom system prompt** — Each project can have its own instructions for the AI +- **Project instructions** — Each Agent Mode project can have its own `AGENTS.md` - **Isolated chat history** — Conversations in one project don't mix with conversations in another **Example use cases:** @@ -56,9 +58,18 @@ Choose which AI model to use for this project. The available options are the mod Override the default temperature and max tokens specifically for this project. -### System Prompt +### Agent Mode Instructions -Set a custom system prompt for this project. This replaces (or supplements) the global default. See [System Prompts](system-prompts.md) for details. +Open the project info popover and select **AGENTS.md**. This opens the real file in Obsidian; there +is no separate prompt editor in project settings. + +Vault instructions apply first, followed by the project's `AGENTS.md`, so project rules take +precedence. For an older project without `AGENTS.md`, opening it once initializes the file from +the existing Project System Prompt stored in `project.md`. A legacy Copilot-generated mirror is +converted to that raw prompt on first open; user-authored files are left alone. + +`project.md` remains the project's metadata and context configuration record. It is not the agent +instruction file and is not renamed or migrated. --- @@ -127,6 +138,6 @@ As an alpha feature, projects have some known limitations: ## Related - [Chat Interface](chat-interface.md) — Chat modes overview, new chat behavior, history -- [System Prompts](system-prompts.md) — Custom system prompts for projects +- [Instructions and System Prompts](system-prompts.md) — Vault and project instructions - [Context and Mentions](context-and-mentions.md) — How context works - [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) — Plus features diff --git a/docs/system-prompts.md b/docs/system-prompts.md index f3a853b8a..acd696ec4 100644 --- a/docs/system-prompts.md +++ b/docs/system-prompts.md @@ -1,4 +1,4 @@ -# System Prompts +# Instructions and System Prompts A system prompt is a set of instructions you give the AI that shapes how it behaves in all conversations. Think of it as a persistent briefing: "You are an assistant that helps me with academic writing. Always cite sources. Respond in formal English." @@ -6,10 +6,10 @@ A system prompt is a set of instructions you give the AI that shapes how it beha ## Overview -Copilot has two layers of system prompts: +Copilot has two instruction surfaces: -1. **Built-in system prompt** — Always active. Defines core behaviors specific to Obsidian (how to format Obsidian links, how to handle note references, etc.) -2. **Custom system prompt** — Optional. You can write your own instructions that are appended to the built-in prompt. +1. **Built-in system prompt** — Internal Agent Mode behavior maintained by Copilot. +2. **User instructions** — `AGENTS.md` for Agent Mode; selectable custom prompt files for Chat mode. --- @@ -32,13 +32,34 @@ This prompt ensures Copilot's output is correctly formatted for Obsidian and awa --- -## Custom System Prompts +## Agent Mode Instructions -Custom system prompts let you add your own instructions on top of the built-in prompt. +Agent Mode uses the standard `AGENTS.md` file: + +- Vault-wide instructions live at `/AGENTS.md`. +- Project instructions live at `/AGENTS.md`. +- Project instructions are more specific and take precedence over vault instructions. + +Go to **Settings → Copilot → Advanced → Vault instructions → Open AGENTS.md** to open the +vault file. In a project, open the project info popover and select **AGENTS.md**. + +Copilot uses Obsidian's normal Markdown editor rather than a separate Settings editor. If a file +is missing, the open action creates it. Copilot also adds an `@AGENTS.md` reference to the +sibling `CLAUDE.md` without replacing other content, so Claude reads the same instructions as +Codex and OpenCode. + +Changes apply to new agent sessions. + +--- + +## Chat Mode Custom System Prompts + +Chat mode custom system prompts let you add instructions on top of its built-in prompt. ### Where They're Stored Custom system prompts are stored as markdown files in your vault, in the folder: + ``` copilot/system-prompts/ ``` @@ -47,16 +68,6 @@ You can change this folder in **Settings → Copilot → Advanced → System Pro ### Creating a System Prompt -#### From Settings - -1. Go to **Settings → Copilot → Advanced** -2. Under **User System Prompt**, click the `+` button -3. Enter a title for the prompt (e.g., "Academic Writing") -4. A new markdown file is created in your system prompts folder -5. Open the file and write your instructions - -#### From the System Prompts Folder - Create any `.md` file in the `copilot/system-prompts/` folder. Its filename (without `.md`) becomes the prompt's title. ### Writing Good System Prompts @@ -70,8 +81,10 @@ Tips for effective system prompts: - **Limit scope**: "Only answer questions related to my research notes on climate science" **Example system prompt:** + ```markdown You are a Zettelkasten assistant helping me build a knowledge base. + - Always connect new ideas to existing notes when possible - Suggest up to 3 related concepts per response - Format all note suggestions as [[Note Title]] @@ -80,18 +93,6 @@ You are a Zettelkasten assistant helping me build a knowledge base. --- -## Setting a Global Default - -You can set one of your custom prompts as the global default — it will be used for all new chat sessions: - -1. Go to **Settings → Copilot → Advanced** -2. Under **Default System Prompt**, select your prompt from the dropdown -3. Any new conversation will start with this prompt active - -To stop using a custom default, select **None (use built-in prompt)** from the dropdown. - ---- - ## Per-Session Override (Gear Icon) You can override the system prompt for just the current conversation: @@ -113,14 +114,15 @@ Both sets of instructions are active simultaneously. Your custom instructions ca --- -## Per-Project System Prompts +## Per-Project Instructions -Each [Project](projects.md) can have its own system prompt, independent of the global default. Configure this in the project settings under **System Prompt**. +Agent Mode projects use their own `AGENTS.md`. Legacy Chat mode projects retain their existing +Project System Prompt field. --- ## Related - [Chat Interface](chat-interface.md) — Per-session gear settings -- [Projects](projects.md) — Per-project system prompts +- [Projects](projects.md) — Per-project instructions - [Getting Started](getting-started.md) — Initial setup diff --git a/docs/troubleshooting-and-faq.md b/docs/troubleshooting-and-faq.md index 061e9f360..378703812 100644 --- a/docs/troubleshooting-and-faq.md +++ b/docs/troubleshooting-and-faq.md @@ -22,6 +22,7 @@ Before diving into specific fixes, try these steps first: **Cause**: The model you selected doesn't have a valid API key for its provider. **Fix**: + 1. Go to **Settings → Copilot → Basic → Set Keys** 2. Enter the API key for the provider your model uses 3. If you're unsure which provider a model uses, check **Settings → Copilot → Model** — each model shows its provider @@ -31,6 +32,7 @@ Before diving into specific fixes, try these steps first: **Cause**: You've sent too many requests to the API in a short time. **Fix**: + - Wait a minute and try again - If this happens frequently during indexing, reduce **Embedding Requests per Minute** in QA settings (try 10–20) - Consider upgrading your API plan with the provider @@ -40,6 +42,7 @@ Before diving into specific fixes, try these steps first: **Cause**: Network issue, provider outage, or the request took too long. **Fix**: + - Check your internet connection - Try again after a few seconds - Check the provider's status page for outages @@ -50,6 +53,7 @@ Before diving into specific fixes, try these steps first: **Cause**: You're trying to use Vault QA or semantic search but the vault hasn't been indexed yet. **Fix**: + 1. Make sure you have an embedding model configured with a valid API key (**Settings → Copilot → QA → Embedding Model**) 2. Run **Command palette → Index (refresh) vault** 3. Wait for indexing to complete @@ -69,6 +73,7 @@ Before diving into specific fixes, try these steps first: ### Notes Not Found in Search Even after indexing, relevant notes aren't being returned? Try: + 1. Switch to **Copilot Plus** mode and use `@vault` for more powerful search 2. Try the **multilingual embedding model** for non-English notes 3. Review your QA inclusions/exclusions to confirm the notes aren't filtered out @@ -90,6 +95,7 @@ Even after indexing, relevant notes aren't being returned? Try: **Problem**: "Connection refused" or model not responding **Fix**: + - Make sure Ollama is running: open a terminal and run `ollama serve` - Verify the model is downloaded: `ollama list` - Check that the port in Copilot settings matches (default: 11434) @@ -101,6 +107,7 @@ Even after indexing, relevant notes aren't being returned? Try: **Fix**: Azure OpenAI requires all four fields to be filled in correctly: + 1. API Key 2. Instance Name (your Azure resource name, e.g., `my-azure-openai`) 3. Deployment Name (the name you gave your model deployment) @@ -113,6 +120,7 @@ Any missing or incorrect field will cause errors. **Problem**: "Model not found" or access denied **Fix**: + - Always use **cross-region inference profile IDs**, not bare model IDs: - ✅ `us.anthropic.claude-sonnet-4-5-20250929-v1:0` - ❌ `anthropic.claude-sonnet-4-5-20250929-v1:0` @@ -124,6 +132,7 @@ Any missing or incorrect field will cause errors. **Problem**: "Token expired" or authentication fails **Fix**: + - Go to **Settings → Copilot → Basic → Set Keys** - Click **Connect GitHub Copilot** to re-authenticate via OAuth - Make sure your GitHub Copilot subscription is active @@ -133,6 +142,7 @@ Any missing or incorrect field will cause errors. **Problem**: "QUOTA_EXCEEDED" or slow responses **Fix**: + - Check your quota at https://console.cloud.google.com - Try switching to the Flash model (faster, higher quota) - Consider using Google via OpenRouter instead for a unified quota @@ -142,6 +152,7 @@ Any missing or incorrect field will cause errors. **Problem**: Response cuts off or streaming errors **Fix**: + - DeepSeek reasoning models (deepseek-reasoner) can produce very long outputs; try increasing Max Tokens - If you see streaming errors, check the DeepSeek status page - Try switching between deepseek-chat and deepseek-reasoner @@ -155,6 +166,7 @@ Any missing or incorrect field will cause errors. **Cause**: Large vault with many notes, or low rate limit setting. **Fix**: + - Check **Embedding Requests per Minute** — higher values speed up indexing but may cause rate limits - Use exclusions to skip folders you don't need indexed (e.g., large archive folders) - Use the incremental **Index (refresh) vault** command instead of Force Reindex when possible @@ -165,6 +177,7 @@ Any missing or incorrect field will cause errors. **Cause**: Large lexical search index or many indexed files. **Fix**: + - Reduce **Lexical Search RAM Limit** in QA settings (default 100 MB, range 20–1000 MB) - Add more folders to exclusions to reduce the index size - On mobile, disable indexing altogether @@ -174,6 +187,7 @@ Any missing or incorrect field will cause errors. **Cause**: Rendering many chat messages or a very long conversation. **Fix**: + - Start a new chat — long conversations can slow down rendering - Auto-compact will trigger automatically at 128,000 tokens to keep conversations manageable - Lower your auto-compact threshold if you're hitting performance issues early @@ -226,11 +240,14 @@ Yes — use `[[Note Title]]` syntax directly in your message. Copilot adds that ### How do I make Copilot always reply in English? -Go to **Settings → Copilot → Advanced → Default System Prompt**, create a custom prompt, and add "Always respond in English." as an instruction. See [System Prompts](system-prompts.md). +For Agent Mode, go to **Settings → Copilot → Advanced → Vault instructions**, open +`AGENTS.md`, and add "Always respond in English." For Chat mode, select a custom system prompt +from the chat settings gear. See [Instructions and System Prompts](system-prompts.md). ### Can Copilot understand images in my notes? Yes, but only with models that have **Vision** capability (shown by a vision icon in the model list). Make sure: + 1. You're using a vision-capable model 2. **Settings → Copilot → Basic → Pass markdown images to AI** is enabled @@ -268,6 +285,7 @@ Use **Command palette → Clear Copilot cache**. This clears cached responses an ### What is the `copilot/` folder in my vault? The `copilot/` folder is created by the plugin and stores: + - `copilot-conversations/` — Saved chat histories - `copilot-custom-prompts/` — Your custom commands - `system-prompts/` — Your custom system prompts @@ -278,6 +296,7 @@ This folder is automatically excluded from vault search to avoid cluttering resu ### How do I switch modes? Click the mode selector at the top of the chat panel. Available modes: + - Chat - Vault QA (Basic) - Copilot Plus (requires license) @@ -286,6 +305,7 @@ Click the mode selector at the top of the chat panel. Available modes: ### The AI keeps forgetting what we talked about earlier This usually means the conversation has grown too long and older turns are being trimmed from context. Options: + - Lower **Conversation Turns in Context** in Model settings - Let auto-compact handle it (it summarizes old turns automatically) - Start a new chat and reference the previous chat file diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts index 75534f24c..af83b9b72 100644 --- a/src/agentMode/backends/claude/descriptor.ts +++ b/src/agentMode/backends/claude/descriptor.ts @@ -329,7 +329,7 @@ export const ClaudeBackendDescriptor: BackendDescriptor = { // symlink fanout already enforces visibility (no link = not seen). If the // Claude Agent SDK ever grows a per-skill deny hook, wire // `composeDenyList(getManagedSkills(), "claude")` in here. - getSystemPromptAppend: (opts) => buildAgentSystemPrompt(opts), + getSystemPromptAppend: () => buildAgentSystemPrompt(), }); }, diff --git a/src/agentMode/backends/codex/CodexBackend.test.ts b/src/agentMode/backends/codex/CodexBackend.test.ts index 6b903c8db..d1c22befc 100644 --- a/src/agentMode/backends/codex/CodexBackend.test.ts +++ b/src/agentMode/backends/codex/CodexBackend.test.ts @@ -93,20 +93,18 @@ describe("CodexBackend.buildSpawnDescriptor", () => { expect(value).not.toContain("copilot/skills//SKILL.md"); }); - it("appends the user's selected custom prompt to developer_instructions", async () => { + it("does not copy Chat mode custom prompts into developer_instructions", async () => { updateCachedSystemPrompts([makeSystemPrompt("Haiku", "respond in haiku")]); setSelectedPromptTitle("Haiku"); const backend = new CodexBackend(); const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); const value = desc.args[desc.args.indexOf("-c") + 1]; expect(value).toContain("Obsidian Copilot"); - // The TOML basic string escapes newlines as \n, so match the wrapper + - // content rather than the literal multi-line block. - expect(value).toContain(""); - expect(value).toContain("respond in haiku"); + expect(value).not.toContain(""); + expect(value).not.toContain("respond in haiku"); }); - it("suppresses the base prompt when 'disable builtin' is on, keeping the user prompt + pill directive", async () => { + it("suppresses the base prompt when 'disable builtin' is on, keeping the pill directive", async () => { updateCachedSystemPrompts([makeSystemPrompt("Haiku", "respond in haiku")]); setSelectedPromptTitle("Haiku"); setDisableBuiltinSystemPrompt(true); @@ -114,7 +112,7 @@ describe("CodexBackend.buildSpawnDescriptor", () => { const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); const value = desc.args[desc.args.indexOf("-c") + 1]; expect(value).not.toContain("Obsidian Copilot"); - expect(value).toContain("respond in haiku"); + expect(value).not.toContain("respond in haiku"); // Pill directive is functional wiring, not builtin framing — always sent. expect(value).toContain("{folder_name}"); }); @@ -291,9 +289,8 @@ describe("CodexBackend.buildSpawnDescriptor", () => { }); it("does not add a project.md fallback to the codex spawn args", async () => { - // Session-start ensureAgentsMirror supersedes the spawn-level fallback for project scopes; - // omitting it also prevents a GLOBAL session from treating a vault-root project.md note as - // codex instructions (the spawn descriptor has no scope to gate on). + // Omitting the fallback prevents a GLOBAL session from treating a vault-root project.md note + // as codex instructions (the spawn descriptor has no scope to gate on). const backend = new CodexBackend(); const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" }); expect(desc.args).not.toContainEqual(expect.stringContaining("project_doc_fallback_filenames")); diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts index 135e5be5f..d78e555eb 100644 --- a/src/agentMode/backends/codex/CodexBackend.ts +++ b/src/agentMode/backends/codex/CodexBackend.ts @@ -30,9 +30,8 @@ export class CodexBackend implements AcpBackend { INITIAL_AGENT_MODE: "agent", } ); - // Forward the shared composed system prompt — the Copilot base framing - // (unless the user disabled it), the pill-syntax directive, and the user's - // custom prompt — via codex's `developer_instructions` config field as a + // Forward the shared built-in prompt — the Copilot base framing, tool + // guidance, and pill-syntax directive — via codex's `developer_instructions` as a // TOML 1.0 basic string. codex appends `developer_instructions` to its own // base prompt, so this adds the Obsidian-vault framing on top. Read at // spawn time; the host restarts codex on prompt changes via @@ -60,14 +59,8 @@ export class CodexBackend implements AcpBackend { "-c", 'sandbox_mode="workspace-write"', ]; - // DESIGN NOTE: deliberately no `project_doc_fallback_filenames=["project.md"]`. - // Post-Phase-2 the session-start `ensureAgentsMirror` (AgentSessionManager, run before - // `resolveSessionCwd` for codex/opencode project sessions) guarantees the marker'd - // `AGENTS.md` mirror exists in the project cwd, so a `project.md` fallback is redundant. - // This descriptor only knows `vaultBasePath`, not the session scope: a spawn-level fallback - // would also apply to GLOBAL sessions and let codex read a user's vault-root `project.md` - // note as instructions. On the rare ensure failure a project session gets no instructions - // (ensure never throws and re-runs next session) rather than the frontmatter-laden source. + // Deliberately no `project_doc_fallback_filenames=["project.md"]`: project.md is metadata, + // while Codex discovers the canonical AGENTS.md instructions from the session cwd. return descriptor; } } diff --git a/src/agentMode/backends/opencode/OpencodeBackend.test.ts b/src/agentMode/backends/opencode/OpencodeBackend.test.ts index 847d9eea0..cc4d2275c 100644 --- a/src/agentMode/backends/opencode/OpencodeBackend.test.ts +++ b/src/agentMode/backends/opencode/OpencodeBackend.test.ts @@ -660,7 +660,7 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)", expect(cfg.agent["copilot-build"].mode).toBe("primary"); }); - it("appends the user's selected custom prompt to both agent prompts", async () => { + it("does not copy Chat mode custom prompts into either agent prompt", async () => { updateCachedSystemPrompts([makeSystemPrompt("Haiku", "respond in haiku")]); setSelectedPromptTitle("Haiku"); const cfg = (await buildOpencodeConfig(getSettings(), NO_MODELS_DEPS)) as { @@ -668,13 +668,12 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)", }; for (const id of ["copilot-build", "build"]) { expect(cfg.agent[id].prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true); - expect(cfg.agent[id].prompt).toContain( - "\nrespond in haiku\n" - ); + expect(cfg.agent[id].prompt).not.toContain(""); + expect(cfg.agent[id].prompt).not.toContain("respond in haiku"); } }); - it("suppresses the base prompt when 'disable builtin' is on, keeping the user prompt + pill directive", async () => { + it("suppresses the base prompt when 'disable builtin' is on, keeping the pill directive", async () => { updateCachedSystemPrompts([makeSystemPrompt("Haiku", "respond in haiku")]); setSelectedPromptTitle("Haiku"); setDisableBuiltinSystemPrompt(true); @@ -684,7 +683,7 @@ describe("buildOpencodeConfig — agent/prompt/mode/skills blocks (preserved)", for (const id of ["copilot-build", "build"]) { expect(cfg.agent[id].prompt).not.toContain(COPILOT_PROMPT_BASE); expect(cfg.agent[id].prompt).not.toContain("You are Obsidian Copilot"); - expect(cfg.agent[id].prompt).toContain("respond in haiku"); + expect(cfg.agent[id].prompt).not.toContain("respond in haiku"); // Pill directive is functional wiring, not builtin framing — always sent. expect(cfg.agent[id].prompt).toContain("{folder_name}"); } diff --git a/src/agentMode/backends/opencode/OpencodeBackend.ts b/src/agentMode/backends/opencode/OpencodeBackend.ts index 531aaf8b2..7f86fa2fa 100644 --- a/src/agentMode/backends/opencode/OpencodeBackend.ts +++ b/src/agentMode/backends/opencode/OpencodeBackend.ts @@ -91,13 +91,8 @@ export class OpencodeBackend implements AcpBackend { ); } - // DESIGN NOTE: opencode only auto-discovers `AGENTS.md` from the session cwd and has no - // `project_doc_fallback_filenames` equivalent. The plugin guarantees the file exists by - // materializing the generated `AGENTS.md` mirror from the project's `project.md` at session - // start (see `ensureAgentsMirror`, called before cwd resolution in AgentSessionManager) — - // the same session-start ensure codex now relies on as its sole guarantee (codex's - // `project.md` fallback was removed; see the matching note in CodexBackend). Hence opencode - // needs no instruction-specific code in this spawn. + // opencode discovers vault and project AGENTS.md files from the session cwd, so this spawn + // needs no instruction-specific configuration. // The off-vault conversions cache lives outside opencode's `--cwd ` // boundary, so opencode prompts (`external_directory` ask) on every snapshot // read unless we pre-allow it (see `buildOpencodeConfig`). cacheRoot is a diff --git a/src/agentMode/backends/shared/agentSystemPrompt.test.ts b/src/agentMode/backends/shared/agentSystemPrompt.test.ts index 1f9f5bec2..7cbea6789 100644 --- a/src/agentMode/backends/shared/agentSystemPrompt.test.ts +++ b/src/agentMode/backends/shared/agentSystemPrompt.test.ts @@ -1,4 +1,5 @@ import { resetSettings, updateSetting } from "@/settings/model"; +import { AGENT_TODO_PLANNING_STEERING } from "@/system-prompts/agentTodoPlanningSteering"; import { setDefaultSystemPromptTitle, setDisableBuiltinSystemPrompt, @@ -31,201 +32,149 @@ function resetPromptState(): void { updateCachedSystemPrompts([]); } -describe("buildAgentSystemPrompt", () => { - beforeEach(() => { - resetSettings(); - resetPromptState(); - }); - - it("includes the Copilot base prompt and the pill-syntax directive by default", () => { - const prompt = buildAgentSystemPrompt(); - expect(prompt.startsWith(COPILOT_PROMPT_BASE)).toBe(true); - expect(prompt).toContain("{folder_name}"); - expect(prompt).toContain("{activeNote}"); - // No custom prompt selected → no user block. - expect(prompt).not.toContain(""); - }); - - it("appends the selected user custom prompt wrapped in ", () => { - updateCachedSystemPrompts([makePrompt("Haiku", "respond in haiku")]); - setSelectedPromptTitle("Haiku"); - const prompt = buildAgentSystemPrompt(); - expect(prompt).toContain(COPILOT_PROMPT_BASE); - expect(prompt).toContain( - "\nrespond in haiku\n" - ); - }); +describe("agentSystemPrompt", () => { + describe("buildAgentSystemPrompt()", () => { + beforeEach(() => { + resetSettings(); + resetPromptState(); + }); - it("falls back to the global default prompt when no session prompt is selected", () => { - updateCachedSystemPrompts([makePrompt("Terse", "be terse")]); - setDefaultSystemPromptTitle("Terse"); - const prompt = buildAgentSystemPrompt(); - expect(prompt).toContain("be terse"); - }); + it("includes the Copilot base prompt and the pill-syntax directive by default", () => { + const prompt = buildAgentSystemPrompt(); + expect(prompt.startsWith(COPILOT_PROMPT_BASE)).toBe(true); + expect(prompt).toContain("{folder_name}"); + expect(prompt).toContain("{activeNote}"); + expect(prompt).toContain(AGENT_TODO_PLANNING_STEERING); + expect(prompt).not.toContain(""); + }); - it("suppresses the base prompt when 'disable builtin' is on, keeping the user prompt + pill directive", () => { - updateCachedSystemPrompts([makePrompt("Haiku", "respond in haiku")]); - setSelectedPromptTitle("Haiku"); - setDisableBuiltinSystemPrompt(true); - const prompt = buildAgentSystemPrompt(); - expect(prompt).not.toContain(COPILOT_PROMPT_BASE); - expect(prompt).not.toContain("You are Obsidian Copilot"); - expect(prompt).toContain("respond in haiku"); - // The pill directive is functional wiring, not builtin framing — always sent. - expect(prompt).toContain("{folder_name}"); - }); + it("does not copy Chat mode custom prompts into the Agent Mode system prompt", () => { + updateCachedSystemPrompts([makePrompt("Haiku", "respond in haiku")]); + setSelectedPromptTitle("Haiku"); + setDefaultSystemPromptTitle("Haiku"); - it("omits the user block when no user prompt is set, even with builtin disabled", () => { - setDisableBuiltinSystemPrompt(true); - const prompt = buildAgentSystemPrompt(); - expect(prompt).not.toContain(COPILOT_PROMPT_BASE); - expect(prompt).not.toContain(""); - expect(prompt).toContain("{folder_name}"); - }); + const prompt = buildAgentSystemPrompt(); - it("steers toward the builtin Copilot Plus skills regardless of Plus status", () => { - // Default settings → NOT a Plus user; steering must still be present so a - // self-host user (Plus-enabled but isPaidUser=false) gets it, and non-Plus - // users fall back to their own tools via the steering's fallback clause. - const nonPlus = buildAgentSystemPrompt(); - expect(nonPlus).toContain(COPILOT_PLUS_TOOLS_STEERING); - expect(nonPlus).toContain("copilot-web-search"); - expect(nonPlus).toContain("copilot-web-fetch"); - expect(nonPlus).toContain("copilot-read-pdf"); - expect(nonPlus).toContain("copilot-youtube-transcript"); - expect(nonPlus).toContain("copilot-fetch-x"); - // Fallback clause so a missing/unlicensed skill never dead-ends or blocks - // a free user — it routes the agent to its own equivalent tool instead. - expect(nonPlus).toMatch(/silently fall back to your own equivalent tool/i); - expect(nonPlus).toMatch(/never refuse and never block the user/i); - // Fallback also covers a skill that runs but fails for this request (e.g. a - // page the relay can't fetch), so a single bad input doesn't dead-end it. - expect(nonPlus).toMatch(/fails for this particular request/i); - - // A Plus user gets the same steering. - updateSetting("isPaidUser", true); - expect(buildAgentSystemPrompt()).toContain(COPILOT_PLUS_TOOLS_STEERING); - }); + expect(prompt).not.toContain("respond in haiku"); + expect(prompt).not.toContain(""); + }); - it("suppresses the steering when the builtin prompt is disabled", () => { - setDisableBuiltinSystemPrompt(true); - const prompt = buildAgentSystemPrompt(); - expect(prompt).not.toContain(COPILOT_PLUS_TOOLS_STEERING); - }); + it("suppresses the base prompt when 'disable builtin' is on, keeping the pill directive", () => { + setDisableBuiltinSystemPrompt(true); + const prompt = buildAgentSystemPrompt(); + expect(prompt).not.toContain(COPILOT_PROMPT_BASE); + expect(prompt).not.toContain("You are Obsidian Copilot"); + expect(prompt).not.toContain(AGENT_TODO_PLANNING_STEERING); + expect(prompt).toContain("{folder_name}"); + }); - it("omits the Miyo steering when the search skill is not installed", () => { - updateSetting("enableMiyoSearchSkill", false); - const prompt = buildAgentSystemPrompt(); - expect(prompt).not.toContain(COPILOT_MIYO_SEARCH_STEERING); - expect(prompt).not.toContain("miyo-search"); - }); + it("steers toward the builtin Copilot Plus skills regardless of Plus status", () => { + // Default settings → NOT a Plus user; steering must still be present so a + // users fall back to their own tools via the steering's fallback clause. + const nonPlus = buildAgentSystemPrompt(); + expect(nonPlus).toContain(COPILOT_PLUS_TOOLS_STEERING); + expect(nonPlus).toContain("copilot-web-search"); + expect(nonPlus).toContain("copilot-web-fetch"); + expect(nonPlus).toContain("copilot-read-pdf"); + expect(nonPlus).toContain("copilot-youtube-transcript"); + expect(nonPlus).toContain("copilot-fetch-x"); + // Fallback clause so a missing/unlicensed skill never dead-ends or blocks + // a free user — it routes the agent to its own equivalent tool instead. + expect(nonPlus).toMatch(/silently fall back to your own equivalent tool/i); + expect(nonPlus).toMatch(/never refuse and never block the user/i); + // Fallback also covers a skill that runs but fails for this request (e.g. a + // page the relay can't fetch), so a single bad input doesn't dead-end it. + expect(nonPlus).toMatch(/fails for this particular request/i); + + // A Plus user gets the same steering. + updateSetting("isPaidUser", true); + expect(buildAgentSystemPrompt()).toContain(COPILOT_PLUS_TOOLS_STEERING); + }); - it("appends the Miyo steering only when the search skill is enabled", () => { - updateSetting("enableMiyoSearchSkill", true); - const prompt = buildAgentSystemPrompt(); - expect(prompt).toContain(COPILOT_MIYO_SEARCH_STEERING); - // Names the skill and gives concrete triggers for when to call it. - expect(prompt).toContain("miyo-search"); - expect(prompt).toMatch(/too slow|enough relevant/i); - expect(prompt).toMatch(/explicitly asks/i); - }); + it("suppresses the steering when the builtin prompt is disabled", () => { + setDisableBuiltinSystemPrompt(true); + const prompt = buildAgentSystemPrompt(); + expect(prompt).not.toContain(COPILOT_PLUS_TOOLS_STEERING); + }); - it("suppresses the Miyo steering when the builtin prompt is disabled, even if the skill is enabled", () => { - updateSetting("enableMiyoSearchSkill", true); - setDisableBuiltinSystemPrompt(true); - const prompt = buildAgentSystemPrompt(); - expect(prompt).not.toContain(COPILOT_MIYO_SEARCH_STEERING); - }); + it("omits the Miyo steering when the search skill is not installed", () => { + updateSetting("enableMiyoSearchSkill", false); + const prompt = buildAgentSystemPrompt(); + expect(prompt).not.toContain(COPILOT_MIYO_SEARCH_STEERING); + expect(prompt).not.toContain("miyo-search"); + }); - describe("project instructions (global parity gate)", () => { - it("is byte-identical to the no-arg form when projectInstructions is absent/empty/blank", () => { - const baseline = buildAgentSystemPrompt(); - // The global (no-project) parity guarantee: every "no instructions" - // spelling must produce the exact same payload as today's no-arg call. - expect(buildAgentSystemPrompt({})).toBe(baseline); - expect(buildAgentSystemPrompt({ projectInstructions: undefined })).toBe(baseline); - expect(buildAgentSystemPrompt({ projectInstructions: "" })).toBe(baseline); - expect(buildAgentSystemPrompt({ projectInstructions: " \n\t " })).toBe(baseline); - }); - - it("wraps the trimmed project instructions in as the final section", () => { - const baseline = buildAgentSystemPrompt(); - const prompt = buildAgentSystemPrompt({ - projectInstructions: " Only cite notes tagged #verified. ", - }); - // Existing payload is untouched and stays the prefix; the project body is - // appended after a blank-line delimiter, wrapped like the user block. - expect(prompt).toBe( - `${baseline}\n\n\nOnly cite notes tagged #verified.\n` - ); + it("appends the Miyo steering only when the search skill is enabled", () => { + updateSetting("enableMiyoSearchSkill", true); + const prompt = buildAgentSystemPrompt(); + expect(prompt).toContain(COPILOT_MIYO_SEARCH_STEERING); + // Names the skill and gives concrete triggers for when to call it. + expect(prompt).toContain("miyo-search"); + expect(prompt).toMatch(/too slow|enough relevant/i); + expect(prompt).toMatch(/explicitly asks/i); }); - it("appends project instructions even when the builtin prompt is disabled", () => { + it("suppresses the Miyo steering when the builtin prompt is disabled, even if the skill is enabled", () => { + updateSetting("enableMiyoSearchSkill", true); setDisableBuiltinSystemPrompt(true); - const baseline = buildAgentSystemPrompt(); - const prompt = buildAgentSystemPrompt({ projectInstructions: "project body" }); - expect(prompt).toBe( - `${baseline}\n\n\nproject body\n` - ); - expect(prompt.endsWith("")).toBe(true); + const prompt = buildAgentSystemPrompt(); + expect(prompt).not.toContain(COPILOT_MIYO_SEARCH_STEERING); }); - it("places project instructions after the user custom prompt", () => { - updateCachedSystemPrompts([makePrompt("Haiku", "respond in haiku")]); - setSelectedPromptTitle("Haiku"); - const prompt = buildAgentSystemPrompt({ projectInstructions: "PROJECT BODY" }); - expect(prompt.indexOf("")).toBeLessThan( - prompt.indexOf("") - ); + it("never copies user-authored project instructions or context payloads", () => { + const prompt = buildAgentSystemPrompt(); + expect(prompt).not.toContain(""); + expect(prompt).not.toMatch(/[\s\S]*<\/project_context>/); }); + }); - it("never emits a block in the system prompt (it rides the first user prompt now)", () => { - const prompt = buildAgentSystemPrompt({ projectInstructions: "PROJECT BODY" }); - expect(prompt).not.toContain(""); + describe("COPILOT_PROMPT_BASE", () => { + it("establishes Obsidian Copilot identity, not a CLI/coding agent", () => { + expect(COPILOT_PROMPT_BASE).toMatch(/Obsidian Copilot/); + expect(COPILOT_PROMPT_BASE).toMatch(/NOT a software-engineering agent or CLI coding tool/); }); - }); -}); -describe("COPILOT_PROMPT_BASE", () => { - it("establishes Obsidian Copilot identity, not a CLI/coding agent", () => { - expect(COPILOT_PROMPT_BASE).toMatch(/Obsidian Copilot/); - expect(COPILOT_PROMPT_BASE).toMatch(/NOT a software-engineering agent or CLI coding tool/); - }); + it("does not carry chat-mode-only baggage that misfires in tool-driven agents", () => { + expect(COPILOT_PROMPT_BASE).not.toMatch(/@vault/); + expect(COPILOT_PROMPT_BASE).not.toMatch(/getCurrentTime/); + expect(COPILOT_PROMPT_BASE).not.toMatch(/getTimeRangeMs/); + expect(COPILOT_PROMPT_BASE).not.toMatch(/YouTube/); + }); - it("does not carry chat-mode-only baggage that misfires in tool-driven agents", () => { - expect(COPILOT_PROMPT_BASE).not.toMatch(/@vault/); - expect(COPILOT_PROMPT_BASE).not.toMatch(/getCurrentTime/); - expect(COPILOT_PROMPT_BASE).not.toMatch(/getTimeRangeMs/); - expect(COPILOT_PROMPT_BASE).not.toMatch(/YouTube/); - }); + it("ports AGENT_LOOP_GUIDANCE behavior bullets", () => { + expect(COPILOT_PROMPT_BASE).toMatch(/NEVER search for the same/); + }); - it("ports AGENT_LOOP_GUIDANCE behavior bullets", () => { - expect(COPILOT_PROMPT_BASE).toMatch(/NEVER search for the same/); - }); + it("keeps project workspace policy in the internal prompt", () => { + expect(COPILOT_PROMPT_BASE).toContain(""); + expect(COPILOT_PROMPT_BASE).toContain("outputs/"); + expect(COPILOT_PROMPT_BASE).toContain("configured context sources"); + }); - it("renders note titles as bare [[wikilinks]], never backticked (v3 rule 7)", () => { - expect(COPILOT_PROMPT_BASE).toMatch( - /note titles[^\n]*\[\[title\]\][^\n]*never wrap them in backticks/i - ); - }); + it("renders note titles as bare [[wikilinks]], never backticked (v3 rule 7)", () => { + expect(COPILOT_PROMPT_BASE).toMatch( + /note titles[^\n]*\[\[title\]\][^\n]*never wrap them in backticks/i + ); + }); - it("renders image links without wrapping them in backticks (v3 rules 8-9)", () => { - expect(COPILOT_PROMPT_BASE).toMatch(/!\[\[link\]\][^\n]*never wrap them in backticks/i); - expect(COPILOT_PROMPT_BASE).toMatch(/!\[alt\]\(url\)[^\n]*never wrap them in backticks/i); - }); + it("renders image links without wrapping them in backticks (v3 rules 8-9)", () => { + expect(COPILOT_PROMPT_BASE).toMatch(/!\[\[link\]\][^\n]*never wrap them in backticks/i); + expect(COPILOT_PROMPT_BASE).toMatch(/!\[alt\]\(url\)[^\n]*never wrap them in backticks/i); + }); - it("specifies valid GitHub-flavored tables and forbids stray trailing pipes on caption lines", () => { - expect(COPILOT_PROMPT_BASE).toContain("a delimiter row of dashes"); - expect(COPILOT_PROMPT_BASE).toMatch( - /never append a trailing `\|` to a caption, heading, or any line that is not itself a table row/ - ); - // The old ambiguous wording made the agent append ` |` to a table's caption - // line, producing an orphan pipe row that breaks GFM rendering. - expect(COPILOT_PROMPT_BASE).not.toContain("immediately add ` |` after the table heading"); - }); + it("specifies valid GitHub-flavored tables and forbids stray trailing pipes on caption lines", () => { + expect(COPILOT_PROMPT_BASE).toContain("a delimiter row of dashes"); + expect(COPILOT_PROMPT_BASE).toMatch( + /never append a trailing `\|` to a caption, heading, or any line that is not itself a table row/ + ); + // The old ambiguous wording made the agent append ` |` to a table's caption + // line, producing an orphan pipe row that breaks GFM rendering. + expect(COPILOT_PROMPT_BASE).not.toContain("immediately add ` |` after the table heading"); + }); - it("retains the LaTeX and bullet-list formatting rules", () => { - expect(COPILOT_PROMPT_BASE).toMatch(/\$\.\.\.\$/); - expect(COPILOT_PROMPT_BASE).toContain("Never use `*` for bullets"); + it("retains the LaTeX and bullet-list formatting rules", () => { + expect(COPILOT_PROMPT_BASE).toMatch(/\$\.\.\.\$/); + expect(COPILOT_PROMPT_BASE).toContain("Never use `*` for bullets"); + }); }); }); diff --git a/src/agentMode/backends/shared/agentSystemPrompt.ts b/src/agentMode/backends/shared/agentSystemPrompt.ts index 26e78a200..a2151f522 100644 --- a/src/agentMode/backends/shared/agentSystemPrompt.ts +++ b/src/agentMode/backends/shared/agentSystemPrompt.ts @@ -8,7 +8,7 @@ * `claude_code` preset. Forwarding `COPILOT_PROMPT_BASE` to all three gives * the same "you are an Obsidian vault assistant" framing everywhere. * - * `buildAgentSystemPrompt` composes the full payload each backend forwards: + * `buildAgentSystemPrompt` composes the built-in payload each backend forwards: * * 1. `COPILOT_PROMPT_BASE` (the Obsidian-vault identity) — unless the user * enabled Settings → System prompts → "Disable builtin system prompt". @@ -16,12 +16,14 @@ * Plus skills, with a fallback to the agent's own tools) — sent to everyone. * Then `COPILOT_MIYO_SEARCH_STEERING` — appended only when `shouldUseMiyo` * is true, so the agent is pointed at the `miyo-search` skill only while - * Miyo is enabled and available. + * Miyo is enabled and available. Task-planning guidance follows as another + * internal behavior layer. * 2. The pill-syntax directive (`buildPillSyntaxDirective`) — always present; * it teaches the agent how to read the chat editor's `[[note]]`/`{folder}` * tokens and is functional wiring, not "builtin framing" the user toggles. - * 3. The user's custom prompt (`getEffectiveUserPrompt`) wrapped in - * ``, mirroring legacy chat's `getSystemPrompt()`. + * + * User-authored Agent Mode instructions live in AGENTS.md and are discovered + * from the session working directory instead of being copied into this prompt. * * `COPILOT_PROMPT_BASE` content is curated, not invented. Two existing Copilot * prompts cover most of what's needed: @@ -40,8 +42,8 @@ // discovery, or the Skills UI the barrel also re-exports. import { buildPillSyntaxDirective } from "@/agentMode/skills/pillSyntaxDirective"; import { getSettings } from "@/settings/model"; +import { AGENT_TODO_PLANNING_STEERING } from "@/system-prompts/agentTodoPlanningSteering"; import { getDisableBuiltinSystemPrompt } from "@/system-prompts/state"; -import { getEffectiveUserPrompt } from "@/system-prompts/systemPromptBuilder"; /** * Steers the agent toward the bundled Copilot Plus skills for the relay * capabilities (see `skills/builtin/builtinSkills.ts`) instead of its own @@ -79,7 +81,7 @@ If a skill is missing, disabled, reports that Copilot Plus is not active, or fai export const COPILOT_MIYO_SEARCH_STEERING = `## Vault semantic search (Miyo) The user has Miyo enabled: local, meaning-based semantic search over their vault. For any vault-search intent, use the \`miyo-search\` skill when your builtin \`grep\` search is too slow or doesn't surface enough relevant notes, or whenever the user explicitly asks for Miyo search. Follow the skill's own instructions to run it.`; -export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise. +export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault, or a project folder within it: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise. ## Grounding - The user's vault contains markdown notes. When the user says "note", they mean an Obsidian note in this vault. @@ -94,6 +96,12 @@ export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant th - After 1-2 searches, synthesize an answer from the results you have. Do not keep searching unless the results are clearly insufficient. - If you have enough information to answer, respond directly without calling any more tools. +## Project workspaces +When the conversation includes a \`\` block: +- Treat the working directory as that project's workspace. Write generated files, drafts, and intermediate artifacts under an \`outputs/\` folder inside it, creating it if needed, unless the user names a different destination. +- Read and search inside the working directory by default. The configured context sources in \`\` are also opted in even when they live outside it. When a source shows a \`→ \` snapshot pointer, read that path directly. +- Don't reach for unrelated files outside the working directory or configured context sources unless the instructions or user name a specific file or location. + ## Response Style - Respond at length appropriate to note-taking and knowledge work. Do NOT default to 1-3 line CLI cadence — give the user enough context to understand and act on your answer. - Be direct and concrete. Don't pad with preamble or postamble. @@ -119,29 +127,17 @@ export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant th * is ever needed, key it off the live model at a respawn or per-turn boundary * (e.g. a `restartOnModelChange` descriptor flag) — not a spawn-time id. * - * Reads the live system-prompt state (`getDisableBuiltinSystemPrompt`, - * `getEffectiveUserPrompt`) at call time. Backends call this at their natural + * Reads the live built-in-prompt state (`getDisableBuiltinSystemPrompt`) at call time. + * Backends call this at their natural * prompt-injection point — spawn time for opencode/codex, `newSession()` for * the Claude SDK — so a settings change applies to the next session. * - * `opts.projectInstructions` is the owning project's composed instruction - * body — the built-in project policy layered ahead of the user's `project.md` - * body (an opaque string the caller resolves via - * `getComposedProjectInstructions`; this module never touches the `projects/` - * layer). When absent or blank the output is byte-identical to the no-project - * prompt — the global (no-project) parity guarantee. When present it is - * wrapped in `` (mirroring ``) - * as the final section, after the user's custom prompt. codex/opencode get the - * same composed body for free via native `AGENTS.md` discovery from the - * session cwd; this append is the Claude SDK's equivalent, since it has no - * cwd-discovery channel. - * * Project *file context* (folders/notes/URLs) is NOT part of the system prompt: * it is delivered as a `` block inlined into the session's * first user message (reachable by all three backends), built by the context * materializer's `buildProjectContextBlock`. */ -export function buildAgentSystemPrompt(opts?: { projectInstructions?: string }): string { +export function buildAgentSystemPrompt(): string { const parts: string[] = []; // The "Disable builtin system prompt" toggle suppresses only the Copilot @@ -163,32 +159,10 @@ export function buildAgentSystemPrompt(opts?: { projectInstructions?: string }): if (getSettings().enableMiyoSearchSkill === true) { parts.push(COPILOT_MIYO_SEARCH_STEERING); } - // Extensibility seam — todo/plan steering. Today `AGENT_TODO_PLANNING_STEERING` - // is injected ONLY in Project scope (via `composeProjectInstructions`), so this - // global prompt stays byte-identical for no-project sessions. To make todo - // planning global: push it here AND remove it from `composeProjectInstructions` - // — otherwise a Project session receives the section twice (once globally, once - // via `` / AGENTS.md). Note the placement decision: pushing - // it INSIDE this `!getDisableBuiltinSystemPrompt()` branch ties it to the "Disable - // builtin system prompt" toggle (so those users lose Project todo steering too); - // push it OUTSIDE the branch if todo planning should survive that toggle. + parts.push(AGENT_TODO_PLANNING_STEERING); } parts.push(buildPillSyntaxDirective()); - const userPrompt = getEffectiveUserPrompt().trim(); - if (userPrompt) { - parts.push(`\n${userPrompt}\n`); - } - - // Optional project-instructions block, last. Absent/blank → nothing pushed, - // so the global (no-project) prompt stays byte-identical. Wrapped in - // ``, mirroring the `` block - // above — the plugin's convention for tagging opaque user content. - const projectInstructions = opts?.projectInstructions?.trim(); - if (projectInstructions) { - parts.push(`\n${projectInstructions}\n`); - } - return parts.join("\n\n"); } diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 2ef072ec1..d835cf5f2 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -111,8 +111,7 @@ function collectAgentSkillsDirsProjectRel(): Record { * The exact system prompt every backend bakes in — i.e. `buildAgentSystemPrompt`'s * real output, not a hand-picked subset. Used as the per-backend restart dedup * key so a restart fires iff the composed prompt actually changes: the "disable - * builtin" toggle, the user's custom prompt (including the legacy - * `userSystemPrompt` fallback), the base framing, and the pill directive. + * builtin" toggle, the base framing, tool guidance, and the pill directive. * Keying on the builder's actual output means the key can't silently drift from * what the backend sends. * @@ -237,17 +236,12 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen plugin.modelManagement.backendConfigRegistry.subscribe(() => restartProviderAffected("backend enabled models changed") ); - // The composed Agent Mode system prompt (Copilot base + pill directive + the - // user's custom prompt) is baked into opencode/codex spawn-time config and - // shared across sessions, so a prompt change only reaches those agents on a - // fresh spawn. Restart the opted-in backends when their *effective* composed - // prompt changes; the Claude SDK re-reads it per `newSession()` and opts out. + // The composed Agent Mode built-in prompt is baked into opencode/codex + // spawn-time config and shared across sessions. Restart the opted-in backends + // when its effective content changes; Claude re-reads it per `newSession()`. // - // The effective prompt depends on several stores (the session-selection atom, - // the prompts list, the persisted default-prompt-title, and the legacy - // `userSystemPrompt` fallback), and the underlying atoms also fire on no-op - // list reloads — so we dedupe per backend on the builder's real output rather - // than a guessed subset of inputs. On initial load this is a harmless no-op: + // The system-prompt store also emits for Chat mode prompt changes, so dedupe + // on the builder's real output. On initial load this is a harmless no-op: // `restartBackend` returns early when no subprocess is running yet. const lastSystemPromptKeys = new Map(); for (const descriptor of listBackendDescriptors()) { diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts index 6181aa610..2a40b1004 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts @@ -477,8 +477,8 @@ describe("ClaudeSdkBackendProcess", () => { // The SDK adapter's contract is "forward `getSystemPromptAppend()` verbatim // into `options.systemPrompt.append`", proven above. The Claude descriptor // wires that callback to `buildAgentSystemPrompt`, whose composition — the - // Copilot base prompt, pill directive, user custom prompt, and the - // disable-builtin behavior — is unit-tested in + // Copilot base prompt, pill directive, tool guidance, and disable-builtin + // behavior — is unit-tested in // `backends/shared/agentSystemPrompt.test.ts`. (The `sdk` layer can't import // a `backend` module under `boundaries/dependencies`, so that assertion // lives there, not here.) @@ -949,129 +949,6 @@ describe("ClaudeSdkBackendProcess", () => { }; } - describe("setProjectProfileProvider()", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - // Echoes the resolved project instructions so a test can read back exactly - // what the SDK passed to `getSystemPromptAppend` per session. Mirrors the - // descriptor's real `(opts) => buildAgentSystemPrompt(opts)`. - const echoAppend = (opts?: { projectInstructions?: string }): string => { - const parts = ["BASE"]; - if (opts?.projectInstructions) parts.push(opts.projectInstructions); - return parts.join("\n\n"); - }; - - function makeProc(getSystemPromptAppend: (opts?: { projectInstructions?: string }) => string) { - return new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getSystemPromptAppend, - }); - } - - function appendOf(callIndex: number): unknown { - const opts = (getPromptQueryCalls()[callIndex][0] as { options: Record }) - .options; - return (opts.systemPrompt as { append?: unknown } | undefined)?.append; - } - - it("global parity: an unset provider resolves the byte-identical no-project append", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - - // No provider wired, no projectId → same as today's global path. - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE"); - }); - - it("global parity: GLOBAL_SCOPE / unknown project (provider returns undefined) yields the global append", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider(() => undefined); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - projectId: "__global__", - }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE"); - }); - - it("appends the owning project's instructions when the provider resolves a profile", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider((id) => - id === "proj-1" ? { id, systemPrompt: "Cite only #verified notes." } : undefined - ); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - projectId: "proj-1", - }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE\n\nCite only #verified notes."); - }); - - it("resolves each session's own project on one process (per-session, not process-global)", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider((id) => { - if (id === "proj-A") return { id, systemPrompt: "A rules" }; - if (id === "proj-B") return { id, systemPrompt: "B rules" }; - return undefined; - }); - - const a = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-A" }); - const b = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-B" }); - proc.registerSessionHandler(a.sessionId, () => {}); - proc.registerSessionHandler(b.sessionId, () => {}); - - await proc.prompt({ sessionId: a.sessionId, prompt: [{ type: "text", text: "hi" }] }); - await proc.prompt({ sessionId: b.sessionId, prompt: [{ type: "text", text: "hi" }] }); - - // The two prompt calls carry each session's own project instructions. - expect(appendOf(0)).toBe("BASE\n\nA rules"); - expect(appendOf(1)).toBe("BASE\n\nB rules"); - }); - - it("captures project instructions at newSession, ignoring later provider swaps mid-session", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider((id) => - id === "proj-1" ? { id, systemPrompt: "ORIGINAL" } : undefined - ); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - projectId: "proj-1", - }); - proc.registerSessionHandler(sessionId, () => {}); - // Swap the provider after the session exists; the captured append must win. - proc.setProjectProfileProvider((id) => - id === "proj-1" ? { id, systemPrompt: "CHANGED" } : undefined - ); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE\n\nORIGINAL"); - }); - }); - describe("supportsAdditionalDirectories()", () => { beforeEach(() => { queryMock.mockReset(); diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts index d6ff0066e..aed1c9a50 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -47,7 +47,6 @@ import type { OpenSessionOutput, PermissionDecision, PermissionPrompt, - ProjectProfile, PromptInput, PromptOutput, ResumeSessionInput, @@ -121,10 +120,9 @@ interface SessionState { active?: Query; /** * Snapshot of the composed Copilot system prompt (base framing + pill-syntax - * directive + user custom prompt) captured at `newSession()` time so a - * settings change takes effect on the next session rather than mid- - * conversation. Empty string = no append. Appended to Claude's default - * `claude_code` preset via `options.systemPrompt.append`. + * directive + built-in tool guidance) captured at `newSession()` time. + * Empty string = no append. Appended to Claude's default `claude_code` + * preset via `options.systemPrompt.append`. */ systemPromptAppend: string; } @@ -192,18 +190,12 @@ export interface ClaudeSdkBackendProcessOptions { getDefaultModelId?: () => string | undefined; /** * Returns the composed Copilot system prompt to append to Claude's default - * `claude_code` system prompt (base Obsidian framing + pill-syntax directive - * + user custom prompt). Read once per `newSession()` so a settings change + * `claude_code` system prompt (base Obsidian framing + pill-syntax directive). + * Read once per `newSession()` so a settings change * applies to the next session rather than mid-turn. Empty string / undefined * disables the append. - * - * `projectInstructions` is the owning project's resolved instruction body - * (from {@link setProjectProfileProvider}); the descriptor composes it into - * the append. An omitted object, or one whose `projectInstructions` is - * `undefined` (no project / GLOBAL_SCOPE / unset provider), yields the - * byte-identical global prompt. */ - getSystemPromptAppend?: (opts?: { projectInstructions?: string }) => string | undefined; + getSystemPromptAppend?: () => string | undefined; /** * User-defined env vars merged onto `process.env` for the spawned `claude` * CLI. Read per `prompt()` so settings edits apply on the next turn. @@ -252,15 +244,6 @@ export class ClaudeSdkBackendProcess implements BackendProcess { null; private askUserQuestionPrompter: AskUserQuestionPrompter | null = null; private isReadOnlySession: ((sessionId: SessionId) => boolean) | null = null; - /** - * Resolves a session's owning-project instructions by scope id. Injected by - * the manager via {@link setProjectProfileProvider} (mirrors the prompter - * setters). Null until wired, and returns `undefined` for `GLOBAL_SCOPE` / - * unknown projects — both paths fall back to the global prompt. - */ - private projectProfileProvider: - | ((projectId: ProjectScopeId) => ProjectProfile | undefined) - | null = null; private exitListeners = new Set<() => void>(); private shuttingDown = false; private readonly bridge: PermissionBridge; @@ -312,22 +295,8 @@ export class ClaudeSdkBackendProcess implements BackendProcess { this.askUserQuestionPrompter = fn; } - setProjectProfileProvider(fn: (projectId: ProjectScopeId) => ProjectProfile | undefined): void { - this.projectProfileProvider = fn; - } - - /** - * Compose this session's system-prompt append, resolving the owning - * project's instructions (if any). A defined non-global `projectId` consults - * the injected provider; `undefined` projectId, an unset provider, or a - * provider that returns `undefined` (GLOBAL_SCOPE / unknown project) all - * yield no project instructions → the append is byte-identical to the global - * (no-project) prompt. Captured at `newSession`/`resumeSession` time so a - * settings change applies to the next session, not mid-conversation. - */ - private resolveSystemPromptAppend(projectId: ProjectScopeId | undefined): string { - const profile = projectId !== undefined ? this.projectProfileProvider?.(projectId) : undefined; - return this.opts.getSystemPromptAppend?.({ projectInstructions: profile?.systemPrompt }) ?? ""; + private resolveSystemPromptAppend(): string { + return this.opts.getSystemPromptAppend?.() ?? ""; } registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void { @@ -378,7 +347,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess { mcpServers: mcp, model: seedModelId, additionalDirectories: params.additionalDirectories, - systemPromptAppend: this.resolveSystemPromptAppend(params.projectId), + systemPromptAppend: this.resolveSystemPromptAppend(), claudeTaskPlan: createClaudeTaskPlanState(), backgroundTasks: new ClaudeBackgroundTaskStateMachine(), }); @@ -434,7 +403,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess { // to Claude's default `claude_code` preset. The SDK's preset+append form // preserves the full default system prompt — keeping Claude's tool and // planning framing — while layering on the Obsidian-vault identity, the - // pill-syntax directive, and the user's custom prompt. + // pill-syntax directive, and Copilot's built-in tool guidance. if (session.systemPromptAppend) { options.systemPrompt = { type: "preset", @@ -785,7 +754,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess { mcpServers: mcp, model: seedModelId, additionalDirectories: params.additionalDirectories, - systemPromptAppend: this.resolveSystemPromptAppend(params.projectId), + systemPromptAppend: this.resolveSystemPromptAppend(), claudeTaskPlan: createClaudeTaskPlanState(), backgroundTasks: new ClaudeBackgroundTaskStateMachine(), }); diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index 0fee5f5fb..2033d31b5 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -43,11 +43,6 @@ const settingsChangeCallbacks = new Set< (prev: { agentMode: unknown }, next: { agentMode: unknown }) => void >(); -// Stub the project-folder mirror so a non-global spawn never touches the vault. -jest.mock("@/projects/ensureAgentsMirror", () => ({ - ensureAgentsMirror: jest.fn(async () => undefined), -})); - // MRU touch is fire-and-forget through the singleton; mock it so enterProject // tests assert the call without real frontmatter IO. const mockTouchProjectLastUsed = jest.fn(async () => undefined); diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index b0b919c86..522f68f36 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -75,8 +75,6 @@ import { import { ProjectContentTracker } from "@/context/projectContentTracker"; import { buildProjectContextUpdatesBlock } from "@/context/contextUpdatesBlockBuilder"; import { ProjectFileManager } from "@/projects/ProjectFileManager"; -import { ensureAgentsMirror } from "@/projects/ensureAgentsMirror"; -import { getComposedProjectInstructions } from "@/projects/projectSystemPrompt"; import type { AgentQuestionAnswers, AskUserQuestionPrompt, @@ -91,7 +89,6 @@ import type { ModelSelection, PermissionDecision, PermissionPrompt, - ProjectProfile, SessionId, } from "./types"; @@ -147,11 +144,6 @@ const EMPTY_HISTORY_ITEMS = Object.freeze([]) as unknown as ChatHistoryItem[]; // `.has()`, read `.size`, and iterate. const EMPTY_RECENT_CHAT_IDS: ReadonlySet = new Set(); -// Backends that discover project instructions from a physical `AGENTS.md` in the session cwd. -// (claude instead has `project.md` parsed and injected in-process via setProjectProfileProvider, -// so it needs no file.) Only these require the generated mirror to exist before cwd is read. -const CWD_INSTRUCTION_BACKENDS: ReadonlySet = new Set(["codex", "opencode"]); - // Delivery-cursor seed for a resumed session: BEHIND any real content epoch // (which starts at 0), so its first send always emits the coarse freshness note // regardless of whether a change was observed this plugin session. @@ -283,8 +275,8 @@ export class AgentSessionManager { // (no lost update). Mirrors chat mode's `markdownNeedsReload`. private readonly contextDirtySignatures = new Map(); // A project landing session captures MORE than materialized context at - // creation: codex/opencode read the AGENTS.md mirror from cwd, and Claude - // captures its project-instructions append. The materialization dirty flag + // creation: each backend reads the project's instruction files from cwd. + // The materialization dirty flag // above deliberately ignores `systemPrompt`, so reuse decisions need their own // fingerprint of what the session actually baked in (the landing-capture // signature). Keyed by internal session id; every project session gets an @@ -1185,24 +1177,14 @@ export class AgentSessionManager { const requestedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode"; const resolvedId = this.opts.resolveDescriptor(requestedId) ? requestedId : "opencode"; - // Read the live record once: it drives both the AGENTS.md mirror below and - // the landing-capture signature recorded after the session is built, so the - // two agree on the exact config this session bakes in. + // Read the live record once for the landing-capture signature recorded + // after the session is built. const projectRecord = projectId === GLOBAL_SCOPE ? undefined : getCachedProjectRecordById(projectId); const landingCaptureSignature = projectRecord ? getProjectLandingCaptureSignature(projectRecord) : undefined; - // Materialize the project's AGENTS.md mirror from project.md BEFORE resolving cwd, so a - // cwd-instruction backend (codex/opencode) discovers the instruction file on its first - // session. This is the sole correctness guarantee for an old project.md-only project. - // Never throws (degrades gracefully); skipped for GLOBAL_SCOPE and for claude (which gets - // the instruction injected in-process, no file needed). - if (projectRecord && CWD_INSTRUCTION_BACKENDS.has(resolvedId)) { - await ensureAgentsMirror(this.app, projectRecord); - } - // Resolves the scope's cwd (vault root for global, project folder otherwise) // and validates desktop/orphaned up front, before any pending-create state // is mutated. @@ -1454,8 +1436,8 @@ export class AgentSessionManager { * * Publishes the blocking load state SYNCHRONOUSLY (before the session even * appears) so the composer gates send the instant the tab renders, then flips - * to `done`/`error` and stores the result for {@link getProjectProfile} once - * the materializer settles. NEVER rejects — failure degrades to an empty + * to `done`/`error` and stores the result for the session once the materializer + * settles. NEVER rejects — failure degrades to an empty * result so the session's `newSession` (which awaits this) is never blocked. * * Resolves the full {@link ContextMaterializationResult} (searchable roots + @@ -2882,14 +2864,6 @@ export class AgentSessionManager { sessionId: SessionId, projectId: ProjectScopeId ): Promise { - // Same AGENTS.md mirror ensure as `createSession` — resume rehydrates an existing - // session, but its cwd still derives from the project record, so a cwd-instruction - // backend needs the mirror materialized before cwd is read. Never throws; skipped for - // GLOBAL_SCOPE and for claude. - if (projectId !== GLOBAL_SCOPE && CWD_INSTRUCTION_BACKENDS.has(backendId)) { - const record = getCachedProjectRecordById(projectId); - if (record) await ensureAgentsMirror(this.app, record); - } const cwd = this.resolveSessionCwd(projectId); // Settle queued vault events so the revision key below reflects a just-edited // source — otherwise resume could join an in-flight run reading the old content @@ -3315,29 +3289,6 @@ export class AgentSessionManager { // Lets a backend with its own permission gate (Claude SDK) hard-deny write/exec // tools for read-only fan-out sub-sessions — see `permissionBridge`. proc.setReadOnlySessionPredicate?.((sessionId) => this.isReadOnlyFanoutSession(sessionId)); - // Inject the project-instruction resolver. Wiring here (the single - // warm-adopt + fresh choke point) covers both backend bring-up paths. - // Backends that discover instructions from cwd (codex/opencode) omit the - // setter and this is a no-op. - proc.setProjectProfileProvider?.((projectId) => this.getProjectProfile(projectId)); - } - - /** - * Map a scope id to the minimal {@link ProjectProfile} a backend needs to - * inject project instructions. Returns `undefined` for {@link GLOBAL_SCOPE} - * or an unknown project — keeping the `projects/` lookup here so - * `backends/` never imports the projects layer. - */ - private getProjectProfile(projectId: ProjectScopeId): ProjectProfile | undefined { - if (projectId === GLOBAL_SCOPE) return undefined; - const record = getCachedProjectRecordById(projectId); - if (!record) return undefined; - return { - id: record.project.id, - // Layer the built-in project policy ahead of the user's own instruction body, so Claude's - // `` carries the same composed body codex/opencode get via the mirror. - systemPrompt: getComposedProjectInstructions(record), - }; } private async ensureBackend( diff --git a/src/agentMode/session/descriptor.ts b/src/agentMode/session/descriptor.ts index efee761a7..ce43b618c 100644 --- a/src/agentMode/session/descriptor.ts +++ b/src/agentMode/session/descriptor.ts @@ -140,11 +140,9 @@ export interface BackendDescriptor { /** * When true, the host restarts this backend whenever the effective Agent - * Mode system prompt changes (the user's selected/default custom prompt or - * the "Disable builtin system prompt" toggle). Set for backends (opencode, - * codex) that bake the composed system prompt into spawn-time config and - * share one subprocess across sessions, so a changed prompt only reaches the - * agent on the next spawn. + * Mode built-in system prompt changes. Set for backends (opencode, codex) + * that bake the composed prompt into spawn-time config and share one + * subprocess across sessions. * * The Claude SDK adapter re-reads the composed prompt per `newSession()`, so * a new chat already picks up the change without a restart — it sets this to diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts index e8506f394..02c02507f 100644 --- a/src/agentMode/session/types.ts +++ b/src/agentMode/session/types.ts @@ -653,30 +653,14 @@ export type McpServerSpec = headers: Array<{ name: string; value: string }>; }; -// ---- Project scope ------------------------------------------------------ - -/** - * Minimal per-session view of a project's instruction payload. Deliberately - * decoupled from `aiParams.ProjectConfig` (which still carries legacy CAG - * fields like `projectModelKey`): a backend that injects project instructions - * needs only the scope id and the already-parsed, frontmatter-stripped system - * prompt. The manager maps `ProjectConfig → ProjectProfile` so `backends/` - * never imports the `projects/` layer. - */ -export interface ProjectProfile { - id: string; - systemPrompt: string; -} - // ---- Session-creation I/O shapes --------------------------------------- export interface OpenSessionInput { cwd: string; mcpServers: McpServerSpec[]; /** - * Scope this session belongs to ({@link ProjectScopeId}); captured like - * `cwd` so the backend can resolve the owning project's instructions. - * Absent / `GLOBAL_SCOPE` means the implicit global workspace. + * Scope this session belongs to ({@link ProjectScopeId}). Absent / + * `GLOBAL_SCOPE` means the implicit global workspace. */ projectId?: ProjectScopeId; /** @@ -788,16 +772,6 @@ export interface BackendProcess { * sessions; ACP backends governed by the shared prompter omit it. */ setReadOnlySessionPredicate?(fn: (sessionId: SessionId) => boolean): void; - /** - * Optional: register the resolver a backend calls to look up a session's - * project instructions by scope id. Mirrors the prompter setters; the - * manager supplies a resolver that maps the cached project record to a - * minimal {@link ProjectProfile}. Returns `undefined` for `GLOBAL_SCOPE` - * or an unknown project. Only backends that inject project instructions - * implement it (Claude SDK in PR2b-1); codex/opencode discover AGENTS.md - * from the session cwd and omit it. - */ - setProjectProfileProvider?(fn: (projectId: ProjectScopeId) => ProjectProfile | undefined): void; registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void; newSession(params: OpenSessionInput): Promise; prompt(params: PromptInput): Promise; @@ -846,9 +820,8 @@ export interface BackendProcess { /** * Whether the backend honors {@link OpenSessionInput.additionalDirectories} * (widening the agent's searchable roots on every session-lifecycle request: - * new / resume / load). Optional like the - * other incrementally-added capability hooks (`setProjectProfileProvider`): - * the manager always forwards `additionalDirectories` and each backend gates + * new / resume / load). The manager always forwards `additionalDirectories` + * and each backend gates * internally on this before passing them to its wire/SDK. Backends that * cannot widen roots omit it or return `false`. */ diff --git a/src/agentMode/ui/ProjectInfoPopover.test.tsx b/src/agentMode/ui/ProjectInfoPopover.test.tsx index f764aaf7c..1c96fd982 100644 --- a/src/agentMode/ui/ProjectInfoPopover.test.tsx +++ b/src/agentMode/ui/ProjectInfoPopover.test.tsx @@ -19,7 +19,6 @@ jest.mock("@/projects/state", () => ({ jest.mock("@/projects/projectPaths", () => ({ getProjectFolderPath: (folderName: string) => `copilot/projects/${folderName}`, })); - // Keep the edit/reveal collaborators inert — exercised elsewhere. jest.mock("@/components/modals/project/AddProjectModal", () => ({ AddProjectModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })), @@ -28,12 +27,9 @@ const revealProjectFolder = jest.fn(); jest.mock("@/agentMode/ui/AgentProjectRowActions", () => ({ revealProjectFolder: (...args: unknown[]) => revealProjectFolder(...args), })); -const openSystemPromptModal = jest.fn(); -jest.mock("@/agentMode/ui/ProjectSystemPromptModal", () => ({ - ProjectSystemPromptModal: jest.fn().mockImplementation((...args: unknown[]) => { - openSystemPromptModal(...args); - return { open: jest.fn() }; - }), +const openAgentsFile = jest.fn().mockResolvedValue(undefined); +jest.mock("@/instructions/agentsFile", () => ({ + openAgentsFile: (...args: unknown[]) => openAgentsFile(...args), })); import { TFile, TFolder } from "obsidian"; @@ -58,25 +54,12 @@ function makeFolder(names: string[]) { return folder; } -// A marker line a generated AGENTS.md mirror carries (see ensureAgentsMirror.ts) — a read -// returning this means "generated, hide it". Ownership keys off the stable -// `MIRROR_MARKER_PREFIX`, not the exact wording, so this older-tail variant is still detected; -// keeping it here doubles as a backward tail-compat check. -const MIRROR_MARKER = - ""; - -function renderPopover( - todoList: AgentTodoListEntry[] | null, - folderNames: string[] = [], - fileContents: Record = {} -) { - getCachedProjectRecordById.mockReturnValue({ folderName: "proj-1" }); +function renderPopover(todoList: AgentTodoListEntry[] | null, folderNames: string[] = []) { + getCachedProjectRecordById.mockReturnValue({ folderName: "proj-1", project: PROJECT }); const openFile = jest.fn().mockResolvedValue(undefined); const app = { vault: { getAbstractFileByPath: jest.fn().mockReturnValue(makeFolder(folderNames)), - read: jest.fn((file: TFile) => Promise.resolve(fileContents[file.name] ?? "")), }, workspace: { getLeaf: jest.fn().mockReturnValue({ openFile }) }, } as unknown as Parameters[0]["app"]; @@ -89,16 +72,15 @@ function renderPopover( describe("ProjectInfoPopover", () => { beforeEach(() => jest.clearAllMocks()); - it("renders the project name and the System Prompt row (never the backing file)", async () => { + it("renders the project name and canonical AGENTS.md row", async () => { renderPopover(null, ["project.md", "AGENTS.md", "notes.md"]); expect(screen.getAllByText("My Research").length).toBeGreaterThan(0); - // Await the async file listing so its setState settles inside act(). - expect(await screen.findByText("System Prompt")).toBeTruthy(); + expect(await screen.findByText("AGENTS.md")).toBeTruthy(); }); it("omits the Progress section when there is no todo list", async () => { renderPopover(null); - expect(await screen.findByText("System Prompt")).toBeTruthy(); + expect(await screen.findByText("AGENTS.md")).toBeTruthy(); expect(screen.queryByText("Progress")).toBeNull(); }); @@ -108,58 +90,35 @@ describe("ProjectInfoPopover", () => { { content: "step B", status: "in_progress" }, { content: "step C", status: "pending" }, ]); - await screen.findByText("System Prompt"); + await screen.findByText("AGENTS.md"); expect(screen.getByText("Progress")).toBeTruthy(); expect(screen.getByText("1/3")).toBeTruthy(); expect(screen.getByText("step A")).toBeTruthy(); expect(screen.getByText("step C")).toBeTruthy(); }); - it("lists folder files but excludes project.md and a GENERATED AGENTS.md mirror", async () => { - renderPopover(null, ["project.md", "AGENTS.md", "guide.pdf", "draft.md"], { - "AGENTS.md": `${MIRROR_MARKER}\n\nbe helpful`, // marker → generated mirror - }); + it("lists folder files but excludes project.md and a duplicate AGENTS.md row", async () => { + renderPopover(null, ["project.md", "AGENTS.md", "guide.pdf", "draft.md"]); expect(await screen.findByText("guide.pdf")).toBeTruthy(); expect(screen.getByText("draft.md")).toBeTruthy(); expect(screen.queryByText("project.md")).toBeNull(); - expect(screen.queryByText("AGENTS.md")).toBeNull(); - }); - - it("KEEPS a user-authored AGENTS.md (no marker) in the file list", async () => { - renderPopover(null, ["project.md", "AGENTS.md", "draft.md"], { - "AGENTS.md": "my own agent rules", // no marker → user-authored, must show - }); - expect(await screen.findByText("AGENTS.md")).toBeTruthy(); - expect(screen.getByText("draft.md")).toBeTruthy(); - expect(screen.queryByText("project.md")).toBeNull(); - }); - - it("keeps an AGENTS.md visible when its content cannot be read", async () => { - getCachedProjectRecordById.mockReturnValue({ folderName: "proj-1" }); - const app = { - vault: { - getAbstractFileByPath: jest.fn().mockReturnValue(makeFolder(["AGENTS.md", "draft.md"])), - read: jest.fn().mockRejectedValue(new Error("read failed")), - }, - workspace: { getLeaf: jest.fn().mockReturnValue({ openFile: jest.fn() }) }, - } as unknown as Parameters[0]["app"]; - render(); - fireEvent.click(screen.getByLabelText("Project info for My Research")); - // Read failure must not hide a possibly-user file. - expect(await screen.findByText("AGENTS.md")).toBeTruthy(); + expect(screen.getAllByText("AGENTS.md")).toHaveLength(1); }); - it("opens the System Prompt editor when the row is clicked", async () => { + it("opens the project AGENTS.md file with the legacy body as missing-file content", async () => { renderPopover(null); - fireEvent.click(await screen.findByText("System Prompt")); - expect(openSystemPromptModal).toHaveBeenCalledTimes(1); - // (app, initialPrompt, persistFn) - expect(openSystemPromptModal.mock.calls[0][1]).toBe("be helpful"); + fireEvent.click(await screen.findByText("AGENTS.md")); + expect(openAgentsFile).toHaveBeenCalledWith( + expect.anything(), + "copilot/projects/proj-1", + "be helpful", + true + ); }); it("reveals the project folder from the header button", async () => { renderPopover(null); - await screen.findByText("System Prompt"); + await screen.findByText("AGENTS.md"); fireEvent.click(screen.getByLabelText("Reveal project folder in vault")); expect(revealProjectFolder).toHaveBeenCalledWith(expect.anything(), PROJECT); }); diff --git a/src/agentMode/ui/ProjectInfoPopover.tsx b/src/agentMode/ui/ProjectInfoPopover.tsx index 6b1eaff80..738f773d2 100644 --- a/src/agentMode/ui/ProjectInfoPopover.tsx +++ b/src/agentMode/ui/ProjectInfoPopover.tsx @@ -1,34 +1,27 @@ import { revealProjectFolder } from "@/agentMode/ui/AgentProjectRowActions"; -import { ProjectSystemPromptModal } from "@/agentMode/ui/ProjectSystemPromptModal"; import type { AgentTodoListEntry } from "@/agentMode/session/types"; import { ProjectConfig } from "@/aiParams"; import { AddProjectModal } from "@/components/modals/project/AddProjectModal"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { openAgentsFile } from "@/instructions/agentsFile"; import { cn } from "@/lib/utils"; import { logError } from "@/logger"; -import { isGeneratedAgentsMirrorContent } from "@/projects/ensureAgentsMirror"; import { getProjectFolderPath } from "@/projects/projectPaths"; import { ProjectFileManager } from "@/projects/ProjectFileManager"; import { getCachedProjectRecordById } from "@/projects/state"; import { + ArrowUpRight, Check, ChevronDown, ChevronRight, FolderSearch, List, Settings, - SquarePen, } from "lucide-react"; -import { App, TFile, TFolder } from "obsidian"; -import React, { memo, useEffect, useState } from "react"; +import { App, Notice, TFile, TFolder } from "obsidian"; +import React, { memo, useMemo, useState } from "react"; -/** - * The project's instruction config — always represented by the fixed "System - * Prompt" row, never listed as a plain file. `AGENTS.md` is NOT in here: it can - * be either the plugin's generated mirror (hidden) or a user-authored file - * (shown), distinguished by marker content, not by name — see the listing. - */ const HIDDEN_BASENAME = "project.md"; const AGENTS_BASENAME = "agents.md"; @@ -114,58 +107,24 @@ interface ProjectFilesSectionProps { app: App; project: ProjectConfig; onClose: () => void; - /** Fired after the System Prompt is saved, so the caller refreshes its cache. */ - onEdited?: (project: ProjectConfig) => void; } -function ProjectFilesSection({ app, project, onClose, onEdited }: ProjectFilesSectionProps) { +function ProjectFilesSection({ app, project, onClose }: ProjectFilesSectionProps) { const [outputsOpen, setOutputsOpen] = useState(false); - const [files, setFiles] = useState([]); - - // Direct children of the project folder, minus: ALL dot-prefixed entries - // (user dot-files like `.env` — hiding dot-files is the conventional listing - // default, matching Finder/`ls` - // and Obsidian's own dot-folder handling), project.md (the System Prompt row - // represents it), and a GENERATED AGENTS.md mirror. A user-authored AGENTS.md - // (no marker) is kept — distinguishing the two needs the file CONTENT, not - // its name, so the listing is async. Runs when the popover body mounts (it - // only exists while open, so each open re-lists fresh without a vault - // subscription). - useEffect(() => { - let cancelled = false; - void (async () => { - const record = getCachedProjectRecordById(project.id); - const folderPath = record ? getProjectFolderPath(record.folderName) : null; - const folder = folderPath ? app.vault.getAbstractFileByPath(folderPath) : null; - if (!(folder instanceof TFolder)) { - if (!cancelled) setFiles([]); - return; - } - const candidates = folder.children.filter( + const files = useMemo(() => { + const record = getCachedProjectRecordById(project.id); + const folderPath = record ? getProjectFolderPath(record.folderName) : null; + const folder = folderPath ? app.vault.getAbstractFileByPath(folderPath) : null; + if (!(folder instanceof TFolder)) return []; + return folder.children + .filter( (child): child is TFile => child instanceof TFile && !child.name.startsWith(".") && - child.name.toLowerCase() !== HIDDEN_BASENAME - ); - const visible: TFile[] = []; - for (const child of candidates) { - if (child.name.toLowerCase() === AGENTS_BASENAME) { - // Hide only the generated mirror; on read failure keep the file - // visible rather than risk hiding a user's own AGENTS.md. - const isMirror = await app.vault - .read(child) - .then(isGeneratedAgentsMirrorContent) - .catch(() => false); - if (isMirror) continue; - } - visible.push(child); - } - visible.sort((a, b) => a.name.localeCompare(b.name)); - if (!cancelled) setFiles(visible); - })(); - return () => { - cancelled = true; - }; + child.name.toLowerCase() !== HIDDEN_BASENAME && + child.name.toLowerCase() !== AGENTS_BASENAME + ) + .sort((a, b) => a.name.localeCompare(b.name)); }, [app, project.id]); const handleOpenFile = (file: TFile) => { @@ -176,17 +135,19 @@ function ProjectFilesSection({ app, project, onClose, onEdited }: ProjectFilesSe .catch((err) => logError("[ProjectInfoPopover] openFile failed", err)); }; - const handleEditSystemPrompt = () => { + const handleOpenProjectInstructions = () => { + const record = getCachedProjectRecordById(project.id); + if (!record) return; onClose(); - new ProjectSystemPromptModal(app, project.systemPrompt ?? "", async (prompt) => { - const updated = await ProjectFileManager.getInstance(app).updateProject(project.id, { - ...project, - systemPrompt: prompt, - }); - // Keep the caller's cached project in sync with the gear-edit path, so a - // reopen reads the new prompt instead of the pre-save value. - onEdited?.(updated.project); - }).open(); + void openAgentsFile( + app, + getProjectFolderPath(record.folderName), + record.project.systemPrompt ?? "", + true + ).catch((error) => { + logError("[ProjectInfoPopover] Failed to open project AGENTS.md", error); + new Notice("Failed to open project AGENTS.md."); + }); }; return ( @@ -207,20 +168,19 @@ function ProjectFilesSection({ app, project, onClose, onEdited }: ProjectFilesSe - {/* Fixed first row: the project's instructions. Shows ONLY the label — - never the backing file name (project.md / the AGENTS.md mirror). */} + {/* Fixed first row: the project's canonical instructions file. */}
{ - if (e.key === "Enter" || e.key === " ") handleEditSystemPrompt(); + if (e.key === "Enter" || e.key === " ") handleOpenProjectInstructions(); }} > - System Prompt -
{files.map((file) => ( @@ -285,7 +245,7 @@ interface ProjectInfoPopoverProps { * Project-info popover anchored to the project header's trailing button, * replacing the old `⋯` overflow menu (design: PROJECT_INFO_POPOVER.md, * project-info-panel-hifi.html F1–F3). Top card (name + Edit gear + reveal) → - * Progress (live todo list, hidden when none) → Project files (System Prompt + * Progress (live todo list, hidden when none) → Project files (AGENTS.md * row + folder files + Outputs placeholder). Deliberately NO Delete and no * config chips — deletion stays on the project list rows' inline actions. */ @@ -370,12 +330,7 @@ export const ProjectInfoPopover = memo( - setOpen(false)} - onEdited={onEdited} - /> + setOpen(false)} /> ); diff --git a/src/agentMode/ui/ProjectSystemPromptModal.tsx b/src/agentMode/ui/ProjectSystemPromptModal.tsx deleted file mode 100644 index e899dbe5d..000000000 --- a/src/agentMode/ui/ProjectSystemPromptModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { logError } from "@/logger"; -import { createPluginRoot } from "@/utils/react/createPluginRoot"; -import { App, Modal, Notice } from "obsidian"; -import React, { useState } from "react"; -import { Root } from "react-dom/client"; - -interface ProjectSystemPromptModalContentProps { - initialPrompt: string; - onSave: (prompt: string) => Promise; - onCancel: () => void; -} - -function ProjectSystemPromptModalContent({ - initialPrompt, - onSave, - onCancel, -}: ProjectSystemPromptModalContentProps) { - const [prompt, setPrompt] = useState(initialPrompt); - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - setSaving(true); - try { - await onSave(prompt); - } finally { - setSaving(false); - } - }; - - return ( -
-