|
11 | 11 | mod codex_oauth_flow; |
12 | 12 | mod headless; |
13 | 13 | mod oauth_flow; |
| 14 | +mod stream_mode; |
14 | 15 | mod upgrade; |
15 | 16 |
|
16 | 17 | use headless::{RunOutcome, SessionBrief}; |
@@ -881,6 +882,31 @@ async fn main() -> anyhow::Result<()> { |
881 | 882 |
|
882 | 883 | // --print / headless mode |
883 | 884 | if is_headless { |
| 885 | + // Long-lived stream-json mode: the Coven runtime stream protocol. |
| 886 | + // Turns arrive as stdin JSON frames; the process exits on stdin EOF. |
| 887 | + // GitHub App runs (--context) never use this path — their prompt |
| 888 | + // comes from the session brief. |
| 889 | + if cli.input_format == CliInputFormat::StreamJson && github_context.is_none() { |
| 890 | + if cli.prompt.is_some() { |
| 891 | + eprintln!( |
| 892 | + "Warning: positional prompt is ignored with --input-format stream-json; \ |
| 893 | + send user frames on stdin." |
| 894 | + ); |
| 895 | + } |
| 896 | + let run = stream_mode::run_stream_loop(stream_mode::StreamLoopParams { |
| 897 | + client, |
| 898 | + tools, |
| 899 | + tool_ctx, |
| 900 | + query_config, |
| 901 | + cost_tracker, |
| 902 | + model: claurst_api::effective_model_for_config(&config, &model_registry), |
| 903 | + resume_id: cli.resume.clone(), |
| 904 | + }) |
| 905 | + .await; |
| 906 | + cron_cancel.cancel(); |
| 907 | + return run; |
| 908 | + } |
| 909 | + |
884 | 910 | // For a coven-github run, configure git auth from COVEN_GIT_TOKEN before |
885 | 911 | // the agent starts. This installs an env-backed credential helper in the |
886 | 912 | // workspace (the token stays in the environment, never on disk) so the |
@@ -1606,75 +1632,29 @@ async fn run_headless( |
1606 | 1632 | use tokio_util::sync::CancellationToken; |
1607 | 1633 |
|
1608 | 1634 | // Build initial messages list from input. |
1609 | | - // --input-format stream-json: stdin is newline-delimited JSON, each line is |
1610 | | - // {"role":"user"|"assistant","content":"..."} (mirrors TS --input-format stream-json). |
1611 | | - // --input-format text (default): read prompt from positional arg or entire stdin as text. |
1612 | | - let mut messages: Vec<claurst_core::types::Message> = |
1613 | | - if cli.input_format == CliInputFormat::StreamJson { |
1614 | | - use tokio::io::{self, AsyncBufReadExt, BufReader}; |
1615 | | - let stdin = io::stdin(); |
1616 | | - let mut reader = BufReader::new(stdin); |
1617 | | - let mut line = String::new(); |
1618 | | - let mut parsed: Vec<claurst_core::types::Message> = Vec::new(); |
1619 | | - loop { |
1620 | | - line.clear(); |
1621 | | - let n = reader.read_line(&mut line).await?; |
1622 | | - if n == 0 { |
1623 | | - break; |
1624 | | - } |
1625 | | - let trimmed = line.trim(); |
1626 | | - if trimmed.is_empty() { |
1627 | | - continue; |
1628 | | - } |
1629 | | - match serde_json::from_str::<serde_json::Value>(trimmed) { |
1630 | | - Ok(v) => { |
1631 | | - let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("user"); |
1632 | | - let content = v |
1633 | | - .get("content") |
1634 | | - .and_then(|c| c.as_str()) |
1635 | | - .unwrap_or("") |
1636 | | - .to_string(); |
1637 | | - if role == "assistant" { |
1638 | | - parsed.push(claurst_core::types::Message::assistant(content)); |
1639 | | - } else { |
1640 | | - parsed.push(claurst_core::types::Message::user(content)); |
1641 | | - } |
1642 | | - } |
1643 | | - Err(e) => { |
1644 | | - eprintln!( |
1645 | | - "Warning: skipping malformed JSON line: {} ({:?})", |
1646 | | - trimmed, e |
1647 | | - ); |
1648 | | - } |
1649 | | - } |
1650 | | - } |
1651 | | - if parsed.is_empty() { |
1652 | | - // Also check positional arg as fallback |
1653 | | - if let Some(ref p) = cli.prompt { |
1654 | | - parsed.push(claurst_core::types::Message::user(p.clone())); |
1655 | | - } |
1656 | | - } |
1657 | | - parsed |
| 1635 | + // --input-format stream-json runs never reach here: main routes them to |
| 1636 | + // stream_mode::run_stream_loop (the long-lived Coven runtime protocol) |
| 1637 | + // before calling run_headless. This path reads the prompt from the |
| 1638 | + // positional arg, the session brief, or stdin as plain text. |
| 1639 | + let mut messages: Vec<claurst_core::types::Message> = { |
| 1640 | + let prompt = if let Some(ref p) = cli.prompt { |
| 1641 | + p.clone() |
| 1642 | + } else if let Some(brief) = github_context { |
| 1643 | + brief.to_prompt() |
1658 | 1644 | } else { |
1659 | | - // Plain text mode |
1660 | | - let prompt = if let Some(ref p) = cli.prompt { |
1661 | | - p.clone() |
1662 | | - } else if let Some(brief) = github_context { |
1663 | | - brief.to_prompt() |
1664 | | - } else { |
1665 | | - use tokio::io::{self, AsyncReadExt}; |
1666 | | - let mut stdin = io::stdin(); |
1667 | | - let mut buf = String::new(); |
1668 | | - stdin.read_to_string(&mut buf).await?; |
1669 | | - buf.trim().to_string() |
1670 | | - }; |
| 1645 | + use tokio::io::{self, AsyncReadExt}; |
| 1646 | + let mut stdin = io::stdin(); |
| 1647 | + let mut buf = String::new(); |
| 1648 | + stdin.read_to_string(&mut buf).await?; |
| 1649 | + buf.trim().to_string() |
| 1650 | + }; |
1671 | 1651 |
|
1672 | | - if prompt.is_empty() { |
1673 | | - anyhow::bail!("No prompt provided. Use --print <prompt> or pipe text to stdin."); |
1674 | | - } |
| 1652 | + if prompt.is_empty() { |
| 1653 | + anyhow::bail!("No prompt provided. Use --print <prompt> or pipe text to stdin."); |
| 1654 | + } |
1675 | 1655 |
|
1676 | | - vec![claurst_core::types::Message::user(prompt)] |
1677 | | - }; |
| 1656 | + vec![claurst_core::types::Message::user(prompt)] |
| 1657 | + }; |
1678 | 1658 |
|
1679 | 1659 | // --prefill: inject a partial assistant turn before the query so the model |
1680 | 1660 | // continues from that text (mirrors TS --prefill flag). |
@@ -5475,4 +5455,130 @@ mod tests { |
5475 | 5455 | // NOTE: the coven-github headless-contract types + behavior moved to the |
5476 | 5456 | // `headless` module; their conformance tests live in `headless.rs` |
5477 | 5457 | // (`#[cfg(test)] mod tests`), pinned to the vendored golden fixtures. |
| 5458 | + |
| 5459 | + // ── coven-runtimes manifest drift guard ───────────────────────────────── |
| 5460 | + // |
| 5461 | + // `spec/runtime-manifest/coven-code.json` is the runtime adapter manifest |
| 5462 | + // accepted into the OpenCoven/coven-runtimes canonical registry. Registry |
| 5463 | + // versions are immutable: if one of these tests fails, a CLI flag the |
| 5464 | + // manifest depends on drifted. Fix the flag or publish a new manifest |
| 5465 | + // version and re-accept it in coven-runtimes (`conjure registry add`) — |
| 5466 | + // do not silently edit the shipped 1.0.0 definition. |
| 5467 | + |
| 5468 | + const RUNTIME_MANIFEST: &str = |
| 5469 | + include_str!("../../../../spec/runtime-manifest/coven-code.json"); |
| 5470 | + |
| 5471 | + fn runtime_adapter() -> coven_runtime_spec::RuntimeAdapter { |
| 5472 | + let manifest = coven_runtime_spec::AdapterManifest::from_json(RUNTIME_MANIFEST) |
| 5473 | + .expect("runtime manifest parses"); |
| 5474 | + assert_eq!(manifest.adapters.len(), 1, "exactly one adapter expected"); |
| 5475 | + manifest.adapters.into_iter().next().expect("one adapter") |
| 5476 | + } |
| 5477 | + |
| 5478 | + #[test] |
| 5479 | + fn runtime_manifest_passes_spec_validation() { |
| 5480 | + let adapter = runtime_adapter(); |
| 5481 | + let errors = coven_runtime_spec::validate_adapter(&adapter); |
| 5482 | + assert!(errors.is_empty(), "spec violations: {errors:?}"); |
| 5483 | + assert_eq!(adapter.id, "coven-code"); |
| 5484 | + assert_eq!(adapter.executable, "coven-code"); |
| 5485 | + } |
| 5486 | + |
| 5487 | + #[test] |
| 5488 | + fn runtime_manifest_declares_full_stream_capabilities() { |
| 5489 | + let caps = runtime_adapter().capabilities; |
| 5490 | + assert!(caps.stream, "stream mode is implemented in stream_mode.rs"); |
| 5491 | + assert!(caps.preassigned_session_id); |
| 5492 | + assert!(caps.think, "think maps to --thinking"); |
| 5493 | + assert!(caps.speed, "speed maps to --effort"); |
| 5494 | + } |
| 5495 | + |
| 5496 | + #[test] |
| 5497 | + fn runtime_manifest_stream_launch_args_parse() { |
| 5498 | + let adapter = runtime_adapter(); |
| 5499 | + let stream = adapter.stream_args.as_ref().expect("stream_args declared"); |
| 5500 | + |
| 5501 | + let mut argv: Vec<String> = vec!["coven-code".into()]; |
| 5502 | + argv.extend(stream.prefix_args.iter().cloned()); |
| 5503 | + let session_flag = stream |
| 5504 | + .session_id_flag |
| 5505 | + .as_deref() |
| 5506 | + .expect("session_id_flag declared"); |
| 5507 | + argv.push(session_flag.to_string()); |
| 5508 | + argv.push("00000000-0000-0000-0000-000000000000".into()); |
| 5509 | + argv.push(adapter.model_flag.clone().expect("model_flag declared")); |
| 5510 | + argv.push("claude-sonnet-4-5".into()); |
| 5511 | + argv.push( |
| 5512 | + adapter |
| 5513 | + .system_prompt_flag |
| 5514 | + .clone() |
| 5515 | + .expect("system_prompt_flag declared"), |
| 5516 | + ); |
| 5517 | + argv.push("identity preamble".into()); |
| 5518 | + |
| 5519 | + let cli = Cli::try_parse_from(&argv).expect("stream launch argv parses"); |
| 5520 | + assert!(cli.print); |
| 5521 | + assert_eq!(cli.input_format, CliInputFormat::StreamJson); |
| 5522 | + assert_eq!(cli.output_format, CliOutputFormat::StreamJson); |
| 5523 | + assert_eq!( |
| 5524 | + cli.session_id_flag.as_deref(), |
| 5525 | + Some("00000000-0000-0000-0000-000000000000") |
| 5526 | + ); |
| 5527 | + } |
| 5528 | + |
| 5529 | + #[test] |
| 5530 | + fn runtime_manifest_resume_flag_parses() { |
| 5531 | + let adapter = runtime_adapter(); |
| 5532 | + let stream = adapter.stream_args.as_ref().expect("stream_args declared"); |
| 5533 | + let resume_flag = stream.resume_flag.as_deref().expect("resume_flag declared"); |
| 5534 | + |
| 5535 | + let mut argv: Vec<String> = vec!["coven-code".into()]; |
| 5536 | + argv.extend(stream.prefix_args.iter().cloned()); |
| 5537 | + argv.push(resume_flag.to_string()); |
| 5538 | + argv.push("11111111-1111-1111-1111-111111111111".into()); |
| 5539 | + |
| 5540 | + let cli = Cli::try_parse_from(&argv).expect("resume argv parses"); |
| 5541 | + assert_eq!( |
| 5542 | + cli.resume.as_deref(), |
| 5543 | + Some("11111111-1111-1111-1111-111111111111") |
| 5544 | + ); |
| 5545 | + } |
| 5546 | + |
| 5547 | + #[test] |
| 5548 | + fn runtime_manifest_sandbox_mapping_parses_for_both_policies() { |
| 5549 | + let adapter = runtime_adapter(); |
| 5550 | + let sandbox = adapter.sandbox.as_ref().expect("sandbox declared"); |
| 5551 | + |
| 5552 | + for (permission, expected) in [ |
| 5553 | + ( |
| 5554 | + coven_runtime_spec::Permission::Full, |
| 5555 | + CliPermissionMode::BypassPermissions, |
| 5556 | + ), |
| 5557 | + ( |
| 5558 | + coven_runtime_spec::Permission::ReadOnly, |
| 5559 | + CliPermissionMode::Plan, |
| 5560 | + ), |
| 5561 | + ] { |
| 5562 | + let mut argv: Vec<String> = vec!["coven-code".into()]; |
| 5563 | + argv.extend(adapter.non_interactive_prompt_prefix_args.iter().cloned()); |
| 5564 | + argv.extend(sandbox.args(permission)); |
| 5565 | + argv.push("do the thing".into()); |
| 5566 | + |
| 5567 | + let cli = Cli::try_parse_from(&argv) |
| 5568 | + .unwrap_or_else(|e| panic!("{permission:?} sandbox argv rejected: {e}")); |
| 5569 | + assert_eq!(cli.permission_mode, expected); |
| 5570 | + } |
| 5571 | + } |
| 5572 | + |
| 5573 | + #[test] |
| 5574 | + fn runtime_manifest_one_shot_launch_parses() { |
| 5575 | + let adapter = runtime_adapter(); |
| 5576 | + let mut argv: Vec<String> = vec!["coven-code".into()]; |
| 5577 | + argv.extend(adapter.non_interactive_prompt_prefix_args.iter().cloned()); |
| 5578 | + argv.push("hello".into()); |
| 5579 | + |
| 5580 | + let cli = Cli::try_parse_from(&argv).expect("one-shot argv parses"); |
| 5581 | + assert!(cli.print); |
| 5582 | + assert_eq!(cli.prompt.as_deref(), Some("hello")); |
| 5583 | + } |
5478 | 5584 | } |
0 commit comments