diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b05bcb07..33466938 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -246,7 +246,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header 2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters 3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents -4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. +4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. 5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH` CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 4469b585..fe2f9615 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -442,7 +442,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 3. **Tool action display**: Edit `src/components/AiActionCard.tsx` 4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts` 5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling. -6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. +6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. ### Work with external MCP setup diff --git a/docs/adr/0103-adapter-specific-ai-permission-semantics.md b/docs/adr/0103-adapter-specific-ai-permission-semantics.md new file mode 100644 index 00000000..4368c2be --- /dev/null +++ b/docs/adr/0103-adapter-specific-ai-permission-semantics.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0103" +title: "Adapter-specific AI permission semantics" +status: active +date: 2026-04-30 +supersedes: "0092" +--- + +## Context + +ADR-0092 introduced per-vault Vault Safe / Power User modes, but the first implementation left too much room for adapter drift. Some agents can directly deny or allow Bash, some expose only a sandbox/approval profile, and Pi currently has no narrower app-managed switch beyond Tolaria's transient MCP configuration. The shared UI still needs a consistent product contract: Vault Safe must not encourage shell execution, while Power User should keep shell execution available across repeated agent turns where the selected adapter supports it. + +## Decision + +**Tolaria treats the permission mode as a product contract first and maps it conservatively per adapter.** + +- Shared AI system prompts are mode-aware on every turn, including turns with note context snapshots. +- Vault Safe tells agents not to use or advertise shell, terminal, Bash, script execution, git, or command-line tools. +- Power User tells shell-capable agents that local shell commands are available for the active vault and should remain scoped to that vault. +- Claude Code Safe excludes Bash; Power User includes and pre-approves Bash without dangerous bypass flags. +- Codex Safe uses the CLI's read-only sandbox plus untrusted approval policy; Power User uses workspace-write plus never-ask approval so shell-capable Codex turns remain low-friction across the session. +- OpenCode Safe denies bash and external directories; Power User allows bash while still denying external directories. +- Pi keeps the same conservative transient MCP config in both modes until the Pi CLI exposes a reliable app-managed shell permission switch. The prompt must not promise shell for Pi Power User. +- Gemini Safe excludes `run_shell_command`; Gemini Power User intentionally uses `yolo` with trusted transient Tolaria MCP settings. + +## Consequences + +- Mode behavior is no longer described solely by generic UI copy; adapter docs and tests define the exact mapping. +- Codex Vault Safe remains a best-effort safe profile rather than a true built-in-tools-off mode, because Codex CLI currently exposes sandbox and approval controls but not a dedicated switch to remove shell tooling while preserving MCP. +- Future adapters must either implement both modes explicitly or document that Power User maps to the same conservative behavior. +- If Tolaria adds a stronger warning or dangerous mode later, it needs a separate ADR and UI language. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4c1e7b22..8de9da91 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -144,7 +144,7 @@ proposed → active → superseded | [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active | | [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active | | [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active | -| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active | +| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | superseded -> [0103](0103-adapter-specific-ai-permission-semantics.md) | | [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active | | [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active | | [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active | @@ -155,3 +155,4 @@ proposed → active → superseded | [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active | | [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active | | [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active | +| [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active | diff --git a/mcp-server/index.js b/mcp-server/index.js index 7e672bd6..bac19916 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -2,9 +2,8 @@ /** * Tolaria MCP Server — lightweight vault tools for AI agents. * - * The agent has full shell access (bash, read, write, edit). - * These MCP tools provide Tolaria-specific capabilities that - * native tools cannot replace: + * These MCP tools provide Tolaria-specific capabilities alongside each + * app-managed agent's own Safe / Power User permission profile: * * - search_notes: full-text search across vault notes * - get_vault_context: vault structure overview (types, note count, folders) diff --git a/mcp-server/vault.js b/mcp-server/vault.js index 22a305ea..9b7dc6cf 100644 --- a/mcp-server/vault.js +++ b/mcp-server/vault.js @@ -1,6 +1,7 @@ /** - * Vault operations — read-only helpers for Laputa markdown vault. - * Write operations are handled by the agent's native bash/write/edit tools. + * Vault operations — read-only helpers for Tolaria markdown vault. + * Write operations are handled by the app-managed agent's active permission + * profile and native file-edit tools when available. */ import fs from 'node:fs/promises' import path from 'node:path' diff --git a/src-tauri/src/codex_cli.rs b/src-tauri/src/codex_cli.rs index 337357e7..9a168fc3 100644 --- a/src-tauri/src/codex_cli.rs +++ b/src-tauri/src/codex_cli.rs @@ -29,19 +29,13 @@ where } fn find_codex_binary() -> Result { - if let Some(binary) = find_codex_binary_on_path() { - return Ok(binary); - } - - if let Some(binary) = find_codex_binary_in_user_shell() { - return Ok(binary); - } - - if let Some(binary) = find_existing_binary(codex_binary_candidates())? { - return Ok(binary); - } - - Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) + find_codex_binary_on_path() + .filter(is_usable_codex_binary) + .or_else(|| find_codex_binary_in_user_shell().filter(is_usable_codex_binary)) + .or_else(|| find_usable_codex_binary(codex_binary_candidates())) + .ok_or_else(|| { + "Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into() + }) } fn find_codex_binary_on_path() -> Option { @@ -139,8 +133,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec) -> Result, String> { - crate::cli_agent_runtime::find_executable_binary_candidate(candidates, "Codex CLI") +fn find_usable_codex_binary(candidates: Vec) -> Option { + candidates.into_iter().find(is_usable_codex_binary) +} + +fn is_usable_codex_binary(binary: &PathBuf) -> bool { + crate::cli_agent_runtime::version_for_binary(binary).is_some() } fn run_agent_stream_with_binary( @@ -186,9 +184,9 @@ fn build_codex_args(request: &AgentStreamRequest) -> Result, String> Ok(vec![ "--sandbox".into(), - "workspace-write".into(), + codex_sandbox(request.permission_mode).into(), "--ask-for-approval".into(), - "never".into(), + codex_approval_policy(request.permission_mode).into(), "exec".into(), "--json".into(), "-C".into(), @@ -205,6 +203,20 @@ fn build_codex_args(request: &AgentStreamRequest) -> Result, String> ]) } +fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str { + match permission_mode { + crate::ai_agents::AiAgentPermissionMode::Safe => "read-only", + crate::ai_agents::AiAgentPermissionMode::PowerUser => "workspace-write", + } +} + +fn codex_approval_policy(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str { + match permission_mode { + crate::ai_agents::AiAgentPermissionMode::Safe => "untrusted", + crate::ai_agents::AiAgentPermissionMode::PowerUser => "never", + } +} + fn build_codex_prompt(request: &AgentStreamRequest) -> String { crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) } @@ -276,7 +288,7 @@ fn format_codex_error(stderr_output: String, status: String) -> String { } if is_codex_write_permission_error(&lower) { - return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into(); + return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into(); } if stderr_output.trim().is_empty() { @@ -331,13 +343,10 @@ mod tests { } } - fn assert_codex_workspace_write_contract(args: &[String]) { - let prefix = [ - "--sandbox", - "workspace-write", - "--ask-for-approval", - "never", - ]; + fn assert_codex_permission_contract(args: &[String], permission_mode: AiAgentPermissionMode) { + let sandbox = codex_sandbox(permission_mode); + let approval = codex_approval_policy(permission_mode); + let prefix = ["--sandbox", sandbox, "--ask-for-approval", approval]; assert_eq!(&args[..prefix.len()], prefix); assert!(!args.iter().any(|arg| arg == "danger-full-access")); @@ -396,26 +405,21 @@ mod tests { permission_mode: AiAgentPermissionMode::Safe, }) { assert_eq!(args[4], "exec"); - assert_codex_workspace_write_contract(&args); + assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe); assert!(args.contains(&"--json".to_string())); assert!(args.contains(&"-C".to_string())); } } #[test] - fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() { - for permission_mode in [ - AiAgentPermissionMode::Safe, - AiAgentPermissionMode::PowerUser, - ] { - if let Ok(args) = build_codex_args(&AgentStreamRequest { - message: "Rename the note".into(), - system_prompt: None, - vault_path: "/tmp/vault".into(), - permission_mode, - }) { - assert_codex_workspace_write_contract(&args); - } + fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() { + if let Ok(args) = build_codex_args(&AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + permission_mode: AiAgentPermissionMode::PowerUser, + }) { + assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser); } } @@ -522,6 +526,18 @@ exit 2 assert!(candidates.contains(&codex), "missing {}", codex.display()); } + #[cfg(unix)] + #[test] + fn usable_codex_binary_skips_broken_shims() { + let dir = tempfile::tempdir().unwrap(); + let broken = executable_script(dir.path(), "broken-codex", "exit 1\n"); + let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n"); + + let found = find_usable_codex_binary(vec![broken, working.clone()]); + + assert_eq!(found, Some(working)); + } + #[test] fn first_existing_path_skips_empty_and_missing_lines() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1c20635a..03712479 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -850,9 +850,11 @@ mod tests { let allowed_roots = vec![vault_a.clone()]; assert_eq!( - missing_asset_scope_roots(&allowed_roots, &[vault_b.clone()]), + missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_b)), vec![vault_b] ); - assert!(missing_asset_scope_roots(&allowed_roots, &[vault_a]).is_empty()); + assert!( + missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty() + ); } } diff --git a/src/App.test.tsx b/src/App.test.tsx index f2b784a1..79c2a9a6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1052,7 +1052,7 @@ describe('App', () => { await waitFor(() => { expect(getHeader()).toHaveTextContent('Inbox') }) - }) + }, 10_000) it('opens favorites directly into Neighborhood mode', async () => { configureNeighborhoodFavoritesVault() diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 270ee781..fbfa5616 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -102,7 +102,7 @@ describe('SettingsPanel', () => { expect(screen.getByText('设置')).toBeInTheDocument() expect(screen.queryByText('Settings')).not.toBeInTheDocument() - }) + }, 10_000) it('calls onSave with stable defaults on save', () => { render( diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 7d4a4021..5e43f0d3 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -103,7 +103,7 @@ describe('StatusBar', () => { it('build number shows the update tooltip on focus', async () => { render() await expectTooltip(screen.getByRole('button', { name: 'Check for updates' }), 'Check for updates') - }) + }, 10_000) it('does not display branch name', () => { render() diff --git a/src/components/TypeCustomizePopover.test.tsx b/src/components/TypeCustomizePopover.test.tsx index 956fb605..d3ba4942 100644 --- a/src/components/TypeCustomizePopover.test.tsx +++ b/src/components/TypeCustomizePopover.test.tsx @@ -70,7 +70,7 @@ describe('TypeCustomizePopover', () => { expect(screen.getByText('Icon')).toBeInTheDocument() expect(screen.getByText('Template')).toBeInTheDocument() expect(screen.getByText('Done')).toBeInTheDocument() - }) + }, 10_000) it('can hide the template and Done controls for inline appearance editing', () => { renderPopover({ showTemplate: false, showDone: false, surface: 'inline' }) diff --git a/src/hooks/useCliAiAgent.test.ts b/src/hooks/useCliAiAgent.test.ts index a494e9ac..d488420c 100644 --- a/src/hooks/useCliAiAgent.test.ts +++ b/src/hooks/useCliAiAgent.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useCliAiAgent } from './useCliAiAgent' import { streamAiAgent } from '../utils/streamAiAgent' +import { buildAgentSystemPrompt } from '../utils/ai-agent' vi.mock('../utils/streamAiAgent', () => ({ streamAiAgent: vi.fn(), @@ -12,6 +13,7 @@ vi.mock('../utils/ai-agent', () => ({ })) const mockStreamAiAgent = vi.mocked(streamAiAgent) +const mockBuildAgentSystemPrompt = vi.mocked(buildAgentSystemPrompt) const VAULT = '/Users/luca/Laputa' function renderAgent( @@ -48,8 +50,13 @@ describe('useCliAiAgent', () => { }) expect(result.current.sendMessage).not.toBe(firstSendMessage) + expect(mockBuildAgentSystemPrompt).toHaveBeenCalledWith({ + agent: 'codex', + permissionMode: 'safe', + vaultContext: 'You are viewing note with body: Hello world', + }) expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({ - systemPrompt: 'You are viewing note with body: Hello world', + systemPrompt: 'default-system-prompt', })) }) diff --git a/src/lib/aiAgentConversation.test.ts b/src/lib/aiAgentConversation.test.ts index 6d8c421a..525ef96a 100644 --- a/src/lib/aiAgentConversation.test.ts +++ b/src/lib/aiAgentConversation.test.ts @@ -115,12 +115,16 @@ describe('aiAgentConversation', () => { ] const result = buildFormattedMessage( - { agent: 'codex', ready: true, vaultPath: '/vault' }, + { agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' }, messages, { text: 'Latest question' }, ) - expect(buildAgentSystemPromptMock).toHaveBeenCalledTimes(1) + expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({ + agent: 'codex', + permissionMode: 'safe', + vaultContext: undefined, + }) expect(trimHistoryMock).toHaveBeenCalledWith([ { role: 'user', content: 'First question', id: 'msg-1' }, { role: 'assistant', content: 'First answer', id: 'msg-1-resp' }, @@ -135,15 +139,25 @@ describe('aiAgentConversation', () => { }) }) - it('prefers a system prompt override when provided', () => { + it('appends context snapshots to the mode-aware system prompt', () => { const result = buildFormattedMessage( - { agent: 'codex', ready: true, vaultPath: '/vault', systemPromptOverride: 'OVERRIDE' }, + { + agent: 'codex', + ready: true, + vaultPath: '/vault', + permissionMode: 'power_user', + systemPromptOverride: 'CONTEXT', + }, [], { text: 'Prompt' }, ) - expect(buildAgentSystemPromptMock).not.toHaveBeenCalled() - expect(result.systemPrompt).toBe('OVERRIDE') + expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({ + agent: 'codex', + permissionMode: 'power_user', + vaultContext: 'CONTEXT', + }) + expect(result.systemPrompt).toBe('SYSTEM') }) }) diff --git a/src/lib/aiAgentConversation.ts b/src/lib/aiAgentConversation.ts index f12f2972..f892a298 100644 --- a/src/lib/aiAgentConversation.ts +++ b/src/lib/aiAgentConversation.ts @@ -110,7 +110,11 @@ export function buildFormattedMessage( messages: AiAgentMessage[], prompt: PendingUserPrompt, ): { formattedMessage: string; systemPrompt: string } { - const systemPrompt = context.systemPromptOverride ?? buildAgentSystemPrompt() + const systemPrompt = buildAgentSystemPrompt({ + agent: context.agent, + permissionMode: context.permissionMode, + vaultContext: context.systemPromptOverride, + }) const chatHistory = toChatHistory(messages.filter((message) => !message.isStreaming)) const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS) diff --git a/src/lib/aiAgentSession.test.ts b/src/lib/aiAgentSession.test.ts index c5d3f78e..0064aaf2 100644 --- a/src/lib/aiAgentSession.test.ts +++ b/src/lib/aiAgentSession.test.ts @@ -133,7 +133,7 @@ function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void { expect(streamAiAgentMock).toHaveBeenCalledWith({ agent: 'codex', message: 'formatted:Latest question', - systemPrompt: 'OVERRIDE', + systemPrompt: 'SYSTEM', vaultPath: '/vault', permissionMode: 'power_user', callbacks: { stream: 'callbacks' }, @@ -256,6 +256,11 @@ describe('aiAgentSession', () => { expectStreamingRuntimeState(session) expectFormattedHistoryUsed() + expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({ + agent: 'codex', + permissionMode: 'power_user', + vaultContext: 'OVERRIDE', + }) expectStreamingRequest(session.runtime) expect(trackEventMock).toHaveBeenCalledWith('ai_agent_message_sent', { agent: 'codex', diff --git a/src/lib/aiAgentStreamCallbacks.test.ts b/src/lib/aiAgentStreamCallbacks.test.ts index 0a09646c..5c047d53 100644 --- a/src/lib/aiAgentStreamCallbacks.test.ts +++ b/src/lib/aiAgentStreamCallbacks.test.ts @@ -146,6 +146,7 @@ describe('aiAgentStreamCallbacks', () => { }) callbacks.onError('boom') + callbacks.onDone() expect(status.getStatus()).toBe('error') expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_failed', { @@ -169,6 +170,7 @@ describe('aiAgentStreamCallbacks', () => { response: 'Partial reply\n\nError: boom', }, ]) + expect(trackEventMock).not.toHaveBeenCalledWith('ai_agent_response_completed', expect.anything()) }) it.each([ diff --git a/src/lib/aiAgentStreamCallbacks.ts b/src/lib/aiAgentStreamCallbacks.ts index 6fc2f9c3..5094ae6b 100644 --- a/src/lib/aiAgentStreamCallbacks.ts +++ b/src/lib/aiAgentStreamCallbacks.ts @@ -44,6 +44,7 @@ export function createStreamCallbacks(context: StreamMutationContext) { fileCallbacksRef, } = context let failureTracked = false + let streamFailed = false return { onThinking: (chunk: string) => { @@ -97,6 +98,7 @@ export function createStreamCallbacks(context: StreamMutationContext) { if (abortRef.current.aborted) return setStatus('error') + streamFailed = true const partial = responseAccRef.current failureTracked = true trackAiAgentResponseFailed(agent, partial, toolInputMapRef.current.size) @@ -113,6 +115,7 @@ export function createStreamCallbacks(context: StreamMutationContext) { onDone: () => { if (abortRef.current.aborted) return + if (streamFailed) return setStatus('done') const finalResponse = finalResponseText(responseAccRef.current, agent) diff --git a/src/utils/ai-agent.test.ts b/src/utils/ai-agent.test.ts index 5cb3897d..928f8afc 100644 --- a/src/utils/ai-agent.test.ts +++ b/src/utils/ai-agent.test.ts @@ -9,7 +9,8 @@ describe('buildAgentSystemPrompt', () => { const prompt = buildAgentSystemPrompt() expect(prompt).toContain('working inside Tolaria') expect(prompt).toContain('active vault') - expect(prompt).toContain('Avoid shell commands') + expect(prompt).toContain('Vault Safe mode is active') + expect(prompt).toContain('not available in Vault Safe') expect(prompt).not.toContain('full shell access') expect(prompt).not.toContain('Vault context') }) @@ -21,6 +22,19 @@ describe('buildAgentSystemPrompt', () => { expect(prompt).toContain('Recent notes: foo, bar') }) + it('allows shell commands in power user mode where supported', () => { + const prompt = buildAgentSystemPrompt({ agent: 'codex', permissionMode: 'power_user' }) + expect(prompt).toContain('Power User mode is active') + expect(prompt).toContain('Local shell commands are available') + expect(prompt).not.toContain('not available in Vault Safe') + }) + + it('does not promise shell execution for Pi power user mode', () => { + const prompt = buildAgentSystemPrompt({ agent: 'pi', permissionMode: 'power_user' }) + expect(prompt).toContain('Pi currently uses the same conservative Tolaria MCP configuration') + expect(prompt).not.toContain('Local shell commands are available') + }) + it('instructs AI to use wikilink syntax', () => { const prompt = buildAgentSystemPrompt() expect(prompt).toContain('[[') diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index 6ffda9b3..8e52df83 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -1,5 +1,8 @@ +import type { AiAgentId } from '../lib/aiAgents' +import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode' + /** - * AI Agent utilities — Claude CLI agent mode with scoped file tools + MCP vault tools. + * AI Agent utilities for app-managed CLI agent sessions. * * App-managed sessions can edit files in the active vault and use Tolaria-specific * MCP tools (search_notes, get_vault_context, get_note, open_note). @@ -8,18 +11,47 @@ // --- Agent system prompt --- +interface AgentSystemPromptOptions { + vaultContext?: string + permissionMode?: AiAgentPermissionMode + agent?: AiAgentId +} + +function normalizePromptOptions( + options?: string | AgentSystemPromptOptions, +): AgentSystemPromptOptions { + return typeof options === 'string' ? { vaultContext: options } : options ?? {} +} + +function permissionModeInstructions( + mode: AiAgentPermissionMode = 'safe', + agent?: AiAgentId, +): string { + if (mode === 'power_user') { + if (agent === 'pi') { + return `Power User mode is selected, but Pi currently uses the same conservative Tolaria MCP configuration in both modes. Do not promise shell execution unless the Pi CLI exposes it directly in this run.` + } + + return `Power User mode is active. Local shell commands are available for this vault where the selected CLI agent supports them. Keep commands scoped to the active vault, avoid destructive commands unless explicitly requested, and do not expose note content unnecessarily.` + } + + return `Vault Safe mode is active. Do not use shell, terminal, Bash, Python/Node script execution, git, or command-line tools. If the user asks whether shell commands are available, say they are not available in Vault Safe. Use file/search/edit tools and Tolaria MCP tools instead.` +} + const AGENT_SYSTEM_PREAMBLE = `You are working inside Tolaria, a personal knowledge management app. Notes are markdown files with YAML frontmatter. Standard fields: title, type (aliased is_a), date, tags. You can edit markdown files in the active vault. Prefer file edit tools for note changes. Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note). -Avoid shell commands; app-managed sessions are intentionally scoped to vault file edits and Tolaria MCP tools. When you create or edit a note, call open_note(path) so the user sees it in Tolaria. When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it. Be concise and helpful. When you've completed a task, briefly summarize what you did.` -export function buildAgentSystemPrompt(vaultContext?: string): string { - if (!vaultContext) return AGENT_SYSTEM_PREAMBLE - return `${AGENT_SYSTEM_PREAMBLE}\n\nVault context:\n${vaultContext}` +export function buildAgentSystemPrompt(options?: string | AgentSystemPromptOptions): string { + const { vaultContext, permissionMode, agent } = normalizePromptOptions(options) + const prompt = `${AGENT_SYSTEM_PREAMBLE}\n\n${permissionModeInstructions(permissionMode, agent)}` + + if (!vaultContext) return prompt + return `${prompt}\n\nVault context:\n${vaultContext}` }