fix: let OpenAI API create notes

This commit is contained in:
lucaronin
2026-06-06 10:42:33 +02:00
parent 80ee94da73
commit 3c8edd8346
11 changed files with 966 additions and 93 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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<F>(
request: &AiModelStreamRequest,
json: &serde_json::Value,
emit: F,
) -> Result<Option<String>, 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<serde_json::Value> {
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<F>(
request: &AiModelStreamRequest,
tool_calls: &[OpenAiToolCall],
mut emit: F,
) -> Result<String, String>
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<F>(
request: &AiModelStreamRequest,
tool_call: &OpenAiToolCall,
emit: &mut F,
) -> Result<String, String>
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<Vec<OpenAiToolCall>, 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<OpenAiToolCall, String> {
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<CreatedNoteToolResult, String> {
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<Item = &str> {
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<String>,
vault_paths: Vec<String>,
) -> 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<String>) {
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());
}
}

View File

@@ -61,6 +61,9 @@ pub struct AiModelStreamRequest {
pub model_id: String,
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: Option<String>,
#[serde(default)]
pub vault_paths: Vec<String>,
pub api_key_override: Option<String>,
#[serde(default)]
pub event_name: Option<String>,
@@ -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<Str
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
.into(),
),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: normalize_optional_string(request.api_key_override),
event_name: None,
};
send_model_message(&request)
send_model_message(&request, &mut |_| {})
}
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_model_message<F>(request: &AiModelStreamRequest, emit: &mut F) -> Result<String, String>
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<String, String> {
fn send_openai_compatible_message<F>(
request: &AiModelStreamRequest,
emit: &mut F,
) -> Result<String, String>
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!(

View File

@@ -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() {

View File

@@ -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<EnvBin
mod tests {
use super::*;
#[test]
fn env_value_uses_trimmed_process_value() {
let key = "TOLARIA_TEST_SHELL_ENV_PROCESS_VALUE";
std::env::set_var(key, " process-secret ");
let value = env_value_from_process_or_user_shell(EnvName::trusted(key));
std::env::remove_var(key);
assert_eq!(value.as_deref(), Some("process-secret"));
}
#[test]
fn command_value_marks_name_as_already_available() {
let mut command = Command::new("demo");
command.env("TOLARIA_TEST_COMMAND_VALUE", "command-secret");
apply_user_shell_env_vars_if_missing(
&mut command,
&[EnvName::trusted("TOLARIA_TEST_COMMAND_VALUE")],
);
let values = command
.get_envs()
.map(|(key, value)| (key.to_string_lossy().to_string(), value.is_some()))
.collect::<Vec<_>>();
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() {

View File

@@ -1,4 +1,5 @@
pub mod ai_agents;
mod ai_model_tools;
pub mod ai_models;
mod app_icon;
pub mod app_updater;

View File

@@ -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: [] },

View File

@@ -82,6 +82,8 @@ async function streamWithSelectedTarget(
model: context.target.model,
message: formattedMessage,
systemPrompt,
vaultPath: context.vaultPath,
vaultPaths: context.vaultPaths,
callbacks,
})
return

View File

@@ -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'],
}),
})
})
})

View File

@@ -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<void> {
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<void> {
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<AiModelStreamEvent>(eventName, (event) => {
if (event.payload.kind === 'Done') {
closeStream()
return
}
handleStreamEvent(event.payload, callbacks)
handleNativeStreamEvent(event.payload, request.callbacks, closeStream)
})
try {
await invoke<string>('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<void> {
if (!isTauri()) {
streamMockAiModel(request)
return
}
await streamNativeAiModel(request)
}