1use gate4agent_types::{
2 AdapterId, LaunchSpec, SessionOptionSelection, SessionOptionValue,
3 PROVIDER_EVENT_TEXT_MAX_BYTES,
4};
5use thiserror::Error;
6
7pub const ONE_SHOT_REVISION: &str = "gate4agent-one-shot/orca-d8629c4/v2";
8pub const CLAUDE_CODE_INLINE_REVISION: &str = "gate4agent-inline/claude-code-2.1/v1";
9pub const CODEX_CLI_INLINE_REVISION: &str = "gate4agent-inline/codex-cli-0.144/v1";
10pub const KIMI_CODE_INLINE_REVISION: &str = "gate4agent-inline/kimi-code-0.31/v1";
11pub const ONE_SHOT_OUTPUT_MAX_BYTES: usize = 4 * 1024 * 1024;
12pub const ONE_SHOT_TIMEOUT_SECONDS: u64 = 60;
13pub const ONE_SHOT_THINKING_OPTION_ID: &str = "thinking-level";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum OneShotPromptDelivery {
17 StdinClose,
18 Positional,
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OneShotModelSource {
23 Static,
24 Dynamic,
25}
26
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28pub enum OneShotSessionPersistence {
29 #[default]
30 Ephemeral,
31 Persist,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct OneShotThinkingLevel {
36 pub id: String,
37 pub label: String,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct OneShotModelSpec {
42 pub id: String,
43 pub label: String,
44 pub thinking_levels: Vec<OneShotThinkingLevel>,
45 pub default_thinking_level: Option<String>,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct OneShotAdapterSpec {
50 pub adapter_id: AdapterId,
51 pub label: String,
52 pub prompt_delivery: OneShotPromptDelivery,
53 pub model_source: OneShotModelSource,
54 pub models: Vec<OneShotModelSpec>,
55 pub default_model_id: String,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct OneShotPlan {
60 pub program: String,
61 pub args: Vec<String>,
62 pub stdin_payload: Option<String>,
63 pub label: String,
64 pub applied: SessionOptionSelection,
65}
66
67pub fn one_shot_specs() -> Vec<OneShotAdapterSpec> {
68 ["claude", "codex", "kimi"]
69 .into_iter()
70 .map(|id| spec_for(id).expect("hardcoded one-shot adapter"))
71 .collect()
72}
73
74pub fn one_shot_spec(adapter_id: &AdapterId) -> Result<OneShotAdapterSpec, OneShotAdapterError> {
75 spec_for(adapter_id.as_str())
76}
77
78pub fn resolve_one_shot_plan(
79 adapter_id: &AdapterId,
80 launch: &LaunchSpec,
81 prompt: &str,
82 selection: Option<&SessionOptionSelection>,
83) -> Result<OneShotPlan, OneShotAdapterError> {
84 resolve_one_shot_plan_with_persistence(
85 adapter_id,
86 launch,
87 prompt,
88 selection,
89 OneShotSessionPersistence::Ephemeral,
90 )
91}
92
93pub fn resolve_one_shot_plan_with_persistence(
94 adapter_id: &AdapterId,
95 launch: &LaunchSpec,
96 prompt: &str,
97 selection: Option<&SessionOptionSelection>,
98 persistence: OneShotSessionPersistence,
99) -> Result<OneShotPlan, OneShotAdapterError> {
100 if prompt.trim().is_empty()
101 || prompt.len() > PROVIDER_EVENT_TEXT_MAX_BYTES
102 || prompt.contains('\0')
103 {
104 return Err(OneShotAdapterError::InvalidPrompt);
105 }
106 if launch.program.trim().is_empty() || launch.program.contains('\0') {
107 return Err(OneShotAdapterError::InvalidLaunch);
108 }
109 if launch.fixed_args.iter().any(|arg| arg.contains('\0')) {
110 return Err(OneShotAdapterError::InvalidLaunch);
111 }
112
113 let spec = one_shot_spec(adapter_id)?;
114 if persistence == OneShotSessionPersistence::Persist && adapter_id.as_str() != "codex" {
115 return Err(OneShotAdapterError::PersistentSessionsUnsupported(
116 adapter_id.as_str().to_owned(),
117 ));
118 }
119 let (model, thinking) = resolve_selection(&spec, selection)?;
120 let mut args = launch.fixed_args.clone();
121 match adapter_id.as_str() {
122 "claude" => {
123 args.extend(strings(&[
124 "-p",
125 "--output-format",
126 "text",
127 "--model",
128 &model,
129 "--permission-mode",
130 "plan",
131 ]));
132 if let Some(thinking) = &thinking {
133 args.extend(["--effort".to_owned(), thinking.clone()]);
134 }
135 }
136 "codex" => {
137 args.push("exec".to_owned());
138 if persistence == OneShotSessionPersistence::Ephemeral {
139 args.push("--ephemeral".to_owned());
140 }
141 args.extend(strings(&[
142 "--skip-git-repo-check", "-s", "read-only", "--model", &model,
143 ]));
144 if let Some(thinking) = &thinking {
145 args.extend([
146 "-c".to_owned(),
147 format!("model_reasoning_effort={thinking}"),
148 ]);
149 }
150 }
151 "kimi" => {
152 args.extend(strings(&["-p", prompt, "--output-format", "text"]));
153 }
154 id => return Err(OneShotAdapterError::UnsupportedAdapter(id.to_owned())),
155 }
156
157 let mut applied = SessionOptionSelection::new(model);
158 if let Some(thinking) = thinking {
159 applied
160 .values
161 .insert(ONE_SHOT_THINKING_OPTION_ID.to_owned(), thinking.into());
162 }
163 Ok(OneShotPlan {
164 program: launch.program.clone(),
165 args,
166 stdin_payload: (spec.prompt_delivery == OneShotPromptDelivery::StdinClose)
167 .then(|| prompt.to_owned()),
168 label: spec.label,
169 applied,
170 })
171}
172
173fn resolve_selection(
174 spec: &OneShotAdapterSpec,
175 selection: Option<&SessionOptionSelection>,
176) -> Result<(String, Option<String>), OneShotAdapterError> {
177 if let Some(selection) = selection {
178 selection
179 .validate()
180 .map_err(|_| OneShotAdapterError::InvalidSelection)?;
181 if selection
182 .values
183 .keys()
184 .any(|key| key != ONE_SHOT_THINKING_OPTION_ID)
185 {
186 return Err(OneShotAdapterError::UnknownOption);
187 }
188 }
189 let model_id = selection
190 .map(|selection| selection.model.clone())
191 .unwrap_or_else(|| spec.default_model_id.clone());
192 let known = spec.models.iter().find(|model| model.id == model_id);
193 if known.is_none() && spec.model_source == OneShotModelSource::Static {
194 return Err(OneShotAdapterError::UnknownModel(model_id));
195 }
196 let requested_thinking = selection
197 .and_then(|selection| selection.values.get(ONE_SHOT_THINKING_OPTION_ID))
198 .map(|value| match value {
199 SessionOptionValue::String(value) => Ok(value.clone()),
200 SessionOptionValue::Boolean(_) => Err(OneShotAdapterError::InvalidSelection),
201 })
202 .transpose()?;
203 let default_thinking = known
204 .and_then(|model| model.default_thinking_level.clone())
205 .or_else(|| {
206 (spec.model_source == OneShotModelSource::Dynamic
207 && supports_openai_thinking(&model_id))
208 .then(|| "low".to_owned())
209 });
210 let thinking = requested_thinking.or(default_thinking);
211 if let (Some(model), Some(thinking)) = (known, &thinking) {
212 if !model.thinking_levels.is_empty()
213 && !model
214 .thinking_levels
215 .iter()
216 .any(|level| &level.id == thinking)
217 {
218 return Err(OneShotAdapterError::UnknownThinkingLevel(thinking.clone()));
219 }
220 if model.thinking_levels.is_empty() && spec.model_source == OneShotModelSource::Static {
221 return Err(OneShotAdapterError::ThinkingUnsupported(model.id.clone()));
222 }
223 }
224 Ok((model_id, thinking))
225}
226
227fn spec_for(id: &str) -> Result<OneShotAdapterSpec, OneShotAdapterError> {
228 let (label, delivery, source, models, default_model_id) = match id {
229 "claude" => (
230 "Claude",
231 OneShotPromptDelivery::StdinClose,
232 OneShotModelSource::Static,
233 vec![
234 model("haiku", "Haiku", &[], None),
235 model("sonnet", "Sonnet", &claude_thinking(), Some("low")),
236 model("opus", "Opus", &claude_thinking(), Some("low")),
237 ],
238 "sonnet",
239 ),
240 "codex" => (
241 "Codex",
242 OneShotPromptDelivery::StdinClose,
243 OneShotModelSource::Dynamic,
244 [
245 "gpt-5.5",
246 "gpt-5.4",
247 "gpt-5.4-mini",
248 "gpt-5.3-codex",
249 "gpt-5.3-codex-spark",
250 "gpt-5.2",
251 ]
252 .into_iter()
253 .map(|id| model(id, id, &openai_thinking(), Some("low")))
254 .collect(),
255 "gpt-5.5",
256 ),
257 "kimi" => (
258 "Kimi Code",
259 OneShotPromptDelivery::Positional,
260 OneShotModelSource::Static,
261 vec![model("default", "Provider default", &[], None)],
262 "default",
263 ),
264 id => return Err(OneShotAdapterError::UnsupportedAdapter(id.to_owned())),
265 };
266 Ok(OneShotAdapterSpec {
267 adapter_id: AdapterId::new(id).expect("hardcoded adapter ID"),
268 label: label.to_owned(),
269 prompt_delivery: delivery,
270 model_source: source,
271 models,
272 default_model_id: default_model_id.to_owned(),
273 })
274}
275
276fn model(
277 id: &str,
278 label: &str,
279 thinking: &[(&str, &str)],
280 default_thinking: Option<&str>,
281) -> OneShotModelSpec {
282 OneShotModelSpec {
283 id: id.to_owned(),
284 label: label.to_owned(),
285 thinking_levels: thinking
286 .iter()
287 .map(|(id, label)| OneShotThinkingLevel {
288 id: (*id).to_owned(),
289 label: (*label).to_owned(),
290 })
291 .collect(),
292 default_thinking_level: default_thinking.map(str::to_owned),
293 }
294}
295
296fn openai_thinking() -> [(&'static str, &'static str); 4] {
297 [
298 ("low", "Low"),
299 ("medium", "Medium"),
300 ("high", "High"),
301 ("xhigh", "Extra High"),
302 ]
303}
304
305fn claude_thinking() -> [(&'static str, &'static str); 5] {
306 [
307 ("low", "Low"),
308 ("medium", "Medium"),
309 ("high", "High"),
310 ("xhigh", "Extra High"),
311 ("max", "Max"),
312 ]
313}
314
315fn supports_openai_thinking(id: &str) -> bool {
316 let id = id.to_ascii_lowercase();
317 id.contains("gpt-5") || id.contains("codex")
318}
319
320fn strings(values: &[&str]) -> Vec<String> {
321 values.iter().map(|value| (*value).to_owned()).collect()
322}
323
324#[derive(Clone, Debug, Error, Eq, PartialEq)]
325pub enum OneShotAdapterError {
326 #[error("unsupported one-shot adapter '{0}'")]
327 UnsupportedAdapter(String),
328 #[error("one-shot prompt is empty, contains NUL, or exceeds its bound")]
329 InvalidPrompt,
330 #[error("one-shot launch contract is invalid")]
331 InvalidLaunch,
332 #[error("one-shot session-option selection is invalid")]
333 InvalidSelection,
334 #[error("one-shot selection contains an unknown option")]
335 UnknownOption,
336 #[error("one-shot model '{0}' is not declared by the provider adapter")]
337 UnknownModel(String),
338 #[error("one-shot model '{0}' does not support thinking effort")]
339 ThinkingUnsupported(String),
340 #[error("one-shot thinking level '{0}' is not declared by the model")]
341 UnknownThinkingLevel(String),
342 #[error("one-shot adapter '{0}' does not support persistent sessions")]
343 PersistentSessionsUnsupported(String),
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::collections::BTreeSet;
350
351 fn launch(program: &str) -> LaunchSpec {
352 LaunchSpec {
353 program: program.to_owned(),
354 fixed_args: vec!["fixed".to_owned()],
355 }
356 }
357
358 #[test]
359 fn one_shot_inventory_includes_current_target_contracts() {
360 let specs = one_shot_specs();
361 let actual = specs
362 .iter()
363 .map(|spec| spec.adapter_id.as_str())
364 .collect::<BTreeSet<_>>();
365 assert_eq!(actual, ["claude", "codex", "kimi"].into_iter().collect());
366 assert_eq!(
367 specs
368 .iter()
369 .find(|spec| spec.adapter_id.as_str() == "codex")
370 .unwrap()
371 .default_model_id,
372 "gpt-5.5"
373 );
374 }
375
376 #[test]
377 fn exact_provider_plans_preserve_prompt_delivery_and_safety_flags() {
378 let claude = resolve_one_shot_plan(
379 &AdapterId::new("claude").unwrap(),
380 &launch("claude-custom"),
381 "large prompt",
382 None,
383 )
384 .unwrap();
385 assert_eq!(claude.program, "claude-custom");
386 assert_eq!(claude.stdin_payload.as_deref(), Some("large prompt"));
387 assert!(claude
388 .args
389 .windows(2)
390 .any(|pair| pair == ["--permission-mode", "plan"]));
391 assert!(claude
392 .args
393 .windows(2)
394 .any(|pair| pair == ["--effort", "low"]));
395
396 let codex = resolve_one_shot_plan(
397 &AdapterId::new("codex").unwrap(),
398 &launch("codex"),
399 "prompt",
400 Some(
401 &SessionOptionSelection::new("gpt-5.4")
402 .with_value(ONE_SHOT_THINKING_OPTION_ID, "xhigh"),
403 ),
404 )
405 .unwrap();
406 assert!(codex.args.contains(&"--ephemeral".to_owned()));
407 assert!(codex.args.contains(&"read-only".to_owned()));
408 assert!(codex
409 .args
410 .contains(&"model_reasoning_effort=xhigh".to_owned()));
411
412 let kimi = resolve_one_shot_plan(
413 &AdapterId::new("kimi").unwrap(),
414 &launch("kimi"),
415 "prompt in argv",
416 None,
417 )
418 .unwrap();
419 assert_eq!(kimi.stdin_payload, None);
420 assert_eq!(
421 kimi.args,
422 ["fixed", "-p", "prompt in argv", "--output-format", "text"]
423 );
424 }
425
426 #[test]
427 fn codex_persistence_is_explicit_and_preserves_the_exact_safe_plan() {
428 assert_eq!(
429 OneShotSessionPersistence::default(),
430 OneShotSessionPersistence::Ephemeral
431 );
432 let adapter_id = AdapterId::new("codex").unwrap();
433 let selection = SessionOptionSelection::new("gpt-5.4")
434 .with_value(ONE_SHOT_THINKING_OPTION_ID, "xhigh");
435 let legacy = resolve_one_shot_plan(
436 &adapter_id,
437 &launch("codex"),
438 "prompt",
439 Some(&selection),
440 )
441 .unwrap();
442 let ephemeral = resolve_one_shot_plan_with_persistence(
443 &adapter_id,
444 &launch("codex"),
445 "prompt",
446 Some(&selection),
447 OneShotSessionPersistence::Ephemeral,
448 )
449 .unwrap();
450 assert_eq!(legacy, ephemeral);
451
452 let persistent = resolve_one_shot_plan_with_persistence(
453 &adapter_id,
454 &launch("codex"),
455 "prompt",
456 Some(&selection),
457 OneShotSessionPersistence::Persist,
458 )
459 .unwrap();
460 assert_eq!(
461 persistent.args,
462 [
463 "fixed",
464 "exec",
465 "--skip-git-repo-check",
466 "-s",
467 "read-only",
468 "--model",
469 "gpt-5.4",
470 "-c",
471 "model_reasoning_effort=xhigh",
472 ]
473 );
474 assert_eq!(persistent.stdin_payload.as_deref(), Some("prompt"));
475 }
476
477 #[test]
478 fn persistent_sessions_fail_closed_for_non_codex_adapters() {
479 assert!(matches!(
480 resolve_one_shot_plan_with_persistence(
481 &AdapterId::new("claude").unwrap(),
482 &launch("claude"),
483 "prompt",
484 None,
485 OneShotSessionPersistence::Persist,
486 ),
487 Err(OneShotAdapterError::PersistentSessionsUnsupported(id)) if id == "claude"
488 ));
489 }
490
491 #[test]
492 fn every_registered_provider_builds_its_default_execution_path() {
493 let expected_flag = [
494 ("claude", "--permission-mode"),
495 ("codex", "--ephemeral"),
496 ("kimi", "-p"),
497 ];
498 for (id, flag) in expected_flag {
499 let plan = resolve_one_shot_plan(
500 &AdapterId::new(id).unwrap(),
501 &launch(&format!("{id}-binary")),
502 "provider prompt",
503 None,
504 )
505 .unwrap();
506 assert_eq!(plan.program, format!("{id}-binary"));
507 assert_eq!(plan.args.first().map(String::as_str), Some("fixed"));
508 assert!(plan.args.iter().any(|arg| arg == flag), "{id}: {flag}");
509 let delivery = one_shot_spec(&AdapterId::new(id).unwrap())
510 .unwrap()
511 .prompt_delivery;
512 assert_eq!(
513 plan.stdin_payload.is_some(),
514 delivery == OneShotPromptDelivery::StdinClose,
515 "{id}"
516 );
517 }
518 }
519
520 #[test]
521 fn static_models_fail_closed_while_dynamic_models_remain_discovery_compatible() {
522 assert!(matches!(
523 resolve_one_shot_plan(
524 &AdapterId::new("claude").unwrap(),
525 &launch("claude"),
526 "prompt",
527 Some(&SessionOptionSelection::new("unknown")),
528 ),
529 Err(OneShotAdapterError::UnknownModel(_))
530 ));
531 let dynamic = resolve_one_shot_plan(
532 &AdapterId::new("codex").unwrap(),
533 &launch("codex"),
534 "prompt",
535 Some(&SessionOptionSelection::new("vendor/new-model")),
536 )
537 .unwrap();
538 assert!(dynamic.args.contains(&"vendor/new-model".to_owned()));
539 }
540}