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

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