feat: add vault ai agent permission modes

This commit is contained in:
lucaronin
2026-04-29 00:52:27 +02:00
parent 91db7c0098
commit d2d4746d5c
31 changed files with 1402 additions and 231 deletions

View File

@@ -676,7 +676,8 @@ No indexing step required — search runs directly against the filesystem.
Per-vault settings stored locally and scoped by vault path:
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files

View File

@@ -224,9 +224,9 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both 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.
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** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
3. **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, while Power User adds Bash without using dangerous bypass flags. Codex runs 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. OpenCode and Pi both 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. **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`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
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.
@@ -243,7 +243,7 @@ sequenceDiagram
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
R->>R: pick adapter for claude_code, codex, opencode, or pi
R->>C: spawn agent with MCP-enabled config
@@ -788,14 +788,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.

View File

@@ -79,7 +79,7 @@ tolaria/
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
@@ -114,6 +114,7 @@ tolaria/
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
@@ -261,8 +262,8 @@ tolaria/
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, stream normalization, and permission-mode request normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning, permission-mode tool mapping, and NDJSON stream parsing. |
| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
@@ -282,9 +283,10 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
@@ -304,7 +306,7 @@ tolaria/
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
@@ -420,8 +422,9 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
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. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list unless the permission mode explicitly broadens it)
6. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
### Work with external MCP setup

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0092"
title: "Vault-scoped AI agent permission modes"
status: active
date: 2026-04-28
---
## Context
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
## Decision
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
Adapter mappings remain conservative:
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
## Options Considered
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
## Consequences
- Vault config normalization owns the safe default for old vaults and malformed values.
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
- Any future dangerous mode requires a new ADR and separate UI language.

View File

@@ -144,3 +144,4 @@ 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 |

View File

@@ -12,6 +12,19 @@ pub enum AiAgentId {
Pi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentPermissionMode {
Safe,
PowerUser,
}
impl Default for AiAgentPermissionMode {
fn default() -> Self {
Self::Safe
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AiAgentAvailability {
pub installed: bool,
@@ -61,6 +74,13 @@ pub struct AiAgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: Option<AiAgentPermissionMode>,
}
impl AiAgentStreamRequest {
fn permission_mode(&self) -> AiAgentPermissionMode {
self.permission_mode.unwrap_or_default()
}
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
@@ -76,12 +96,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
where
F: FnMut(AiAgentStreamEvent),
{
let permission_mode = request.permission_mode();
match request.agent {
AiAgentId::ClaudeCode => {
let mapped = crate::claude_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::claude_cli::run_agent_stream(mapped, |event| {
if let Some(mapped_event) = map_claude_event(event) {
@@ -95,6 +117,7 @@ where
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::opencode_cli::run_agent_stream(mapped, emit)
}
@@ -103,6 +126,7 @@ where
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::pi_cli::run_agent_stream(mapped, emit)
}
@@ -264,15 +288,26 @@ fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = find_codex_binary()?;
run_codex_agent_stream_with_binary(&binary, request, emit)
}
fn run_codex_agent_stream_with_binary<F>(
binary: &Path,
request: AiAgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let args = build_codex_args(&request)?;
let prompt = build_codex_prompt(&request);
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
let mut command = build_codex_command(binary, args, prompt, &request.vault_path);
let mut child = command
.spawn()
@@ -518,13 +553,85 @@ mod tests {
use super::*;
use std::ffi::OsStr;
#[cfg(unix)]
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join(name);
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn codex_request(
vault_path: &Path,
permission_mode: Option<AiAgentPermissionMode>,
) -> AiAgentStreamRequest {
AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Summarize".into(),
system_prompt: None,
vault_path: vault_path.to_string_lossy().into_owned(),
permission_mode,
}
}
fn assert_codex_workspace_write_contract(args: &[String]) {
let prefix = [
"--sandbox",
"workspace-write",
"--ask-for-approval",
"never",
];
assert_eq!(&args[..prefix.len()], prefix);
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
assert!(!args
.iter()
.any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
}
#[cfg(unix)]
fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(dir.path(), "codex", body);
let mut events = Vec::new();
let thread_id = run_codex_agent_stream_with_binary(
&binary,
codex_request(vault.path(), Some(AiAgentPermissionMode::Safe)),
|event| events.push(event),
)
.unwrap();
(thread_id, events)
}
fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == session
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == text_delta
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
assert!(matches!(status.claude_code.installed, true | false));
assert!(matches!(status.codex.installed, true | false));
assert!(matches!(status.opencode.installed, true | false));
assert!(matches!(status.pi.installed, true | false));
let install_flags = [
status.claude_code.installed,
status.codex.installed,
status.opencode.installed,
status.pi.installed,
];
assert!(install_flags
.iter()
.all(|installed| matches!(installed, true | false)));
}
#[test]
@@ -534,6 +641,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: Some("Be concise".into()),
vault_path: "/tmp/vault".into(),
permission_mode: Some(AiAgentPermissionMode::Safe),
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
@@ -547,19 +655,33 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: None,
}) {
assert_eq!(args[0], "--sandbox");
assert_eq!(args[1], "workspace-write");
assert_eq!(args[2], "--ask-for-approval");
assert_eq!(args[3], "never");
assert_eq!(args[4], "exec");
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(!args.contains(&"danger-full-access".to_string()));
assert_codex_workspace_write_contract(&args);
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(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: Some(permission_mode),
}) {
assert_codex_workspace_write_contract(&args);
}
}
}
#[test]
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
@@ -579,6 +701,37 @@ mod tests {
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
let (thread_id, events) = run_codex_script(
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
"#,
);
assert_eq!(thread_id, "thread_1");
assert_codex_text_flow(&events, "thread_1", "Done");
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
let (thread_id, events) = run_codex_script(
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' 'login required' >&2
exit 2
"#,
);
assert_eq!(thread_id, "thread_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn codex_binary_candidates_include_supported_macos_installs() {
let home = PathBuf::from("/Users/alex");

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
@@ -56,6 +57,7 @@ pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
// ---------------------------------------------------------------------------
@@ -290,7 +292,7 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
agent_tools(req.permission_mode).into(),
"--no-session-persistence".into(),
];
@@ -304,6 +306,13 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
Ok(args)
}
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -663,6 +672,82 @@ mod tests {
use std::ffi::OsString;
use std::process::Command;
macro_rules! chat_request {
($message:expr, None, None $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: None,
session_id: None,
}
};
($message:expr, Some($system_prompt:expr), None $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: Some($system_prompt.to_string()),
session_id: None,
}
};
($message:expr, None, Some($session_id:expr) $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: None,
session_id: Some($session_id.to_string()),
}
};
}
macro_rules! agent_request {
($message:expr, None, $permission_mode:expr $(,)?) => {
AgentStreamRequest {
message: $message.into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: $permission_mode,
}
};
($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => {
AgentStreamRequest {
message: $message.into(),
system_prompt: Some($system_prompt.to_string()),
vault_path: "/tmp/vault".into(),
permission_mode: $permission_mode,
}
};
}
macro_rules! assert_args_contain {
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
$(
assert!($args.contains(&$value.to_string()), "missing {}", $value);
)+
};
}
macro_rules! assert_args_lack {
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
$(
assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value);
)+
};
}
macro_rules! assert_no_arg_contains {
($args:expr, $fragment:expr $(,)?) => {
assert!(!$args.iter().any(|arg| arg.contains($fragment)));
};
}
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
let candidates = claude_binary_candidates_for_home(home);
for candidate in expected {
assert!(
candidates.contains(candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn check_cli_returns_status() {
let status = check_cli();
@@ -685,6 +770,38 @@ mod tests {
assert!(candidates.contains(&claude), "missing {}", claude.display());
}
#[test]
fn agent_args_use_safe_mode_without_bash_by_default() {
let args = build_agent_args(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::Safe,
))
.unwrap();
assert_args_contain!(
args,
["--strict-mcp-config", "--permission-mode", "acceptEdits"]
);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_no_arg_contains!(args, "Bash");
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
}
#[test]
fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() {
let args = build_agent_args(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::PowerUser,
))
.unwrap();
assert_args_contain!(args, ["--strict-mcp-config"]);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]);
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
}
#[test]
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
@@ -1236,52 +1353,31 @@ mod tests {
#[test]
fn build_chat_args_basic() {
let req = ChatStreamRequest {
message: "hello".into(),
system_prompt: None,
session_id: None,
};
let args = build_chat_args(&req);
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"hello".to_string()));
assert!(args.contains(&"stream-json".to_string()));
assert!(!args.contains(&"--system-prompt".to_string()));
assert!(!args.contains(&"--resume".to_string()));
let args = build_chat_args(&chat_request!("hello", None, None));
assert_args_contain!(args, ["-p", "hello", "stream-json"]);
assert_args_lack!(args, ["--system-prompt", "--resume"]);
}
#[test]
fn build_chat_args_with_system_prompt() {
let req = ChatStreamRequest {
message: "hi".into(),
system_prompt: Some("You are helpful.".into()),
session_id: None,
};
let args = build_chat_args(&req);
assert!(args.contains(&"--system-prompt".to_string()));
assert!(args.contains(&"You are helpful.".to_string()));
let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None));
assert_args_contain!(args, ["--system-prompt", "You are helpful."]);
}
#[test]
fn build_chat_args_empty_system_prompt_is_skipped() {
let req = ChatStreamRequest {
message: "hi".into(),
system_prompt: Some(String::new()),
session_id: None,
};
let args = build_chat_args(&req);
let args = build_chat_args(&chat_request!("hi", Some(""), None));
assert!(!args.contains(&"--system-prompt".to_string()));
}
#[test]
fn build_chat_args_with_session_id() {
let req = ChatStreamRequest {
message: "continue".into(),
system_prompt: None,
session_id: Some("sess-abc".into()),
};
let args = build_chat_args(&req);
assert!(args.contains(&"--resume".to_string()));
assert!(args.contains(&"sess-abc".to_string()));
let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc")));
assert_args_contain!(args, ["--resume", "sess-abc"]);
}
// --- build_agent_args ---
@@ -1289,46 +1385,44 @@ mod tests {
#[test]
fn build_agent_args_basic() {
// build_agent_args calls build_mcp_config which needs mcp_server_dir
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "create note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"create note".to_string()));
assert!(args.contains(&"--mcp-config".to_string()));
assert!(args.contains(&"--strict-mcp-config".to_string()));
assert!(args.contains(&"--permission-mode".to_string()));
assert!(args.contains(&"acceptEdits".to_string()));
assert!(args.contains(&"--tools".to_string()));
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(!args.contains(&"bypassPermissions".to_string()));
assert!(!args.contains(&"Bash".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
if let Ok(args) = build_agent_args(&agent_request!(
"create note",
None,
AiAgentPermissionMode::Safe,
)) {
assert_args_contain!(args, ["-p", "create note", "--mcp-config"]);
assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]);
assert_args_contain!(args, ["acceptEdits", "--tools"]);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_args_contain!(args, ["--no-session-persistence"]);
assert_args_lack!(
args,
[
"--dangerously-skip-permissions",
"bypassPermissions",
"--append-system-prompt",
],
);
assert_no_arg_contains!(args, "Bash");
}
}
#[test]
fn build_agent_args_with_system_prompt() {
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "do it".into(),
system_prompt: Some("Act as expert.".into()),
vault_path: "/tmp/v".into(),
}) {
assert!(args.contains(&"--append-system-prompt".to_string()));
assert!(args.contains(&"Act as expert.".to_string()));
if let Ok(args) = build_agent_args(&agent_request!(
"do it",
Some("Act as expert."),
AiAgentPermissionMode::Safe,
)) {
assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]);
}
}
#[test]
fn build_agent_args_empty_system_prompt_is_skipped() {
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "x".into(),
system_prompt: Some(String::new()),
vault_path: "/tmp/v".into(),
}) {
if let Ok(args) =
build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe))
{
assert!(!args.contains(&"--append-system-prompt".to_string()));
}
}
@@ -1338,7 +1432,6 @@ mod tests {
#[test]
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
@@ -1346,32 +1439,19 @@ mod tests {
home.join(".npm-global/bin/claude"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
assert_binary_candidates_include(&home, &expected);
}
#[test]
fn claude_binary_candidates_include_windows_exe_installs() {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
assert_binary_candidates_include(&home, &expected);
}
#[test]
@@ -1427,6 +1507,7 @@ mod tests {
message: "test".into(),
system_prompt: Some("sys".into()),
vault_path: "/tmp/nonexistent".into(),
permission_mode: AiAgentPermissionMode::Safe,
};
let mut events = vec![];
let result = run_agent_stream(req, |e| events.push(e));

View File

@@ -1,22 +1,35 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
use std::io::BufRead;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
pub fn check_cli() -> AiAgentAvailability {
crate::opencode_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::opencode_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mut command = crate::opencode_config::build_command(&binary, &request)?;
let mut child = command
.spawn()
@@ -56,3 +69,88 @@ where
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("opencode");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_opencode_json_events() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
printf '%s\n' '{"type":"message","text":"Done"}'
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "open_1");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "open_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_opencode_nonzero_exit_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
printf '%s\n' 'provider login required' >&2
exit 3
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "open_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("provider configured")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::opencode_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
@@ -12,7 +13,7 @@ pub(crate) fn build_command(
.arg(build_prompt(request))
.env(
"OPENCODE_CONFIG_CONTENT",
build_config(&request.vault_path)?,
build_config(&request.vault_path, request.permission_mode)?,
)
.current_dir(&request.vault_path)
.stdin(Stdio::null())
@@ -40,7 +41,10 @@ fn build_prompt(request: &AgentStreamRequest) -> String {
}
}
fn build_config(vault_path: &str) -> Result<String, String> {
fn build_config(
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
@@ -49,7 +53,7 @@ fn build_config(vault_path: &str) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"permission": permission_config(),
"permission": permission_config(permission_mode),
"mcp": {
"tolaria": {
"type": "local",
@@ -62,7 +66,12 @@ fn build_config(vault_path: &str) -> Result<String, String> {
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
}
fn permission_config() -> serde_json::Value {
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
let bash_permission = match permission_mode {
AiAgentPermissionMode::Safe => "deny",
AiAgentPermissionMode::PowerUser => "allow",
};
serde_json::json!({
"read": "allow",
"edit": "allow",
@@ -70,7 +79,7 @@ fn permission_config() -> serde_json::Value {
"grep": "allow",
"list": "allow",
"external_directory": "deny",
"bash": "deny"
"bash": bash_permission
})
}
@@ -85,6 +94,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
@@ -118,7 +128,9 @@ mod tests {
#[test]
fn config_includes_permissions_and_tolaria_mcp_server() {
if let Ok(config) = build_config("/tmp/vault") {
if let Ok(config) =
build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
{
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["permission"]["edit"], "allow");
assert_eq!(json["permission"]["external_directory"], "deny");
@@ -136,6 +148,18 @@ mod tests {
}
}
#[test]
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
if let Ok(config) = build_config(
"/tmp/vault",
crate::ai_agents::AiAgentPermissionMode::PowerUser,
) {
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["permission"]["bash"], "allow");
assert_eq!(json["permission"]["external_directory"], "deny");
}
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {

View File

@@ -1,5 +1,65 @@
use super::*;
fn dispatch_events(events: impl IntoIterator<Item = serde_json::Value>) -> Vec<AiAgentStreamEvent> {
let mut mapped = Vec::new();
for event in events {
dispatch_event(&event, &mut |event| mapped.push(event));
}
mapped
}
struct ToolExpectation<'a> {
tool_id: &'a str,
tool_name: &'a str,
input: Option<&'a str>,
output: Option<&'a str>,
}
fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart {
tool_name: actual_name,
tool_id: actual_id,
input: actual_input,
} if actual_name == expected.tool_name
&& actual_id == expected.tool_id
&& actual_input.as_deref() == expected.input
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone {
tool_id: actual_id,
output: actual_output,
} if actual_id == expected.tool_id && actual_output.as_deref() == expected.output
));
}
#[test]
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
let mut events = Vec::new();
let read_error = parse_line(
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"broken pipe",
)),
&mut |event| events.push(event),
);
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
assert!(read_error.is_none());
assert!(blank.is_none());
assert!(invalid.is_none());
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
));
}
#[test]
fn dispatch_maps_session_reasoning_and_text() {
let mut events = Vec::new();
@@ -53,61 +113,63 @@ fn dispatch_maps_part_backed_reasoning_and_text() {
#[test]
fn dispatch_maps_tool_events() {
let mut events = Vec::new();
let tool_start = serde_json::json!({
"type": "tool_use",
"id": "tool_1",
"name": "read",
"input": { "path": "Note.md" }
});
let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" });
let direct = dispatch_events([
serde_json::json!({
"type": "tool_use",
"id": "tool_1",
"name": "read",
"input": { "path": "Note.md" }
}),
serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }),
]);
let part_backed = dispatch_events([
serde_json::json!({
"type": "tool_use",
"part": {
"id": "prt_tool_1",
"tool": "read",
"input": { "path": "Note.md" }
}
}),
serde_json::json!({
"type": "tool_result",
"part": {
"id": "prt_tool_1",
"output": "ok"
}
}),
]);
dispatch_event(&tool_start, &mut |event| events.push(event));
dispatch_event(&tool_done, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_1" && output.as_deref() == Some("ok")
));
assert_tool_pair(
&direct,
ToolExpectation {
tool_id: "tool_1",
tool_name: "read",
input: Some(r#"{"path":"Note.md"}"#),
output: Some("ok"),
},
);
assert_tool_pair(
&part_backed,
ToolExpectation {
tool_id: "prt_tool_1",
tool_name: "read",
input: Some(r#"{"path":"Note.md"}"#),
output: Some("ok"),
},
);
}
#[test]
fn dispatch_maps_part_backed_tool_events() {
fn dispatch_maps_error_events() {
let mut events = Vec::new();
let tool_start = serde_json::json!({
"type": "tool_use",
"part": {
"id": "prt_tool_1",
"tool": "read",
"input": { "path": "Note.md" }
}
});
let tool_done = serde_json::json!({
"type": "tool_result",
"part": {
"id": "prt_tool_1",
"output": "ok"
}
});
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
dispatch_event(&tool_start, &mut |event| events.push(event));
dispatch_event(&tool_done, &mut |event| events.push(event));
dispatch_event(&error, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
if tool_name == "read" && tool_id == "prt_tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "prt_tool_1" && output.as_deref() == Some("ok")
AiAgentStreamEvent::Error { message } if message == "provider failed"
));
}
@@ -121,3 +183,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
assert!(message.contains("OpenCode CLI is not authenticated"));
assert!(message.contains("opencode auth login"));
}
#[test]
fn format_error_uses_status_or_first_stderr_lines() {
let empty = format_error(String::new(), "exit status: 2".into());
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
assert_eq!(empty, "opencode exited with status exit status: 2");
assert_eq!(truncated, "line 1\nline 2\nline 3");
}

View File

@@ -1,22 +1,35 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
use std::io::BufRead;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
pub fn check_cli() -> AiAgentAvailability {
crate::pi_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::pi_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let agent_dir = tempfile::Builder::new()
.prefix("tolaria-pi-agent-")
.tempdir()
@@ -60,3 +73,88 @@ where
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("pi");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_pi_json_events() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}'
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "pi_1");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "pi_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_pi_nonzero_exit_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
printf '%s\n' 'api key login required' >&2
exit 4
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "pi_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::pi_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
@@ -7,7 +8,7 @@ pub(crate) fn build_command(
request: &AgentStreamRequest,
agent_dir: &Path,
) -> Result<std::process::Command, String> {
write_mcp_config(agent_dir, &request.vault_path)?;
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
command
@@ -46,15 +47,22 @@ fn build_prompt(request: &AgentStreamRequest) -> String {
}
}
fn write_mcp_config(agent_dir: &Path, vault_path: &str) -> Result<(), String> {
fn write_mcp_config(
agent_dir: &Path,
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<(), String> {
std::fs::create_dir_all(agent_dir)
.map_err(|error| format!("Failed to create Pi agent directory: {error}"))?;
let config = build_mcp_config(vault_path)?;
let config = build_mcp_config(vault_path, permission_mode)?;
std::fs::write(agent_dir.join("mcp.json"), config)
.map_err(|error| format!("Failed to write Pi MCP config: {error}"))
}
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
fn build_mcp_config(
vault_path: &str,
_permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
@@ -93,6 +101,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
@@ -129,7 +138,9 @@ mod tests {
#[test]
fn mcp_config_includes_tolaria_server_for_active_vault() {
if let Ok(config) = build_mcp_config("/tmp/vault") {
if let Ok(config) =
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
{
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["settings"]["toolPrefix"], "none");
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
@@ -147,6 +158,19 @@ mod tests {
}
}
#[test]
fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() {
let safe =
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe).unwrap();
let power = build_mcp_config(
"/tmp/vault",
crate::ai_agents::AiAgentPermissionMode::PowerUser,
)
.unwrap();
assert_eq!(safe, power);
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {

View File

@@ -1,5 +1,28 @@
use super::*;
#[test]
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
let mut events = Vec::new();
let read_error = parse_line(
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"broken pipe",
)),
&mut |event| events.push(event),
);
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
assert!(read_error.is_none());
assert!(blank.is_none());
assert!(invalid.is_none());
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
));
}
#[test]
fn dispatch_maps_session_thinking_and_text() {
let mut events = Vec::new();
@@ -62,6 +85,19 @@ fn dispatch_maps_tool_events() {
));
}
#[test]
fn dispatch_maps_error_events() {
let mut events = Vec::new();
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
dispatch_event(&error, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message == "provider failed"
));
}
#[test]
fn format_error_explains_missing_auth_or_provider_setup() {
let message = format_error(
@@ -72,3 +108,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
assert!(message.contains("Pi CLI is not authenticated"));
assert!(message.contains("pi /login"));
}
#[test]
fn format_error_uses_status_or_first_stderr_lines() {
let empty = format_error(String::new(), "exit status: 2".into());
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
assert_eq!(empty, "pi exited with status exit status: 2");
assert_eq!(truncated, "line 1\nline 2\nline 3");
}

View File

@@ -18,6 +18,7 @@ export interface AiAction {
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
localMarker?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -27,6 +28,18 @@ export interface AiMessageProps {
onNavigateWikilink?: (target: string) => void
}
function LocalMarker({ text }: { text: string }) {
return (
<div
className="mx-auto text-center text-muted-foreground"
style={{ fontSize: 11, margin: '8px 0 16px', maxWidth: '85%' }}
data-testid="ai-local-marker"
>
{text}
</div>
)
}
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
@@ -176,7 +189,15 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
export function AiMessage(props: AiMessageProps) {
if (props.localMarker) {
return <LocalMarker text={props.localMarker} />
}
return <ConversationMessage {...props} />
}
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())

View File

@@ -4,20 +4,27 @@ import { AiPanel } from './AiPanel'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import type { VaultEntry } from '../types'
import { queueAiPrompt } from '../utils/aiPromptBridge'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['status'] = 'idle'
const mockSendMessage = vi.fn()
const mockClearConversation = vi.fn()
const mockAddLocalMarker = vi.fn()
const mockUseCliAiAgent = vi.fn()
vi.mock('../hooks/useCliAiAgent', () => ({
useCliAiAgent: () => ({
messages: mockMessages,
status: mockStatus,
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
}),
useCliAiAgent: (...args: unknown[]) => {
mockUseCliAiAgent(...args)
return {
messages: mockMessages,
status: mockStatus,
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
addLocalMarker: mockAddLocalMarker,
}
},
}))
vi.mock('../utils/ai-chat', () => ({
@@ -45,7 +52,18 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
...overrides,
})
@@ -55,12 +73,78 @@ describe('AiPanel', () => {
mockStatus = 'idle'
mockSendMessage.mockReset()
mockClearConversation.mockReset()
mockAddLocalMarker.mockReset()
mockUseCliAiAgent.mockReset()
resetVaultConfigStore()
bindVaultConfigStore({
zoom: null,
view_mode: null,
editor_mode: null,
note_layout: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
allNotes: null,
ai_agent_permission_mode: 'safe',
}, vi.fn())
})
it('renders panel with the default CLI agent header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('Claude Code')).toBeTruthy()
expect(screen.getByText('Claude Code · Safe')).toBeTruthy()
})
it('passes the vault permission mode to the AI agent session', () => {
bindVaultConfigStore({
...getVaultConfig(),
ai_agent_permission_mode: 'power_user',
}, vi.fn())
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Claude Code · Power User')).toBeTruthy()
expect(mockUseCliAiAgent).toHaveBeenCalledWith(
'/tmp/vault',
undefined,
expect.any(Object),
expect.objectContaining({ permissionMode: 'power_user' }),
)
})
it('persists permission mode changes and records a local transcript marker', () => {
const save = vi.fn()
bindVaultConfigStore({
...getVaultConfig(),
ai_agent_permission_mode: 'safe',
}, save)
mockMessages = [{
userMessage: 'Existing question',
actions: [],
response: 'Existing answer.',
id: 'msg-existing',
}]
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Power User' }))
expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user')
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
ai_agent_permission_mode: 'power_user',
}))
expect(mockAddLocalMarker).toHaveBeenCalledWith(
'AI permission mode changed to Power User. It will apply to the next message.',
)
})
it('disables permission mode changes while the AI agent is running', () => {
mockStatus = 'thinking'
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByRole('button', { name: 'Vault Safe' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Power User' })).toBeDisabled()
})
it('renders data-testid ai-panel', () => {

View File

@@ -82,8 +82,10 @@ export function AiPanelView({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
} = controller
@@ -115,6 +117,9 @@ export function AiPanelView({
<AiPanelHeader
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
permissionMode={permissionMode}
permissionModeDisabled={isActive}
onPermissionModeChange={handlePermissionModeChange}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}

View File

@@ -5,6 +5,10 @@ import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import {
AI_AGENT_PERMISSION_MODE_LABELS,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
@@ -13,6 +17,9 @@ import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
permissionMode: AiAgentPermissionMode
permissionModeDisabled: boolean
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
@@ -124,59 +131,108 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReadiness,
permissionMode,
permissionModeDisabled,
onPermissionModeChange,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
const modeLabel = AI_AGENT_PERMISSION_MODE_LABELS[permissionMode].short
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '0 12px', gap: 8 }}
className="flex shrink-0 flex-col border-b border-border"
style={{ padding: '8px 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
{onCopyMcpConfig ? (
<div className="flex items-center" style={{ gap: 8 }}>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ` · ${modeLabel}`}`}
</span>
</div>
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Copy size={15} />
<Plus size={16} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</Button>
</div>
<AiPermissionModeToggle
value={permissionMode}
disabled={permissionModeDisabled}
onChange={onPermissionModeChange}
/>
</div>
)
}
function AiPermissionModeToggle({
value,
disabled,
onChange,
}: {
value: AiAgentPermissionMode
disabled: boolean
onChange: (mode: AiAgentPermissionMode) => void
}) {
return (
<div
className="grid rounded-md bg-muted"
style={{ gridTemplateColumns: '1fr 1fr', gap: 2, padding: 2 }}
role="group"
aria-label="AI agent permission mode"
data-testid="ai-permission-mode-toggle"
>
{(['safe', 'power_user'] as const).map((mode) => {
const selected = value === mode
return (
<Button
key={mode}
type="button"
size="xs"
variant={selected ? 'secondary' : 'ghost'}
disabled={disabled}
aria-pressed={selected}
onClick={() => onChange(mode)}
>
{AI_AGENT_PERMISSION_MODE_LABELS[mode].control}
</Button>
)
})}
</div>
)
}
@@ -275,16 +331,20 @@ export function AiPanelComposer({
inputRef={inputRef}
/>
</div>
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={sendButtonStyle}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
aria-label="Send message"
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</Button>
</div>
</div>
)

View File

@@ -1,7 +1,17 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
import {
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent'
import type { VaultEntry } from '../types'
import {
getVaultConfig,
subscribeVaultConfig,
updateVaultConfigField,
} from '../utils/vaultConfigStore'
import {
type NoteListItem,
type NoteReference,
@@ -32,8 +42,10 @@ export interface AiPanelController {
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
hasContext: boolean
isActive: boolean
permissionMode: AiAgentPermissionMode
handleSend: (text: string, references: NoteReference[]) => void
handleNavigateWikilink: (target: string) => void
handlePermissionModeChange: (mode: AiAgentPermissionMode) => void
handleNewChat: () => void
}
@@ -44,6 +56,26 @@ function resolveAgentReady(
return (readiness ?? (ready ? 'ready' : 'missing')) === 'ready'
}
function useVaultAiAgentPermissionMode(): AiAgentPermissionMode {
const vaultConfig = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
return normalizeAiAgentPermissionMode(vaultConfig.ai_agent_permission_mode)
}
function useAgentFileCallbacks({
onFileCreated,
onFileModified,
onVaultChanged,
}: Pick<
UseAiPanelControllerArgs,
'onFileCreated' | 'onFileModified' | 'onVaultChanged'
>): AgentFileCallbacks {
return useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
}
export function useAiPanelController({
vaultPath,
defaultAiAgent,
@@ -71,15 +103,13 @@ export function useAiPanelController({
noteListFilter,
})
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
const permissionMode = useVaultAiAgentPermissionMode()
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
agent: defaultAiAgent,
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
permissionMode,
})
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
@@ -94,6 +124,14 @@ export function useAiPanelController({
onOpenNote?.(target)
}, [onOpenNote])
const handlePermissionModeChange = useCallback((mode: AiAgentPermissionMode) => {
const nextMode = normalizeAiAgentPermissionMode(mode)
if (isActive || nextMode === permissionMode) return
updateVaultConfigField('ai_agent_permission_mode', nextMode)
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode))
}, [agent, isActive, permissionMode])
const handleNewChat = useCallback(() => {
agent.clearConversation()
setInput('')
@@ -106,8 +144,10 @@ export function useAiPanelController({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
}
}

View File

@@ -14,11 +14,15 @@ vi.mock('../utils/ai-agent', () => ({
const mockStreamAiAgent = vi.mocked(streamAiAgent)
const VAULT = '/Users/luca/Laputa'
function renderAgent(contextPrompt: string | undefined = undefined) {
function renderAgent(
contextPrompt: string | undefined = undefined,
permissionMode: 'safe' | 'power_user' = 'safe',
) {
return renderHook(
({ context }) => useCliAiAgent(VAULT, context, undefined, {
agent: 'codex',
agentReady: true,
permissionMode,
}),
{ initialProps: { context: contextPrompt } },
)
@@ -49,6 +53,37 @@ describe('useCliAiAgent', () => {
}))
})
it('forwards the current permission mode to the stream request', async () => {
const { result } = renderAgent(undefined, 'power_user')
await act(async () => {
await result.current.sendMessage('Use the local tools')
})
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
permissionMode: 'power_user',
}))
})
it('adds local transcript markers without sending them as chat history', async () => {
const { result } = renderAgent()
act(() => {
result.current.addLocalMarker('AI permission mode changed to Power User. It will apply to the next message.')
})
await act(async () => {
await result.current.sendMessage('Continue')
})
expect(result.current.messages[0]).toEqual(expect.objectContaining({
localMarker: 'AI permission mode changed to Power User. It will apply to the next message.',
}))
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
message: 'Continue',
}))
})
it('embeds completed conversation history and clears it for a fresh chat', async () => {
let responseNumber = 0
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
@@ -80,6 +115,7 @@ describe('useCliAiAgent', () => {
const { result } = renderHook(() => useCliAiAgent(VAULT, undefined, undefined, {
agent: 'codex',
agentReady: false,
permissionMode: 'safe',
}))
await act(async () => {

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { AiAgentId } from '../lib/aiAgents'
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
import type { NoteReference } from '../utils/ai-context'
import {
type AgentStatus,
@@ -7,6 +8,7 @@ import {
} from '../lib/aiAgentConversation'
import type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
import {
addAgentLocalMarker,
clearAgentConversation,
sendAgentMessage,
type AiAgentSessionRuntime,
@@ -20,6 +22,7 @@ export type { AiAgentMessage } from '../lib/aiAgentConversation'
interface UseCliAiAgentOptions {
agent: AiAgentId
agentReady: boolean
permissionMode: AiAgentPermissionMode
}
interface UseCliAiAgentRuntime extends AiAgentSessionRuntime {
@@ -66,6 +69,7 @@ export function useCliAiAgent(
options: UseCliAiAgentOptions,
) {
const { agent, agentReady } = options
const { permissionMode } = options
const runtime = useCliAiAgentRuntime(fileCallbacks)
const { messages, status } = runtime
@@ -76,6 +80,7 @@ export function useCliAiAgent(
agent,
ready: agentReady,
vaultPath,
permissionMode,
systemPromptOverride: contextPrompt,
},
prompt: { text, references },
@@ -86,5 +91,9 @@ export function useCliAiAgent(
clearAgentConversation(runtime)
}
return { messages, status, sendMessage, clearConversation }
function addLocalMarker(text: string): void {
addAgentLocalMarker(runtime, text)
}
return { messages, status, sendMessage, clearConversation, addLocalMarker }
}

View File

@@ -11,6 +11,7 @@ import {
subscribeVaultConfig,
} from '../utils/vaultConfigStore'
import { migrateLocalStorageToVaultConfig } from '../utils/configMigration'
import { DEFAULT_AI_AGENT_PERMISSION_MODE } from '../lib/aiAgentPermissionMode'
const STORAGE_PREFIX = 'laputa:vault-config:'
@@ -20,9 +21,10 @@ function storageKey(vaultPath: string): string {
function loadFromStorage(vaultPath: string): VaultConfig {
const DEFAULT: VaultConfig = {
zoom: null, view_mode: null, editor_mode: null,
zoom: null, view_mode: null, editor_mode: null, note_layout: null,
ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE,
tag_colors: null, status_colors: null, property_display_modes: null,
inbox: null,
inbox: null, allNotes: null,
}
try {
const raw = localStorage.getItem(storageKey(vaultPath))

View File

@@ -11,10 +11,12 @@ import {
import type { NoteReference } from '../utils/ai-context'
import type { AiAgentId } from './aiAgents'
import { getAiAgentDefinition } from './aiAgents'
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
export interface AiAgentMessage {
userMessage: string
references?: NoteReference[]
localMarker?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -29,6 +31,7 @@ export interface AgentExecutionContext {
agent: AiAgentId
ready: boolean
vaultPath: string
permissionMode: AiAgentPermissionMode
systemPromptOverride?: string
}
@@ -38,7 +41,7 @@ export interface PendingUserPrompt {
}
function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
return messages.flatMap((message) => {
return messages.filter((message) => !message.localMarker).flatMap((message) => {
const history: ChatMessage[] = [{ role: 'user', content: message.userMessage, id: message.id ?? '' }]
if (message.response) {
history.push({ role: 'assistant', content: message.response, id: `${message.id}-resp` })
@@ -47,6 +50,21 @@ function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
})
}
export function appendLocalMarker(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
text: string,
): void {
setMessages((current) => [
...current,
{
userMessage: '',
localMarker: text,
actions: [],
id: nextMessageId(),
},
])
}
export function createMissingAgentResponse(agent: AiAgentId): string {
const definition = getAiAgentDefinition(agent)
return `${definition.label} is not available on this machine. Install it or switch the default AI agent in Settings.`

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
AI_AGENT_PERMISSION_MODE_LABELS,
DEFAULT_AI_AGENT_PERMISSION_MODE,
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
} from './aiAgentPermissionMode'
describe('aiAgentPermissionMode', () => {
it('defaults missing, null, and unknown values to vault safe mode', () => {
expect(DEFAULT_AI_AGENT_PERMISSION_MODE).toBe('safe')
expect(normalizeAiAgentPermissionMode(undefined)).toBe('safe')
expect(normalizeAiAgentPermissionMode(null)).toBe('safe')
expect(normalizeAiAgentPermissionMode('danger')).toBe('safe')
})
it('preserves known permission modes and exposes compact labels', () => {
expect(normalizeAiAgentPermissionMode('safe')).toBe('safe')
expect(normalizeAiAgentPermissionMode('power_user')).toBe('power_user')
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.short).toBe('Safe')
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.control).toBe('Vault Safe')
expect(AI_AGENT_PERMISSION_MODE_LABELS.power_user.short).toBe('Power User')
})
it('formats a local transcript marker for mode changes', () => {
expect(aiAgentPermissionModeMarker('power_user')).toBe(
'AI permission mode changed to Power User. It will apply to the next message.',
)
})
})

View File

@@ -0,0 +1,26 @@
export type AiAgentPermissionMode = 'safe' | 'power_user'
export const DEFAULT_AI_AGENT_PERMISSION_MODE: AiAgentPermissionMode = 'safe'
export const AI_AGENT_PERMISSION_MODE_LABELS: Record<
AiAgentPermissionMode,
{ short: string; control: string }
> = {
safe: {
short: 'Safe',
control: 'Vault Safe',
},
power_user: {
short: 'Power User',
control: 'Power User',
},
}
export function normalizeAiAgentPermissionMode(value: unknown): AiAgentPermissionMode {
return value === 'power_user' ? 'power_user' : DEFAULT_AI_AGENT_PERMISSION_MODE
}
export function aiAgentPermissionModeMarker(mode: AiAgentPermissionMode): string {
const label = AI_AGENT_PERMISSION_MODE_LABELS[mode].short
return `AI permission mode changed to ${label}. It will apply to the next message.`
}

View File

@@ -1,6 +1,7 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import {
appendLocalResponse,
appendLocalMarker,
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
@@ -80,6 +81,7 @@ export async function sendAgentMessage({
message: formattedMessage,
systemPrompt,
vaultPath: context.vaultPath,
permissionMode: context.permissionMode,
callbacks: createStreamCallbacks({
agent: context.agent,
messageId,
@@ -94,6 +96,13 @@ export async function sendAgentMessage({
})
}
export function addAgentLocalMarker(
runtime: Pick<AiAgentSessionRuntime, 'setMessages'>,
text: string,
): void {
appendLocalMarker(runtime.setMessages, text)
}
export function clearAgentConversation(runtime: Pick<AiAgentSessionRuntime, 'abortRef' | 'responseAccRef' | 'toolInputMapRef' | 'setMessages' | 'setStatus'>): void {
runtime.abortRef.current.aborted = true
runtime.responseAccRef.current = ''

View File

@@ -1,4 +1,5 @@
import type { AiAgentId } from './lib/aiAgents'
import type { AiAgentPermissionMode } from './lib/aiAgentPermissionMode'
import type { ThemeMode } from './lib/themeMode'
import type { AppLocale } from './lib/i18n'
@@ -161,6 +162,7 @@ export interface VaultConfig {
view_mode: string | null
editor_mode: string | null
note_layout?: NoteLayout | null
ai_agent_permission_mode?: AiAgentPermissionMode | null
tag_colors: Record<string, string> | null
status_colors: Record<string, string> | null
property_display_modes: Record<string, string> | null

View File

@@ -104,6 +104,7 @@ describe('streamAiAgent', () => {
message: 'Explain this',
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
permissionMode: 'power_user',
callbacks,
})
@@ -116,6 +117,7 @@ describe('streamAiAgent', () => {
message: 'Explain this',
system_prompt: 'SYSTEM',
vault_path: '/vault',
permission_mode: 'power_user',
},
})
expect(callbacks.onThinking).toHaveBeenCalledWith('thinking...')

View File

@@ -1,5 +1,9 @@
import { isTauri } from '../mock-tauri'
import { getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import {
normalizeAiAgentPermissionMode,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
type AiAgentStreamEvent =
| { kind: 'Init'; session_id: string }
@@ -24,6 +28,7 @@ export interface StreamAiAgentRequest {
message: string
systemPrompt?: string
vaultPath: string
permissionMode?: AiAgentPermissionMode
callbacks: AgentStreamCallbacks
}
@@ -70,6 +75,7 @@ export async function streamAiAgent(
message,
systemPrompt,
vaultPath,
permissionMode,
callbacks,
} = request
@@ -107,6 +113,7 @@ export async function streamAiAgent(
message,
system_prompt: systemPrompt || null,
vault_path: vaultPath,
permission_mode: normalizeAiAgentPermissionMode(permissionMode),
},
})
closeStream()

View File

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultConfig } from '../types'
import {
bindVaultConfigStore,
getVaultConfig,
resetVaultConfigStore,
updateVaultConfigField,
} from './vaultConfigStore'
function vaultConfig(overrides: Partial<VaultConfig> = {}): VaultConfig {
return {
zoom: null,
view_mode: null,
editor_mode: null,
note_layout: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
allNotes: null,
...overrides,
}
}
describe('vaultConfigStore', () => {
beforeEach(() => {
resetVaultConfigStore()
})
it('normalizes missing, null, and unknown AI agent permission modes to safe', () => {
bindVaultConfigStore(vaultConfig(), vi.fn())
expect(getVaultConfig().ai_agent_permission_mode).toBe('safe')
bindVaultConfigStore(vaultConfig({ ai_agent_permission_mode: null }), vi.fn())
expect(getVaultConfig().ai_agent_permission_mode).toBe('safe')
bindVaultConfigStore({
...vaultConfig(),
ai_agent_permission_mode: 'danger' as VaultConfig['ai_agent_permission_mode'],
}, vi.fn())
expect(getVaultConfig().ai_agent_permission_mode).toBe('safe')
})
it('persists normalized AI agent permission mode updates', () => {
const save = vi.fn()
bindVaultConfigStore(vaultConfig(), save)
updateVaultConfigField('ai_agent_permission_mode', 'power_user')
expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user')
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
ai_agent_permission_mode: 'power_user',
}))
updateVaultConfigField('ai_agent_permission_mode', null)
expect(getVaultConfig().ai_agent_permission_mode).toBe('safe')
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
ai_agent_permission_mode: 'safe',
}))
})
})

View File

@@ -1,12 +1,17 @@
import type { VaultConfig } from '../types'
import {
DEFAULT_AI_AGENT_PERMISSION_MODE,
normalizeAiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
type SaveFn = (config: VaultConfig) => void
type Listener = () => void
const DEFAULT_CONFIG: VaultConfig = {
zoom: null, view_mode: null, editor_mode: null, note_layout: null,
ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE,
tag_colors: null, status_colors: null, property_display_modes: null,
inbox: null,
inbox: null, allNotes: null,
}
let config: VaultConfig = DEFAULT_CONFIG
@@ -18,7 +23,7 @@ export function getVaultConfig(): VaultConfig {
}
export function bindVaultConfigStore(initial: VaultConfig, save: SaveFn): void {
config = initial
config = normalizeVaultConfig(initial)
saveFn = save
notify()
}
@@ -29,7 +34,7 @@ export function resetVaultConfigStore(): void {
}
export function updateVaultConfigField<K extends keyof VaultConfig>(key: K, value: VaultConfig[K]): void {
config = { ...config, [key]: value }
config = normalizeVaultConfig({ ...config, [key]: value })
saveFn?.(config)
notify()
}
@@ -42,3 +47,11 @@ export function subscribeVaultConfig(listener: Listener): () => void {
function notify(): void {
for (const fn of listeners) fn()
}
function normalizeVaultConfig(next: VaultConfig): VaultConfig {
return {
...DEFAULT_CONFIG,
...next,
ai_agent_permission_mode: normalizeAiAgentPermissionMode(next.ai_agent_permission_mode),
}
}

View File

@@ -14346,6 +14346,115 @@
]
}
]
},
{
"type": "frame",
"id": "ai_agent_permission_mode_header",
"x": 560,
"y": 13980,
"name": "AI agent permission mode - panel header",
"theme": {
"Mode": "Light"
},
"width": 360,
"height": 96,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
},
"layout": "vertical",
"padding": 12,
"gap": 10,
"children": [
{
"type": "frame",
"id": "ai_agent_permission_mode_title_row",
"width": "fill_container",
"height": 28,
"alignItems": "center",
"gap": 8,
"children": [
{
"type": "text",
"id": "ai_agent_permission_mode_icon",
"fill": "$--muted-foreground",
"content": "robot",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "frame",
"id": "ai_agent_permission_mode_title_stack",
"width": "fill_container",
"height": 28,
"layout": "vertical",
"gap": 2,
"children": [
{
"type": "text",
"id": "ai_agent_permission_mode_title",
"fill": "$--muted-foreground",
"content": "AI Agent",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "text",
"id": "ai_agent_permission_mode_agent",
"fill": "$--muted-foreground",
"content": "Claude Code · Safe",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "400",
"letterSpacing": 0
}
]
}
]
},
{
"type": "frame",
"id": "ai_agent_permission_mode_toggle",
"width": "fill_container",
"height": 28,
"fill": "$--muted",
"cornerRadius": 6,
"padding": 2,
"gap": 2,
"children": [
{
"type": "text",
"id": "ai_agent_permission_mode_safe",
"width": "fill_container",
"height": 24,
"fill": "$--foreground",
"content": "Vault Safe",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "text",
"id": "ai_agent_permission_mode_power",
"width": "fill_container",
"height": 24,
"fill": "$--muted-foreground",
"content": "Power User",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500",
"letterSpacing": 0
}
]
}
]
}
],
"themes": {