Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs
T
kdletters 37189c9252
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 7m38s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 7m40s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 7m45s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 8m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m24s
Project CI / AI game creator shell Rust crates (push) Successful in 3m8s
Project CI / Repository checks (push) Successful in 7m53s
Project CI / Frontend tests (push) Successful in 8m48s
Project CI / Native shell tests (push) Successful in 11m14s
Project CI / Backend tests (push) Successful in 12m54s
Project CI / AI game creator shell web tests (push) Successful in 5m53s
记录项目主模型使用历史并补录旧项目
在项目 .agent 中保存请求模型与响应确认型号,区分目录标识和真实模型
为旧项目补录带来源标记的模型信息,保留历史并确保重复打开幂等
在 Direct Codex 代理中提取模型元数据,异步落盘且不阻塞响应转发
补充模型追溯边界测试并同步技术方案与团队约定
2026-09-20 17:02:44 +08:00

659 lines
26 KiB
Rust

//! 随项目保存主模型来源;只接收白名单元数据,不接收提示词、响应正文或连接配置。
use super::{
append_jsonl_line_unlocked, open_project_private_regular_file, project_append_lock_for,
read_agent_db_records_bounded, resolve_local_project_path,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const MODEL_USAGE_PATH: &str = ".agent/model-usage.jsonl";
const MODEL_USAGE_LABEL: &str = "项目主模型记录";
const MODEL_USAGE_MAX_BYTES: u64 = 16 * 1024 * 1024;
const MODEL_USAGE_MAX_LINE_BYTES: usize = 4096;
const MODEL_USAGE_MAX_RECORDS: usize = 32_768;
const MODEL_USAGE_MAX_FIELD_BYTES: usize = 256;
const MODEL_HISTORY_MAX_BYTES: u64 = 2 * 1024 * 1024;
const MODEL_HISTORY_MAX_TURN_LOGS: usize = 32;
#[derive(Clone, Debug)]
pub(crate) struct ProjectModelUsageContext {
pub root: PathBuf,
pub client_turn_id: Option<String>,
pub thread_id: Option<String>,
pub requested_model: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ModelUsageRecord {
schema_version: u32,
recorded_at_ms: u64,
source: String,
requested_model: String,
model_name: Option<String>,
client_turn_id: Option<String>,
thread_id: Option<String>,
response_id: Option<String>,
historical_model_confirmed: bool,
}
fn safe_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MODEL_USAGE_MAX_FIELD_BYTES
// 与自定义目录的标识校验同口径,保留供应商使用的 @、+ 等字符。
&& !value.chars().any(|character| character.is_control() || character.is_whitespace())
&& !value.contains("://")
&& !value.contains('\\')
&& !value.starts_with('/')
&& !value.to_ascii_lowercase().starts_with("sk-")
}
fn is_channel_placeholder(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"platform-default" | "codex-app-server" | "codex-cli" | "direct-codex" | "direct codex"
)
}
fn validate_record(record: &ModelUsageRecord) -> Result<(), String> {
if record.schema_version != 1
|| !matches!(
record.source.as_str(),
"turn-request" | "provider-response" | "current-config-backfill" | "history-backfill"
)
|| !safe_identifier(&record.requested_model)
|| record
.model_name
.as_deref()
.is_some_and(|value| !safe_identifier(value) || is_channel_placeholder(value))
|| [
record.client_turn_id.as_deref(),
record.thread_id.as_deref(),
record.response_id.as_deref(),
]
.into_iter()
.flatten()
.any(|value| !safe_identifier(value))
|| (matches!(
record.source.as_str(),
"provider-response" | "history-backfill"
) && (record.model_name.is_none() || !record.historical_model_confirmed))
|| (record.source == "current-config-backfill" && record.historical_model_confirmed)
{
return Err("项目主模型记录字段无效".to_string());
}
Ok(())
}
fn new_record(context: &ProjectModelUsageContext, source: &str) -> ModelUsageRecord {
ModelUsageRecord {
schema_version: 1,
recorded_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or_default(),
source: source.to_string(),
requested_model: context.requested_model.clone(),
model_name: None,
client_turn_id: context.client_turn_id.clone(),
thread_id: context.thread_id.clone(),
response_id: None,
historical_model_confirmed: false,
}
}
fn read_private_bytes(path: &Path, max_bytes: u64) -> Result<Option<Vec<u8>>, String> {
match fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err("读取项目模型记录元数据失败".to_string()),
Ok(_) => {}
}
let (file, metadata) = open_project_private_regular_file(path, MODEL_USAGE_LABEL)?;
if metadata.len() > max_bytes {
return Err("项目模型记录超过读取上限".to_string());
}
let mut content = Vec::new();
file.take(max_bytes + 1)
.read_to_end(&mut content)
.map_err(|_| "读取项目模型记录失败".to_string())?;
if content.len() as u64 > max_bytes {
return Err("项目模型记录超过读取上限".to_string());
}
Ok(Some(content))
}
fn read_usage_records(path: &Path) -> Result<(Vec<ModelUsageRecord>, u64), String> {
let Some(content) = read_private_bytes(path, MODEL_USAGE_MAX_BYTES)? else {
return Ok((Vec::new(), 0));
};
// 既有追加器会修复截断尾行;此处先拒绝所有非完整 JSONL,确保模型历史永不被修剪。
if !content.is_empty() && content.last() != Some(&b'\n') {
return Err("项目模型记录存在不完整尾行,已保留原文件".to_string());
}
let mut records = Vec::new();
for line in content.split(|byte| *byte == b'\n') {
if line.is_empty() {
continue;
}
if line.len() > MODEL_USAGE_MAX_LINE_BYTES || records.len() >= MODEL_USAGE_MAX_RECORDS {
return Err("项目模型记录超过记录上限".to_string());
}
let record: ModelUsageRecord = serde_json::from_slice(line)
.map_err(|_| "项目模型记录损坏,已保留原文件".to_string())?;
validate_record(&record)?;
records.push(record);
}
Ok((records, content.len() as u64))
}
fn same_observation(left: &ModelUsageRecord, right: &ModelUsageRecord) -> bool {
left.source == right.source
&& left.requested_model == right.requested_model
&& left.model_name == right.model_name
&& left.client_turn_id == right.client_turn_id
&& left.thread_id == right.thread_id
&& left.response_id == right.response_id
}
fn append_records_unlocked(
path: &Path,
existing: &[ModelUsageRecord],
mut bytes: u64,
records: &[ModelUsageRecord],
) -> Result<(), String> {
let mut lines = Vec::new();
for (index, record) in records.iter().enumerate() {
validate_record(record)?;
if existing.iter().any(|other| same_observation(other, record))
|| records[..index]
.iter()
.any(|other| same_observation(other, record))
{
continue;
}
let line =
serde_json::to_string(record).map_err(|_| "序列化项目模型记录失败".to_string())?;
bytes = bytes.saturating_add(line.len() as u64 + 1);
if line.len() > MODEL_USAGE_MAX_LINE_BYTES
|| bytes > MODEL_USAGE_MAX_BYTES
|| existing.len() + lines.len() >= MODEL_USAGE_MAX_RECORDS
{
return Err("项目模型记录超过写入上限".to_string());
}
lines.push(line);
}
for line in lines {
append_jsonl_line_unlocked(path, &line, MODEL_USAGE_LABEL)?;
}
Ok(())
}
fn append_record(root: &Path, record: ModelUsageRecord) -> Result<(), String> {
validate_record(&record)?;
let path = resolve_local_project_path(root, MODEL_USAGE_PATH)?;
let append_lock = project_append_lock_for(&path)?;
let _guard = append_lock.lock(MODEL_USAGE_LABEL)?;
let (existing, bytes) = read_usage_records(&path)?;
append_records_unlocked(&path, &existing, bytes, &[record])
}
pub(crate) fn record_project_model_request_at(
context: &ProjectModelUsageContext,
custom_enabled: bool,
) -> Result<(), String> {
let mut record = new_record(context, "turn-request");
if custom_enabled && !is_channel_placeholder(&context.requested_model) {
record.model_name = Some(context.requested_model.clone());
}
append_record(&context.root, record)
}
pub(crate) fn record_project_model_response_at(
context: &ProjectModelUsageContext,
model: &str,
response_id: Option<&str>,
) -> Result<(), String> {
let mut record = new_record(context, "provider-response");
record.model_name = Some(model.to_string());
record.response_id = response_id.map(str::to_string);
record.historical_model_confirmed = true;
append_record(&context.root, record)
}
fn historical_record(root: &Path, value: &Value) -> Option<ModelUsageRecord> {
// 只接受 Direct 主模型审计的显式型号;普通 model 字段可能来自目录、素材或工具参数。
if !matches!(
value.get("recordType").and_then(Value::as_str),
Some("direct.codex.turn" | "direct.codex.turn_start" | "direct.codex.model")
) || !(value
.get("historicalModelConfirmed")
.and_then(Value::as_bool)
== Some(true)
|| value.get("modelSource").and_then(Value::as_str) == Some("provider-response"))
{
return None;
}
let model = value.get("modelName")?.as_str()?;
let context = ProjectModelUsageContext {
root: root.to_path_buf(),
client_turn_id: value
.get("clientTurnId")
.and_then(Value::as_str)
.map(str::to_string),
thread_id: value
.get("threadId")
.and_then(Value::as_str)
.map(str::to_string),
requested_model: value
.get("requestedModel")
.and_then(Value::as_str)
.unwrap_or(model)
.to_string(),
};
let mut record = new_record(&context, "history-backfill");
record.model_name = Some(model.to_string());
record.response_id = value
.get("responseId")
.and_then(Value::as_str)
.map(str::to_string);
record.historical_model_confirmed = true;
validate_record(&record).ok()?;
Some(record)
}
fn collect_historical_records(root: &Path) -> Result<Vec<ModelUsageRecord>, String> {
let (summaries, _) = read_agent_db_records_bounded(root, MODEL_HISTORY_MAX_BYTES)?;
let mut records = Vec::new();
let mut turn_logs = BTreeSet::new();
for value in summaries.iter().rev() {
if let Some(record) = historical_record(root, value) {
records.push(record);
}
if value.get("recordType").and_then(Value::as_str) != Some("direct.codex.turn") {
continue;
}
let Some(relative) = value.get("turnLog").and_then(Value::as_str) else {
continue;
};
let Some(name) = relative.strip_prefix(".agent/runtime/direct-codex/turns/") else {
continue;
};
if name.len() <= MODEL_USAGE_MAX_FIELD_BYTES
&& name.ends_with(".jsonl")
&& !name.contains(['/', '\\'])
&& !name.starts_with('.')
&& turn_logs.len() < MODEL_HISTORY_MAX_TURN_LOGS
{
turn_logs.insert(relative.to_string());
}
}
let mut remaining = MODEL_HISTORY_MAX_BYTES;
for relative in turn_logs {
let path = resolve_local_project_path(root, &relative)?;
let content = match read_private_bytes(&path, remaining) {
Ok(Some(content)) => content,
Ok(None) => continue,
// 历史扫描只是有界证据恢复;预算用尽不阻止带来源标注的当前配置补录。
Err(error) if error == "项目模型记录超过读取上限" => break,
Err(error) => return Err(error),
};
remaining = remaining.saturating_sub(content.len() as u64);
for line in content.split(|byte| *byte == b'\n') {
if line.len() > MODEL_USAGE_MAX_LINE_BYTES {
continue;
}
if let Ok(value) = serde_json::from_slice::<Value>(line) {
if let Some(record) = historical_record(root, &value) {
records.push(record);
}
}
}
}
Ok(records)
}
pub(crate) fn backfill_project_model_usage_at(
root: &Path,
llm: &crate::GameCreatorLlmConfig,
) -> Result<(), String> {
let path = resolve_local_project_path(root, MODEL_USAGE_PATH)?;
let append_lock = project_append_lock_for(&path)?;
let _guard = append_lock.lock(MODEL_USAGE_LABEL)?;
let (existing, bytes) = read_usage_records(&path)?;
if !existing.is_empty() {
return Ok(());
}
let mut records = collect_historical_records(root)?;
if records.is_empty() {
let context = ProjectModelUsageContext {
root: root.to_path_buf(),
client_turn_id: None,
thread_id: None,
requested_model: llm.model.clone(),
};
let mut record = new_record(&context, "current-config-backfill");
if llm.custom_enabled && !is_channel_placeholder(&llm.model) {
record.model_name = Some(llm.model.clone());
}
records.push(record);
}
append_records_unlocked(&path, &existing, bytes, &records)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn context(root: &Path, turn: &str, model: &str) -> ProjectModelUsageContext {
ProjectModelUsageContext {
root: root.to_path_buf(),
client_turn_id: Some(turn.to_string()),
thread_id: Some("thread-model-test".to_string()),
requested_model: model.to_string(),
}
}
fn config(custom_enabled: bool, model: &str) -> crate::GameCreatorLlmConfig {
crate::GameCreatorLlmConfig {
custom_enabled,
visible_models: vec![model.to_string()],
api_key: "credential-must-not-appear".to_string(),
base_url: "https://private-provider.example/v1".to_string(),
model: model.to_string(),
api_kind: "openai_responses".to_string(),
reasoning_effort: "high".to_string(),
stream: true,
web_search_enabled: false,
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 10_000,
max_retries: 0,
retry_backoff_ms: 100,
}
}
fn records(root: &Path) -> Vec<Value> {
fs::read_to_string(root.join(MODEL_USAGE_PATH))
.expect("read model usage")
.lines()
.map(|line| serde_json::from_str(line).expect("valid record"))
.collect()
}
#[test]
fn model_usage_preserves_switched_models_and_deduplicates_response_events() {
let root = tempfile::tempdir().unwrap();
let first = context(root.path(), "turn-first", "quality");
record_project_model_request_at(&first, false).unwrap();
record_project_model_response_at(&first, "gpt-6-astra", Some("response-first")).unwrap();
record_project_model_response_at(&first, "gpt-6-astra", Some("response-first")).unwrap();
let second = context(root.path(), "turn-second", "fast");
record_project_model_request_at(&second, false).unwrap();
record_project_model_response_at(&second, "gpt-5.6-luna", Some("response-second")).unwrap();
let saved = records(root.path());
assert_eq!(saved.len(), 4);
assert_eq!(saved[0]["requestedModel"], "quality");
assert!(saved[0]["modelName"].is_null());
assert_eq!(saved[1]["modelName"], "gpt-6-astra");
assert_eq!(saved[3]["modelName"], "gpt-5.6-luna");
assert_eq!(saved[1]["clientTurnId"], "turn-first");
assert_eq!(saved[3]["clientTurnId"], "turn-second");
}
#[test]
fn model_usage_backfill_is_idempotent_and_does_not_claim_official_history() {
let root = tempfile::tempdir().unwrap();
backfill_project_model_usage_at(root.path(), &config(false, "catalog-new-id")).unwrap();
let original = fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap();
backfill_project_model_usage_at(root.path(), &config(true, "different-model")).unwrap();
assert_eq!(
fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap(),
original
);
let saved = records(root.path());
assert_eq!(saved.len(), 1);
assert_eq!(saved[0]["source"], "current-config-backfill");
assert_eq!(saved[0]["requestedModel"], "catalog-new-id");
assert!(saved[0]["modelName"].is_null());
assert_eq!(saved[0]["historicalModelConfirmed"], false);
}
#[test]
fn model_usage_custom_configuration_keeps_model_without_connection_or_credentials() {
let root = tempfile::tempdir().unwrap();
backfill_project_model_usage_at(root.path(), &config(true, "vendor/model.v1:latest"))
.unwrap();
record_project_model_request_at(
&context(root.path(), "turn-custom", "vendor/model.v2"),
true,
)
.unwrap();
let saved = records(root.path());
assert_eq!(saved[0]["modelName"], "vendor/model.v1:latest");
assert_eq!(saved[1]["modelName"], "vendor/model.v2");
let raw = fs::read_to_string(root.path().join(MODEL_USAGE_PATH)).unwrap();
for forbidden in [
"credential-must-not-appear",
"private-provider",
"apiKey",
"baseUrl",
"prompt",
"content",
"root",
] {
assert!(!raw.contains(forbidden), "forbidden field: {forbidden}");
}
assert_eq!(saved[0].as_object().unwrap().len(), 9);
}
#[test]
fn model_usage_preserves_custom_model_punctuation() {
let root = tempfile::tempdir().unwrap();
let model = "@vendor/model+vision:release-2026";
record_project_model_request_at(&context(root.path(), "turn-punctuation", model), true)
.unwrap();
record_project_model_response_at(
&context(root.path(), "turn-punctuation", model),
model,
None,
)
.unwrap();
let saved = records(root.path());
assert_eq!(saved[0]["requestedModel"], model);
assert_eq!(saved[0]["modelName"], model);
assert_eq!(saved[1]["modelName"], model);
}
#[test]
fn model_usage_backfill_prefers_confirmed_direct_audit_and_ignores_other_models() {
let root = tempfile::tempdir().unwrap();
super::super::append_agent_db_record(root.path(), json!({
"recordType": "asset.generated", "modelName": "gpt-image-2", "historicalModelConfirmed": true
})).unwrap();
super::super::append_agent_db_record(
root.path(),
json!({
"recordType": "direct.codex.turn", "clientTurnId": "turn-old",
"requestedModel": "quality", "modelName": "gpt-old-confirmed",
"historicalModelConfirmed": true, "content": "private text must not be copied"
}),
)
.unwrap();
backfill_project_model_usage_at(root.path(), &config(true, "new-config-model")).unwrap();
let saved = records(root.path());
assert_eq!(saved.len(), 1);
assert_eq!(saved[0]["source"], "history-backfill");
assert_eq!(saved[0]["modelName"], "gpt-old-confirmed");
assert_eq!(saved[0]["historicalModelConfirmed"], true);
assert!(!fs::read_to_string(root.path().join(MODEL_USAGE_PATH))
.unwrap()
.contains("private text"));
}
#[test]
fn model_usage_backfill_rejects_channels_and_unqualified_model_fields() {
let root = tempfile::tempdir().unwrap();
for audit in [
json!({"recordType":"direct.codex.turn", "model":"gpt-claimed"}),
json!({"recordType":"direct.codex.turn", "modelName":"codex-app-server", "historicalModelConfirmed":true}),
json!({"recordType":"design.response", "modelName":"gpt-design", "historicalModelConfirmed":true}),
json!({"recordType":"direct.codex.turn", "toolResult":{"modelName":"gpt-image-2", "historicalModelConfirmed":true}}),
] {
super::super::append_agent_db_record(root.path(), audit).unwrap();
}
backfill_project_model_usage_at(root.path(), &config(false, "platform-default")).unwrap();
let saved = records(root.path());
assert_eq!(saved.len(), 1);
assert_eq!(saved[0]["source"], "current-config-backfill");
assert!(saved[0]["modelName"].is_null());
}
#[test]
fn model_usage_history_scan_budget_does_not_prevent_explicit_configuration_backfill() {
let root = tempfile::tempdir().unwrap();
let relative = ".agent/runtime/direct-codex/turns/turn-large.jsonl";
let path = root.path().join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = fs::File::create(&path).unwrap();
file.set_len(MODEL_HISTORY_MAX_BYTES + 1).unwrap();
drop(file);
super::super::append_agent_db_record(
root.path(),
json!({
"recordType": "direct.codex.turn", "clientTurnId": "turn-large", "turnLog": relative
}),
)
.unwrap();
backfill_project_model_usage_at(root.path(), &config(false, "quality")).unwrap();
let saved = records(root.path());
assert_eq!(saved[0]["source"], "current-config-backfill");
assert!(saved[0]["modelName"].is_null());
assert_eq!(saved[0]["historicalModelConfirmed"], false);
assert_eq!(
fs::metadata(path).unwrap().len(),
MODEL_HISTORY_MAX_BYTES + 1
);
}
#[test]
fn model_usage_concurrent_backfill_writes_once_and_preserves_turn_evidence() {
let root = tempfile::tempdir().unwrap();
let handles: Vec<_> = (0..4)
.map(|_| {
let path = root.path().to_path_buf();
std::thread::spawn(move || {
backfill_project_model_usage_at(&path, &config(false, "quality"))
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
assert_eq!(records(root.path()).len(), 1);
record_project_model_response_at(
&context(root.path(), "turn-later", "quality"),
"gpt-current",
Some("response-later"),
)
.unwrap();
let before = fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap();
backfill_project_model_usage_at(root.path(), &config(false, "changed-catalog-id")).unwrap();
assert_eq!(
fs::read(root.path().join(MODEL_USAGE_PATH)).unwrap(),
before
);
assert_eq!(records(root.path()).len(), 2);
}
#[test]
fn model_usage_corrupt_or_oversized_file_is_never_repaired_or_overwritten() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join(".agent")).unwrap();
let path = root.path().join(MODEL_USAGE_PATH);
for original in [b"{broken}\n".as_slice(), b"{\"unfinished\":".as_slice()] {
fs::write(&path, original).unwrap();
assert!(
backfill_project_model_usage_at(root.path(), &config(true, "new-model")).is_err()
);
assert!(record_project_model_request_at(
&context(root.path(), "turn-bad", "quality"),
false
)
.is_err());
assert_eq!(fs::read(&path).unwrap(), original);
}
let file = fs::OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(MODEL_USAGE_MAX_BYTES + 1).unwrap();
drop(file);
assert!(record_project_model_request_at(
&context(root.path(), "turn-big", "quality"),
false
)
.is_err());
assert_eq!(
fs::metadata(&path).unwrap().len(),
MODEL_USAGE_MAX_BYTES + 1
);
}
#[test]
fn model_usage_rejects_response_placeholders_secrets_and_unbounded_fields() {
let root = tempfile::tempdir().unwrap();
let context = context(root.path(), "turn-validation", "quality");
for model in [
"codex-app-server",
"codex-cli",
"platform-default",
"Direct Codex",
"sk-secret",
"https://provider.example/model",
"response body with spaces",
] {
assert!(record_project_model_response_at(&context, model, None).is_err());
}
assert!(record_project_model_response_at(&context, &"m".repeat(257), None).is_err());
assert!(!root.path().join(MODEL_USAGE_PATH).exists());
}
#[test]
fn model_usage_rejects_hard_link_without_touching_external_file() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join(".agent")).unwrap();
let external = outside.path().join("original.jsonl");
fs::write(&external, "preserve external bytes\n").unwrap();
fs::hard_link(&external, root.path().join(MODEL_USAGE_PATH)).unwrap();
assert!(record_project_model_request_at(
&context(root.path(), "turn-link", "quality"),
false
)
.is_err());
assert_eq!(
fs::read_to_string(&external).unwrap(),
"preserve external bytes\n"
);
}
#[cfg(unix)]
#[test]
fn model_usage_rejects_agent_directory_symlink() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join(".agent")).unwrap();
assert!(record_project_model_request_at(
&context(root.path(), "turn-link", "quality"),
false
)
.is_err());
assert!(!outside.path().join("model-usage.jsonl").exists());
}
}