fix: let OpenAI API create notes
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
623
src-tauri/src/ai_model_tools.rs
Normal file
623
src-tauri/src/ai_model_tools.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ai_agents;
|
||||
mod ai_model_tools;
|
||||
pub mod ai_models;
|
||||
mod app_icon;
|
||||
pub mod app_updater;
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -82,6 +82,8 @@ async function streamWithSelectedTarget(
|
||||
model: context.target.model,
|
||||
message: formattedMessage,
|
||||
systemPrompt,
|
||||
vaultPath: context.vaultPath,
|
||||
vaultPaths: context.vaultPaths,
|
||||
callbacks,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user