fix(backend): identité requester explicite + policy des tools OpenAI-compatible (#62)

AgentSessionFactory::start propage désormais l'identité du requester aux
sessions structurées ; OpenTicketAssistant et LaunchAgent la portent
correctement de bout en bout. ToolPolicyRegistry est branché sur
AppOpenAiToolInvoker pour combler le trou de parité : TicketToolProvider
n'appliquait pas la policy des tools sur le chemin OpenAI-compatible,
contrairement au chemin structuré natif.

QA vert (échecs de bind loopback écartés comme non-régression préexistante,
tracés en #80).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:35:17 +02:00
parent a7abd331b8
commit b4a34b40e5
15 changed files with 555 additions and 19 deletions

View File

@ -136,6 +136,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
@ -150,11 +151,9 @@ impl AgentSessionFactory for StructuredSessionFactory {
let command = profile.command.clone();
let cwd = cwd.as_str().to_owned();
let seed = seed_conversation_id(session);
let requester = std::path::Path::new(&cwd)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned();
let requester = requester
.map(str::to_owned)
.unwrap_or_else(|| fallback_requester_from_cwd(&cwd));
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
@ -216,3 +215,70 @@ impl AgentSessionFactory for StructuredSessionFactory {
Ok(session)
}
}
fn fallback_requester_from_cwd(cwd: &str) -> String {
std::path::Path::new(cwd)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned()
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use serde_json::json;
use super::*;
#[derive(Default)]
struct RecordingToolInvoker {
call: Mutex<Option<(String, String)>>,
}
#[async_trait]
impl ToolInvoker for RecordingToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
Vec::new()
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
*self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned()));
Ok("ok".to_owned())
}
}
#[tokio::test]
async fn project_scoped_tool_invoker_injects_explicit_requester_identity() {
let recorder = Arc::new(RecordingToolInvoker::default());
let invoker = ProjectScopedToolInvoker {
inner: recorder.clone(),
project_root: "/project/root".to_owned(),
requester: "ticket-assistant:project:7".to_owned(),
};
let out = invoker
.call("idea_ticket_update", r##"{"ref":"#7"}"##)
.await
.expect("tool call ok");
assert_eq!(out, "ok");
let (name, args_json) = recorder.call.lock().unwrap().clone().expect("recorded");
assert_eq!(name, "idea_ticket_update");
let args: Value = serde_json::from_str(&args_json).unwrap();
assert_eq!(
args,
json!({
"ref": "#7",
PROJECT_ROOT_ARG: "/project/root",
REQUESTER_ARG: "ticket-assistant:project:7",
})
);
}
#[test]
fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() {
assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7");
}
}

View File

@ -510,6 +510,7 @@ mod tests {
&prepared_ctx(),
&cwd(),
&SessionPlan::None,
None,
&[],
None,
)
@ -527,6 +528,7 @@ mod tests {
&prepared_ctx(),
&cwd(),
&SessionPlan::None,
None,
&[],
None,
)
@ -558,6 +560,7 @@ mod tests {
&prepared_ctx(),
&temp_cwd("factory-openai"),
&SessionPlan::None,
None,
&[],
None,
)
@ -589,7 +592,7 @@ mod tests {
};
let session = factory
.start(&codex, &ctx, &cwd(), &SessionPlan::None, &[], None)
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None, &[], None)
.await
.expect("start Codex ok");
let content = drain_final(session.as_ref()).await;
@ -623,6 +626,7 @@ mod tests {
&SessionPlan::Resume {
conversation_id: "repris-42".to_owned(),
},
None,
&[],
None,
)
@ -1437,6 +1441,7 @@ mod tests {
&SessionPlan::Resume {
conversation_id: "cx-resume".to_owned(),
},
None,
&[],
None,
)
@ -1461,6 +1466,7 @@ mod tests {
&SessionPlan::Assign {
conversation_id: "ignored-by-engine".to_owned(),
},
None,
&[],
None,
)
@ -1487,7 +1493,15 @@ mod tests {
)
.expect("profil valide");
match factory
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, &[], None)
.start(
&tui,
&prepared_ctx(),
&cwd(),
&SessionPlan::None,
None,
&[],
None,
)
.await
{
Err(AgentSessionError::Start(_)) => {}

View File

@ -470,7 +470,15 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
let session = factory
.start(&profile, &ctx, &cwd, &SessionPlan::None, &[], Some(&plan))
.start(
&profile,
&ctx,
&cwd,
&SessionPlan::None,
None,
&[],
Some(&plan),
)
.await
.expect("start sandboxé ok");

View File

@ -480,6 +480,7 @@ impl AgentSessionFactory for BlockingReplyFactory {
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {