From 3c8edd8346e45a0c3c2d6b938dc2f04f60a6bfa9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 6 Jun 2026 10:42:33 +0200 Subject: [PATCH] fix: let OpenAI API create notes --- docs/ABSTRACTIONS.md | 2 +- docs/ARCHITECTURE.md | 2 +- src-tauri/src/ai_model_tools.rs | 623 +++++++++++++++++++ src-tauri/src/ai_models.rs | 88 ++- src-tauri/src/claude_cli.rs | 54 +- src-tauri/src/cli_agent_runtime/shell_env.rs | 77 ++- src-tauri/src/lib.rs | 1 + src/lib/aiAgentSession.test.ts | 84 +++ src/lib/aiAgentSession.ts | 2 + src/utils/streamAiModel.test.ts | 25 + src/utils/streamAiModel.ts | 101 ++- 11 files changed, 966 insertions(+), 93 deletions(-) create mode 100644 src-tauri/src/ai_model_tools.rs diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index e10f78a5..2eac4ae0 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -910,7 +910,7 @@ interface Settings { } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. +Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. Direct OpenAI-compatible model streams receive the active vault root and may execute Tolaria's native create-only `create_note` tool, but they do not receive shell access or general file-write tools. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 32c5d10a..b8765ead 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -343,7 +343,7 @@ Large active notes are compacted into a head/tail body snapshot before they ente ### Direct Model Targets -Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints. +Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints. Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication. diff --git a/src-tauri/src/ai_model_tools.rs b/src-tauri/src/ai_model_tools.rs new file mode 100644 index 00000000..97192fa8 --- /dev/null +++ b/src-tauri/src/ai_model_tools.rs @@ -0,0 +1,623 @@ +use crate::ai_agents::AiAgentStreamEvent; +use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest}; +use std::path::{Path, PathBuf}; + +const CREATE_NOTE_TOOL_NAME: &str = "create_note"; +const CREATE_NOTE_TOOL_JSON: &str = r#"{ + "type": "function", + "function": { + "name": "create_note", + "description": "Create a new markdown note inside the active Tolaria vault without overwriting existing files.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path inside the vault, or an absolute path inside the active vault. Must end in .md." + }, + "content": { + "type": "string", + "description": "Full markdown note content, including YAML frontmatter and H1 when needed." + }, + "title": { + "type": "string", + "description": "Optional title used only when content is omitted." + }, + "type": { + "type": "string", + "description": "Optional note type used only when content is omitted." + }, + "is_a": { + "type": "string", + "description": "Legacy alias for type, used only when content is omitted." + }, + "vaultPath": { + "type": "string", + "description": "Optional target vault root when multiple vaults are active." + } + }, + "required": ["path"], + "additionalProperties": false + } + } +}"#; + +struct OpenAiToolCall { + id: String, + name: String, + arguments: serde_json::Value, + raw_arguments: String, +} + +struct CreatedNoteToolResult { + summary: String, + output: String, +} + +pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value { + let mut payload = serde_json::json!({ + "model": request.model_id, + "messages": openai_chat_messages(request), + "stream": false + }); + if should_offer_openai_tools(request) { + payload["tools"] = serde_json::Value::Array(vec![openai_create_note_tool()]); + payload["tool_choice"] = serde_json::Value::String("auto".into()); + } + payload +} + +pub(crate) fn execute_openai_tool_calls( + request: &AiModelStreamRequest, + json: &serde_json::Value, + emit: F, +) -> Result, String> +where + F: FnMut(AiAgentStreamEvent), +{ + let tool_calls = openai_tool_calls(json)?; + if tool_calls.is_empty() { + return Ok(None); + } + run_openai_tool_calls(request, &tool_calls, emit).map(Some) +} + +fn openai_chat_messages(request: &AiModelStreamRequest) -> Vec { + let mut messages = Vec::new(); + if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) { + messages.push(serde_json::json!({ "role": "system", "content": system_prompt })); + } + messages.push(serde_json::json!({ "role": "user", "content": request.message })); + messages +} + +fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool { + let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some(); + has_active_vault + && (request.provider.kind == AiModelProviderKind::OpenAi + || selected_model_supports_tools(request)) +} + +fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool { + request + .provider + .models + .iter() + .find(|model| model.id == request.model_id) + .is_some_and(|model| model.capabilities.tools) +} + +fn openai_create_note_tool() -> serde_json::Value { + serde_json::from_str(CREATE_NOTE_TOOL_JSON).expect("create_note tool schema must be valid JSON") +} + +fn run_openai_tool_calls( + request: &AiModelStreamRequest, + tool_calls: &[OpenAiToolCall], + mut emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let mut summaries = Vec::new(); + for tool_call in tool_calls { + summaries.push(execute_openai_tool_call(request, tool_call, &mut emit)?); + } + Ok(summaries.join("\n")) +} + +fn execute_openai_tool_call( + request: &AiModelStreamRequest, + tool_call: &OpenAiToolCall, + emit: &mut F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + if tool_call.name != CREATE_NOTE_TOOL_NAME { + return Err(format!( + "AI provider requested unsupported tool: {}", + tool_call.name + )); + } + + emit(AiAgentStreamEvent::ToolStart { + tool_name: CREATE_NOTE_TOOL_NAME.into(), + tool_id: tool_call.id.clone(), + input: Some(tool_call.raw_arguments.clone()), + }); + + match create_note_from_tool_args(request, &tool_call.arguments) { + Ok(result) => { + emit(AiAgentStreamEvent::ToolDone { + tool_id: tool_call.id.clone(), + output: Some(result.output), + }); + Ok(result.summary) + } + Err(error) => { + emit(AiAgentStreamEvent::ToolDone { + tool_id: tool_call.id.clone(), + output: Some(format!("Error: {error}")), + }); + Err(error) + } + } +} + +fn openai_tool_calls(json: &serde_json::Value) -> Result, String> { + let Some(calls) = json["choices"][0]["message"]["tool_calls"].as_array() else { + return Ok(Vec::new()); + }; + + calls + .iter() + .enumerate() + .map(|(index, call)| openai_tool_call(index, call)) + .collect() +} + +fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result { + let function = &call["function"]; + let name = function["name"] + .as_str() + .ok_or_else(|| "AI provider tool call did not include a function name.".to_string())? + .to_string(); + let (arguments, raw_arguments) = parse_tool_arguments(&function["arguments"])?; + let id = call["id"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| format!("tool_call_{index}")); + Ok(OpenAiToolCall { + id, + name, + arguments, + raw_arguments, + }) +} + +fn parse_tool_arguments(value: &serde_json::Value) -> Result<(serde_json::Value, String), String> { + if let Some(raw) = value.as_str() { + let parsed = serde_json::from_str(raw) + .map_err(|error| format!("Failed to parse AI tool arguments: {error}"))?; + return Ok((parsed, raw.to_string())); + } + if value.is_object() { + let raw = serde_json::to_string(value) + .map_err(|error| format!("Failed to serialize AI tool arguments: {error}"))?; + return Ok((value.clone(), raw)); + } + Ok((serde_json::json!({}), "{}".into())) +} + +fn create_note_from_tool_args( + request: &AiModelStreamRequest, + args: &serde_json::Value, +) -> Result { + let note_path = required_tool_string(args, "path")?; + let content = create_note_tool_content(args, note_path); + let vault_path = tool_vault_path(request, args)?; + crate::commands::create_note_content( + PathBuf::from(note_path), + content, + Some(PathBuf::from(vault_path)), + )?; + let output = serde_json::json!({ + "path": note_path, + "vaultPath": vault_path, + }) + .to_string(); + Ok(CreatedNoteToolResult { + summary: format!("Created note: {note_path}"), + output, + }) +} + +fn tool_vault_path<'a>( + request: &'a AiModelStreamRequest, + args: &'a serde_json::Value, +) -> Result<&'a str, String> { + if let Some(vault_path) = string_arg(args, "vaultPath") { + return active_tool_vault_path(request, vault_path); + } + request + .vault_path + .as_deref() + .and_then(non_empty_str) + .ok_or_else(|| "No active vault is available for create_note.".to_string()) +} + +fn active_tool_vault_path<'a>( + request: &'a AiModelStreamRequest, + vault_path: &'a str, +) -> Result<&'a str, String> { + if active_vault_paths(request).any(|active| active == vault_path) { + Ok(vault_path) + } else { + Err(format!("Vault is not active in Tolaria: {vault_path}")) + } +} + +fn active_vault_paths(request: &AiModelStreamRequest) -> impl Iterator { + request + .vault_path + .as_deref() + .into_iter() + .chain(request.vault_paths.iter().map(String::as_str)) + .filter_map(non_empty_str) +} + +fn create_note_tool_content(args: &serde_json::Value, note_path: &str) -> String { + if let Some(content) = content_arg(args, "content") { + return content.to_string(); + } + let title = string_arg(args, "title") + .map(str::to_string) + .unwrap_or_else(|| fallback_note_title(note_path)); + let note_type = string_arg(args, "type") + .or_else(|| string_arg(args, "is_a")) + .unwrap_or("Note"); + let note_type_yaml = serde_json::to_string(note_type).unwrap_or_else(|_| "\"Note\"".into()); + format!("---\ntype: {note_type_yaml}\n---\n\n# {title}\n") +} + +fn fallback_note_title(note_path: &str) -> String { + let normalized = note_path.replace('\\', "/"); + Path::new(&normalized) + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|title| !title.trim().is_empty()) + .unwrap_or("Untitled") + .to_string() +} + +fn required_tool_string<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, String> { + string_arg(args, key).ok_or_else(|| format!("create_note requires {key}.")) +} + +fn string_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args[key].as_str().and_then(non_empty_str) +} + +fn content_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args[key] + .as_str() + .filter(|content| !content.trim().is_empty()) +} + +fn non_empty_option(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn non_empty_str(value: &str) -> Option<&str> { + non_empty_option(Some(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_models::{ + AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider, + }; + use serde_json::json; + use std::fs; + + const CREATED_NOTE_PATH: &str = "nota-longa-teste-gerada-2.md"; + const CREATED_NOTE_CONTENT: &str = "---\ntype: Note\n---\n\n# Nota longa de teste - gerada 2\n"; + + fn request(vault_path: String) -> AiModelStreamRequest { + request_with_provider(provider(), Some(vault_path), Vec::new()) + } + + fn request_with_provider( + provider: AiModelProvider, + vault_path: Option, + vault_paths: Vec, + ) -> AiModelStreamRequest { + AiModelStreamRequest { + provider, + model_id: "gpt-5-nano".into(), + message: "Create the note".into(), + system_prompt: Some("Use create_note for new notes.".into()), + vault_path, + vault_paths, + api_key_override: None, + event_name: None, + } + } + + fn provider() -> AiModelProvider { + AiModelProvider { + id: "openai".into(), + name: "OpenAI".into(), + kind: AiModelProviderKind::OpenAi, + base_url: Some("https://api.openai.com/v1".into()), + api_key_storage: Some(AiModelApiKeyStorage::LocalFile), + api_key_env_var: None, + headers: None, + models: vec![AiModelDefinition { + id: "gpt-5-nano".into(), + display_name: None, + context_window: None, + max_output_tokens: None, + capabilities: AiModelCapabilities { + streaming: false, + tools: false, + vision: false, + json_mode: false, + reasoning: false, + }, + }], + } + } + + fn create_note_response() -> serde_json::Value { + tool_call_response(json!({ + "id": "call_create", + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "path": CREATED_NOTE_PATH, + "content": CREATED_NOTE_CONTENT + })).unwrap() + } + })) + } + + fn tool_call_response(tool_call: serde_json::Value) -> serde_json::Value { + json!({ + "choices": [{ + "message": { + "tool_calls": [tool_call] + } + }] + }) + } + + fn create_note_response_with_args(arguments: serde_json::Value) -> serde_json::Value { + tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": arguments, + } + })) + } + + fn create_note_error(arguments: serde_json::Value) -> String { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + + execute_openai_tool_calls(&request, &create_note_response_with_args(arguments), |_| {}) + .unwrap_err() + } + + fn assert_note_created(vault_path: &Path) { + let actual = fs::read_to_string(vault_path.join(CREATED_NOTE_PATH)).unwrap(); + assert_eq!(actual, CREATED_NOTE_CONTENT); + } + + fn assert_summary(summary: Option) { + assert_eq!( + summary.as_deref(), + Some("Created note: nota-longa-teste-gerada-2.md"), + ); + } + + fn assert_tool_events(events: &[AiAgentStreamEvent]) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, input: Some(input) } + if tool_name == CREATE_NOTE_TOOL_NAME && tool_id == "call_create" && input.contains(CREATED_NOTE_PATH) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output: Some(output) } + if tool_id == "call_create" && output.contains(CREATED_NOTE_PATH) + )); + } + + #[test] + fn openai_payload_offers_create_note_when_active_vault_is_loaded() { + let dir = tempfile::tempdir().unwrap(); + let payload = openai_chat_payload(&request(dir.path().to_string_lossy().into_owned())); + + assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME); + } + + #[test] + fn openai_payload_offers_create_note_for_tool_capable_custom_models() { + let dir = tempfile::tempdir().unwrap(); + let mut provider = provider(); + provider.kind = AiModelProviderKind::OpenAiCompatible; + provider.models[0].capabilities.tools = true; + let request = request_with_provider( + provider, + Some(dir.path().to_string_lossy().into_owned()), + vec![], + ); + + let payload = openai_chat_payload(&request); + + assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME); + } + + #[test] + fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() { + let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new())); + + assert!(payload.get("tools").is_none()); + assert!(payload.get("tool_choice").is_none()); + } + + #[test] + fn execute_openai_tool_calls_returns_none_without_tool_calls() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + + let summary = execute_openai_tool_calls( + &request, + &json!({ "choices": [{ "message": { "content": "No tools" } }] }), + |_| {}, + ) + .unwrap(); + + assert_eq!(summary, None); + } + + #[test] + fn executes_openai_create_note_tool_call_inside_active_vault() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let mut events = Vec::new(); + + let summary = execute_openai_tool_calls(&request, &create_note_response(), |event| { + events.push(event) + }) + .unwrap(); + + assert_note_created(dir.path()); + assert_summary(summary); + assert_tool_events(&events); + } + + #[test] + fn executes_openai_create_note_with_object_arguments_and_selected_vault() { + let primary = tempfile::tempdir().unwrap(); + let secondary = tempfile::tempdir().unwrap(); + let secondary_path = secondary.path().to_string_lossy().into_owned(); + let request = request_with_provider( + provider(), + Some(primary.path().to_string_lossy().into_owned()), + vec![secondary_path.clone()], + ); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": { + "path": "Generated/fallback-note.md", + "is_a": "Project", + "vaultPath": secondary_path, + } + } + })); + + let summary = execute_openai_tool_calls(&request, &response, |_| {}).unwrap(); + + assert_eq!( + fs::read_to_string(secondary.path().join("Generated/fallback-note.md")).unwrap(), + "---\ntype: \"Project\"\n---\n\n# fallback-note\n", + ); + assert_eq!( + summary.as_deref(), + Some("Created note: Generated/fallback-note.md") + ); + assert!(!primary.path().join("Generated/fallback-note.md").exists()); + } + + #[test] + fn execute_openai_tool_calls_rejects_unsupported_tool_before_running_it() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "id": "call_delete", + "function": { + "name": "delete_note", + "arguments": "{}", + } + })); + let mut events = Vec::new(); + + let error = + execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap_err(); + + assert_eq!(error, "AI provider requested unsupported tool: delete_note"); + assert!(events.is_empty()); + } + + #[test] + fn execute_openai_tool_calls_rejects_malformed_arguments() { + let error = create_note_error(json!("{not-json")); + + assert!(error.contains("Failed to parse AI tool arguments")); + } + + #[test] + fn execute_openai_tool_calls_treats_non_object_arguments_as_empty() { + let error = create_note_error(json!([])); + + assert_eq!(error, "create_note requires path."); + } + + #[test] + fn execute_openai_tool_calls_rejects_existing_note_without_overwriting() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + fs::write(dir.path().join("existing.md"), "# Existing\n").unwrap(); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "path": "existing.md", + "content": "# Replacement\n", + })).unwrap(), + } + })); + + let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err(); + + assert!(error.contains("already exists")); + assert_eq!( + fs::read_to_string(dir.path().join("existing.md")).unwrap(), + "# Existing\n", + ); + } + + #[test] + fn execute_openai_tool_calls_requires_path() { + let error = create_note_error(json!("{}")); + + assert_eq!(error, "create_note requires path."); + } + + #[test] + fn execute_openai_tool_calls_rejects_inactive_explicit_vault() { + let active = tempfile::tempdir().unwrap(); + let inactive = tempfile::tempdir().unwrap(); + let request = request(active.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "path": "inactive.md", + "content": "# Inactive\n", + "vaultPath": inactive.path().to_string_lossy(), + })).unwrap(), + } + })); + + let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err(); + + assert!(error.starts_with("Vault is not active in Tolaria:")); + assert!(!inactive.path().join("inactive.md").exists()); + } +} diff --git a/src-tauri/src/ai_models.rs b/src-tauri/src/ai_models.rs index 056148e9..b7f27fd8 100644 --- a/src-tauri/src/ai_models.rs +++ b/src-tauri/src/ai_models.rs @@ -61,6 +61,9 @@ pub struct AiModelStreamRequest { pub model_id: String, pub message: String, pub system_prompt: Option, + pub vault_path: Option, + #[serde(default)] + pub vault_paths: Vec, pub api_key_override: Option, #[serde(default)] pub event_name: Option, @@ -166,7 +169,7 @@ where session_id: format!("api-{}", uuid::Uuid::new_v4()), }); - let text = send_model_message(&request)?; + let text = send_model_message(&request, &mut emit)?; emit(AiAgentStreamEvent::TextDelta { text }); emit(AiAgentStreamEvent::Done); @@ -182,33 +185,39 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result Result { +fn send_model_message(request: &AiModelStreamRequest, emit: &mut F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ match request.provider.kind { AiModelProviderKind::Anthropic => send_anthropic_message(request), - _ => send_openai_compatible_message(request), + _ => send_openai_compatible_message(request, emit), } } -fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result { +fn send_openai_compatible_message( + request: &AiModelStreamRequest, + emit: &mut F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ let endpoint = format!("{}/chat/completions", normalized_base_url(request)?); - let mut messages = Vec::new(); - if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) { - messages.push(serde_json::json!({ "role": "system", "content": system_prompt })); - } - messages.push(serde_json::json!({ "role": "user", "content": request.message })); - - let payload = serde_json::json!({ - "model": request.model_id, - "messages": messages, - "stream": false - }); + let payload = crate::ai_model_tools::openai_chat_payload(request); let json = send_json_request(request, endpoint, payload)?; + if let Some(tool_summary) = + crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)? + { + return Ok(tool_summary); + } extract_openai_text(&json) } @@ -551,6 +560,8 @@ mod tests { model_id: "demo-model".into(), message: "Hello".into(), system_prompt: Some(" Be concise. ".into()), + vault_path: None, + vault_paths: Vec::new(), api_key_override: None, event_name: None, } @@ -623,6 +634,51 @@ mod tests { assert_eq!(headers, vec![("X-Demo", "demo")]); } + #[test] + fn request_builders_apply_auth_and_provider_headers() { + let client = reqwest::blocking::Client::new(); + let mut anthropic_provider = provider(AiModelProviderKind::Anthropic); + anthropic_provider.api_key_env_var = None; + anthropic_provider.headers = Some(BTreeMap::from([ + ("Authorization".into(), "ignored".into()), + ("X-Demo".into(), "demo".into()), + ])); + let mut anthropic_request = request(anthropic_provider); + anthropic_request.api_key_override = Some(" secret ".into()); + + let built = apply_provider_headers( + apply_auth_headers(client.post("https://example.test"), &anthropic_request).unwrap(), + &anthropic_request, + ) + .build() + .unwrap(); + + let headers = built.headers(); + assert_eq!( + ( + headers["x-api-key"].to_str().unwrap(), + headers["anthropic-version"].to_str().unwrap(), + headers["X-Demo"].to_str().unwrap(), + headers.get("authorization").is_none(), + ), + ("secret", "2023-06-01", "demo", true), + ); + + let mut openai_provider = provider(AiModelProviderKind::OpenAi); + openai_provider.api_key_env_var = None; + let mut openai_request = request(openai_provider); + openai_request.api_key_override = Some(" openai-secret ".into()); + let built = apply_auth_headers(client.post("https://example.test"), &openai_request) + .unwrap() + .build() + .unwrap(); + + assert_eq!( + built.headers()["authorization"].to_str().unwrap(), + "Bearer openai-secret" + ); + } + #[test] fn shared_provider_catalog_supplies_runtime_base_urls() { assert_eq!( diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 8b9bff19..d74dcff3 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -1303,6 +1303,7 @@ mod tests { assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done))); } + #[cfg(unix)] #[test] fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() { use std::io::Read; @@ -1321,7 +1322,7 @@ mod tests { let child_stdin = child.stdin.take().unwrap(); let mut stdout = child.stdout.take().unwrap(); let mut stderr = child.stderr.take().unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(30); let status = loop { if let Some(status) = child.try_wait().unwrap() { @@ -1347,6 +1348,7 @@ mod tests { ); } + #[cfg(unix)] #[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"] #[test] fn stdin_probe_parent_child() { @@ -1354,25 +1356,15 @@ mod tests { return; } - let fake_bin = current_test_binary(); - let args = vec![ - "stdin_probe_mock_claude_child".to_string(), - "--ignored".to_string(), - "--nocapture".to_string(), - ]; - std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1"); - let mut events = vec![]; - let result = run_claude_subprocess( - ClaudeSubprocessRequest { - bin: &fake_bin, - args: &args, - fallback_args: &[], - stdin_text: None, - cwd: None, - }, - &mut |event| events.push(event), - ); - std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD"); + let (result, events) = run_mock_script(MockClaudeScript(concat!( + "#!/bin/sh\n", + "stdin=\"$(cat)\"\n", + "if [ -n \"$stdin\" ]; then\n", + " echo \"stdin was not closed\" >&2\n", + " exit 9\n", + "fi\n", + "printf '%s\\n' '{\"type\":\"result\",\"result\":\"stdin closed\",\"session_id\":\"stdin-ok\"}'\n", + ))); assert_eq!(result.unwrap(), "stdin-ok"); assert!(matches!( @@ -1383,28 +1375,6 @@ mod tests { assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done))); } - #[ignore = "spawned by stdin_probe_parent_child"] - #[test] - fn stdin_probe_mock_claude_child() { - if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() { - return; - } - - use std::io::Read; - - let mut stdin = String::new(); - std::io::stdin().read_to_string(&mut stdin).unwrap(); - assert!(stdin.is_empty(), "stdin was not EOF"); - println!( - "{}", - serde_json::json!({ - "type": "result", - "result": "stdin closed", - "session_id": "stdin-ok" - }) - ); - } - #[cfg(unix)] #[test] fn run_subprocess_skips_blank_and_non_json_lines() { diff --git a/src-tauri/src/cli_agent_runtime/shell_env.rs b/src-tauri/src/cli_agent_runtime/shell_env.rs index cd6a1a29..cb7674f1 100644 --- a/src-tauri/src/cli_agent_runtime/shell_env.rs +++ b/src-tauri/src/cli_agent_runtime/shell_env.rs @@ -1,6 +1,6 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:"; @@ -110,6 +110,7 @@ fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option let output = crate::hidden_command(shell) .arg("-lc") .arg(shell_probe_script(shell, names)) + .stdin(Stdio::null()) .output() .ok()?; if !output.status.success() { @@ -191,6 +192,80 @@ fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option>(); + assert_eq!(values, vec![("TOLARIA_TEST_COMMAND_VALUE".into(), true)]); + } + + #[test] + fn parse_probe_output_filters_invalid_unexpected_and_blank_values() { + let names = [ + EnvName::trusted("GOOD_VALUE"), + EnvName::trusted("OTHER_VALUE"), + ]; + + let bindings = parse_probe_output( + "__TOLARIA_ENV__:GOOD_VALUE=kept\n\ + ignored\n\ + __TOLARIA_ENV__:BAD-NAME=bad\n\ + __TOLARIA_ENV__:OTHER_VALUE= \n\ + __TOLARIA_ENV__:UNEXPECTED=value\n", + &names, + ); + + assert_eq!( + bindings, + vec![EnvBinding { + name: "GOOD_VALUE".into(), + value: "kept".into(), + }] + ); + } + + #[cfg(unix)] + #[test] + fn user_shell_bindings_from_shell_returns_none_for_failing_shell() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("zsh"); + std::fs::write(&shell, "#!/bin/sh\nexit 7\n").unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let values = user_shell_bindings_from_shell(&shell, &[EnvName::trusted("MISSING")]); + + assert_eq!(values, None); + } + + #[cfg(unix)] + #[test] + fn rc_source_command_ignores_unknown_shells() { + assert_eq!(rc_source_command(Path::new("fish")), ""); + } + #[cfg(unix)] #[test] fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c466c187..d7dc0449 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod ai_agents; +mod ai_model_tools; pub mod ai_models; mod app_icon; pub mod app_updater; diff --git a/src/lib/aiAgentSession.test.ts b/src/lib/aiAgentSession.test.ts index e224448c..a38c19c8 100644 --- a/src/lib/aiAgentSession.test.ts +++ b/src/lib/aiAgentSession.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatus, AiAgentMessage } from './aiAgentConversation' +import type { AiModelDefinition, AiModelProvider, AiTarget } from './aiTargets' const { buildAgentSystemPromptMock, @@ -8,6 +9,7 @@ const { hydrateNoteReferencesMock, nextMessageIdMock, streamAiAgentMock, + streamAiModelMock, trackEventMock, trimHistoryMock, } = vi.hoisted(() => ({ @@ -17,6 +19,7 @@ const { hydrateNoteReferencesMock: vi.fn(async (references: unknown) => references), nextMessageIdMock: vi.fn(), streamAiAgentMock: vi.fn(async () => {}), + streamAiModelMock: vi.fn(async () => {}), trackEventMock: vi.fn(), trimHistoryMock: vi.fn((history: unknown) => history), })) @@ -40,6 +43,10 @@ vi.mock('../utils/streamAiAgent', () => ({ streamAiAgent: streamAiAgentMock, })) +vi.mock('../utils/streamAiModel', () => ({ + streamAiModel: streamAiModelMock, +})) + vi.mock('../utils/ai-reference-content', () => ({ hydrateNoteReferences: hydrateNoteReferencesMock, })) @@ -109,6 +116,36 @@ const expectedChatHistory = [ { role: 'user', content: 'Previous question', id: 'msg-1' }, { role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' }, ] +const apiModelProvider: AiModelProvider = { + id: 'openai', + name: 'OpenAI', + kind: 'open_ai', + base_url: 'https://api.openai.com/v1', + api_key_storage: 'local_file', + api_key_env_var: null, + models: [], +} +const apiModel: AiModelDefinition = { + id: 'gpt-5-nano', + display_name: 'GPT-5 nano', + context_window: null, + max_output_tokens: null, + capabilities: { + streaming: false, + tools: false, + vision: false, + json_mode: false, + reasoning: false, + }, +} +const apiTarget: AiTarget = { + kind: 'api_model', + provider: apiModelProvider, + model: apiModel, + id: 'model:openai/gpt-5-nano', + label: 'OpenAI ยท GPT-5 nano', + shortLabel: 'GPT-5 nano', +} function expectStreamingRuntimeState(session: RuntimeFixture): void { expect(session.runtime.abortRef.current).toEqual({ aborted: false }) @@ -153,6 +190,24 @@ function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void { }) } +function expectApiModelStreamingRequest(runtime: RuntimeFixture['runtime']): void { + expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({ + messageId: 'msg-stream', + vaultPath: '/vault', + setMessages: runtime.setMessages, + setStatus: runtime.setStatus, + })) + expect(streamAiModelMock).toHaveBeenCalledWith({ + provider: apiModelProvider, + model: apiModel, + message: expect.stringContaining('formatted:Latest question'), + systemPrompt: 'SYSTEM', + vaultPath: '/vault', + vaultPaths: ['/vault', '/team-vault'], + callbacks: { stream: 'callbacks' }, + }) +} + describe('aiAgentSession', () => { beforeEach(() => { vi.clearAllMocks() @@ -286,6 +341,35 @@ describe('aiAgentSession', () => { }) }) + it('passes vault roots to api model streams for native note tools', async () => { + nextMessageIdMock.mockReturnValue('msg-stream') + const session = createRuntime([ + completedHistory, + streamingHistory, + ]) + + await sendAgentMessage({ + runtime: session.runtime, + context: { + agent: 'codex', + target: apiTarget, + ready: true, + vaultPath: '/vault', + vaultPaths: ['/vault', '/team-vault'], + permissionMode: 'safe', + }, + prompt: { + text: ' Latest question ', + references: [{ path: '/vault/ref.md', title: 'Ref' }], + }, + }) + + expectStreamingRuntimeState(session) + expectFormattedHistoryUsed() + expectApiModelStreamingRequest(session.runtime) + expect(streamAiAgentMock).not.toHaveBeenCalled() + }) + it('clears the conversation and resets runtime refs', () => { const { runtime } = createRuntime([ { id: 'msg-1', userMessage: 'Question', actions: [] }, diff --git a/src/lib/aiAgentSession.ts b/src/lib/aiAgentSession.ts index 178152c0..82c25543 100644 --- a/src/lib/aiAgentSession.ts +++ b/src/lib/aiAgentSession.ts @@ -82,6 +82,8 @@ async function streamWithSelectedTarget( model: context.target.model, message: formattedMessage, systemPrompt, + vaultPath: context.vaultPath, + vaultPaths: context.vaultPaths, callbacks, }) return diff --git a/src/utils/streamAiModel.test.ts b/src/utils/streamAiModel.test.ts index ac2a0e5d..88408ebb 100644 --- a/src/utils/streamAiModel.test.ts +++ b/src/utils/streamAiModel.test.ts @@ -86,4 +86,29 @@ describe('streamAiModel', () => { expect(callbacks.onDone).toHaveBeenCalledTimes(1) expect(unlisten).toHaveBeenCalledTimes(1) }) + + it('passes active vault roots to native model streams for note tools', async () => { + isTauriState.value = true + const unlisten = vi.fn() + listenMock.mockResolvedValue(unlisten) + invokeMock.mockResolvedValue('session') + + const callbacks = createCallbacks() + + await streamAiModel({ + provider, + model, + message: 'create a note', + vaultPath: '/vault', + vaultPaths: ['/vault', '/team-vault'], + callbacks, + }) + + expect(invokeMock).toHaveBeenCalledWith('stream_ai_model', { + request: expect.objectContaining({ + vault_path: '/vault', + vault_paths: ['/vault', '/team-vault'], + }), + }) + }) }) diff --git a/src/utils/streamAiModel.ts b/src/utils/streamAiModel.ts index a9abfb3c..24dec2d1 100644 --- a/src/utils/streamAiModel.ts +++ b/src/utils/streamAiModel.ts @@ -18,9 +18,22 @@ interface StreamAiModelRequest { model: AiModelDefinition message: string systemPrompt?: string + vaultPath?: string + vaultPaths?: string[] callbacks: AgentStreamCallbacks } +interface NativeAiModelStreamRequest { + provider: AiModelProvider + model_id: string + message: string + system_prompt: string | null + vault_path: string | null + vault_paths: string[] | null + api_key_override: null + event_name: string +} + function mockModelResponse(provider: AiModelProvider, model: AiModelDefinition, message: string): string { const displayName = model.display_name || model.id return `[mock-${provider.name} ${displayName}] You asked: "${message.slice(0, 160)}"` @@ -49,55 +62,79 @@ function handleStreamEvent(data: AiModelStreamEvent, callbacks: AgentStreamCallb } } -export async function streamAiModel({ - provider, - model, - message, - systemPrompt, - callbacks, -}: StreamAiModelRequest): Promise { - if (!isTauri()) { - setTimeout(() => { - callbacks.onText(mockModelResponse(provider, model, message)) - callbacks.onDone() - }, 300) - return - } +function streamMockAiModel({ provider, model, message, callbacks }: StreamAiModelRequest): void { + setTimeout(() => { + callbacks.onText(mockModelResponse(provider, model, message)) + callbacks.onDone() + }, 300) +} - const { invoke } = await import('@tauri-apps/api/core') - const { listen } = await import('@tauri-apps/api/event') - const eventName = createScopedStreamEventName('ai-model-stream') +function nativeVaultPaths(vaultPaths: string[] | undefined): string[] | null { + return vaultPaths && vaultPaths.length > 0 ? vaultPaths : null +} + +function nativeAiModelRequest(request: StreamAiModelRequest, eventName: string): NativeAiModelStreamRequest { + return { + provider: request.provider, + model_id: request.model.id, + message: request.message, + system_prompt: request.systemPrompt || null, + vault_path: request.vaultPath || null, + vault_paths: nativeVaultPaths(request.vaultPaths), + api_key_override: null, + event_name: eventName, + } +} + +function createStreamCloser(callbacks: AgentStreamCallbacks) { let closed = false - const closeStream = (): void => { + return (): void => { if (closed) return closed = true callbacks.onDone() } +} + +function handleNativeStreamEvent( + data: AiModelStreamEvent, + callbacks: AgentStreamCallbacks, + closeStream: () => void, +): void { + if (data.kind === 'Done') { + closeStream() + return + } + handleStreamEvent(data, callbacks) +} + +async function streamNativeAiModel(request: StreamAiModelRequest): Promise { + const { invoke } = await import('@tauri-apps/api/core') + const { listen } = await import('@tauri-apps/api/event') + const eventName = createScopedStreamEventName('ai-model-stream') + const closeStream = createStreamCloser(request.callbacks) const unlisten = await listen(eventName, (event) => { - if (event.payload.kind === 'Done') { - closeStream() - return - } - handleStreamEvent(event.payload, callbacks) + handleNativeStreamEvent(event.payload, request.callbacks, closeStream) }) try { await invoke('stream_ai_model', { - request: { - provider, - model_id: model.id, - message, - system_prompt: systemPrompt || null, - api_key_override: null, - event_name: eventName, - }, + request: nativeAiModelRequest(request, eventName), }) closeStream() } catch (err) { - callbacks.onError(err instanceof Error ? err.message : String(err)) + request.callbacks.onError(err instanceof Error ? err.message : String(err)) closeStream() } finally { cleanupTauriEventListener(unlisten) } } + +export async function streamAiModel(request: StreamAiModelRequest): Promise { + if (!isTauri()) { + streamMockAiModel(request) + return + } + + await streamNativeAiModel(request) +}