From 5d305e93fb395f9fcc9cd29022fa9b07c9bded81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 23 Sep 2026 08:06:59 +0000 Subject: [PATCH 1/2] chore: sync public mirror from internal --- .repository-projection.json | 6 +- README.md | 17 + packages/local-host-rs/src/credential_mode.rs | 2 +- packages/maestro-rs/src/main.rs | 2 +- packages/tui-rs/src/cli_commands.rs | 2 + packages/tui-rs/src/entrypoint.rs | 3 +- packages/tui-rs/src/lib.rs | 1 + packages/tui-rs/src/thread_cli.rs | 911 ++++++++++++++++++ 8 files changed, 938 insertions(+), 6 deletions(-) create mode 100644 packages/tui-rs/src/thread_cli.rs diff --git a/.repository-projection.json b/.repository-projection.json index 6f1087341..2a473b30b 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "883bd85c592bbfc7ac95992291f8b02e87011ecd", + "sourceSha": "1727afbfb234f32fb7b0300a78a074dd96994d67", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "6ede82720df7af1028a4791aeb49593e5c9c7bbc", + "priorProjectedBase": "85879e61077a7fd73f0dedcfd21e7c06e7f2f280", "definitionDigest": "82936441c776e3e8edb5d215a75007ec9714a233f489d460075d79d5ef5ba32f", "toolDigest": "45502ff0478e541d02f34cc39ac332935f8d3c0030fa221afe5bcc3b5e51f88e", - "contentDigest": "cd333a61496a0fd449c9b897ac7dec59e0302939c30d209e47898db02ae480d9", + "contentDigest": "6d75f98142a29d2bcc0ff7bd13cd92424b13e73e9622eba5e65550596c6a4ec3", "publicationEligible": true } diff --git a/README.md b/README.md index 12a7050b2..1dcf75a37 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,23 @@ deixic-code serve --port 3000 # HTTP runtime gateway deixic-code hosted-runner ``` +To continue a hosted Dex thread from the terminal, sign in with +`deixic-code evalops login`, then attach using its `thread:` id: + +```sh +deixic-code thread attach thread:example +deixic-code thread attach thread:example --message "Check the latest result" --json +``` + +The terminal reads the Platform-owned thread, submits messages through the +same tenant-scoped owner API as the Deixic UI, and shows safe execution events. +When a turn needs approval or input, use `/respond [text]` +after reviewing the request. The CLI never grants an approval automatically. +`remote attach` is a separate RunnerSession transport and does not attach to a +Dex thread. A disconnected client can reattach without restarting the resident +turn. The one-shot form stops following after two minutes and leaves accepted +work running on Platform. + For a multi-feature coding task, prepare a mission contract and run it through the native workflow scheduler. See [Run a local coding mission](docs/MISSION_RUN.md). diff --git a/packages/local-host-rs/src/credential_mode.rs b/packages/local-host-rs/src/credential_mode.rs index 1326ad04a..831d5ca2b 100644 --- a/packages/local-host-rs/src/credential_mode.rs +++ b/packages/local-host-rs/src/credential_mode.rs @@ -437,7 +437,7 @@ fn current_verified_identity_session_with_env() /// Load and verify the current human Identity session for a product-owned /// child operation such as Session History ingestion. -pub(crate) fn verified_current_identity_session() -> Result { +pub fn verified_current_identity_session() -> Result { crate::safety::require_vendor_network()?; let env = std::env::vars().collect::>(); let snapshot = if platform_session_from(None, &env).is_some() { diff --git a/packages/maestro-rs/src/main.rs b/packages/maestro-rs/src/main.rs index c63ebbaa6..3c0febdd0 100644 --- a/packages/maestro-rs/src/main.rs +++ b/packages/maestro-rs/src/main.rs @@ -11,7 +11,7 @@ fn pre_main_hardening() { maestro_tui::process_hardening::pre_main_hardening(); } -const HELP: &str = "Deixic Code\n\nUsage:\n deixic-code [options] [prompt]\n deixic-code exec \n deixic-code -w [prompt]\n deixic-code doctor [--json] [--live] [--verbose] [--timeout ] [probe groups]\n deixic-code setup [--json] [--live] [--model ]\n deixic-code --list-tools [--json]\n deixic-code --headless\n deixic-code serve [--port ] [--parent-pid | --liveness-fd ]\n deixic-code hosted-runner [options]\n\nCommon commands:\n deixic-code setup Check authentication and show the next setup step\n deixic-code config Inspect or change Deixic Code configuration\n deixic-code experiments Review or change tool experiment participation\n deixic-code models List available models and providers\n deixic-code sessions List, export, or import saved sessions\n deixic-code scenario Run or inspect scripted scenarios\n deixic-code run Inspect or replay a saved session\n\nAdvanced commands:\n deixic-code diagnostics Project compiler JSONL, optionally against a prior observation\n deixic-code codex Manage Codex authentication and app-server access\n deixic-code mcp Configure MCP servers\n deixic-code plugins Inspect and manage plugins\n deixic-code connections Manage API keys and delegated accounts\n deixic-code init Register a Deixic agent integration\n\nCompatibility:\n maestro remains available as an alias; MAESTRO_* variables and .maestro paths are unchanged.\n\nOptions:\n -w, --worktree Run the session in a new git worktree at ../-wt-\n on a new branch; interactive worktrees and branches are kept\n exec removes only clean worktrees with no new commits\n\nThe product runtime is native Rust; no Node.js or Bun runtime is required."; +const HELP: &str = "Deixic Code\n\nUsage:\n deixic-code [options] [prompt]\n deixic-code exec \n deixic-code -w [prompt]\n deixic-code doctor [--json] [--live] [--verbose] [--timeout ] [probe groups]\n deixic-code setup [--json] [--live] [--model ]\n deixic-code --list-tools [--json]\n deixic-code --headless\n deixic-code serve [--port ] [--parent-pid | --liveness-fd ]\n deixic-code hosted-runner [options]\n\nCommon commands:\n deixic-code setup Check authentication and show the next setup step\n deixic-code config Inspect or change Deixic Code configuration\n deixic-code experiments Review or change tool experiment participation\n deixic-code models List available models and providers\n deixic-code sessions List, export, or import saved sessions\n deixic-code thread Attach to a hosted Dex thread\n deixic-code scenario Run or inspect scripted scenarios\n deixic-code run Inspect or replay a saved session\n\nAdvanced commands:\n deixic-code diagnostics Project compiler JSONL, optionally against a prior observation\n deixic-code codex Manage Codex authentication and app-server access\n deixic-code mcp Configure MCP servers\n deixic-code plugins Inspect and manage plugins\n deixic-code connections Manage API keys and delegated accounts\n deixic-code init Register a Deixic agent integration\n\nCompatibility:\n maestro remains available as an alias; MAESTRO_* variables and .maestro paths are unchanged.\n\nOptions:\n -w, --worktree Run the session in a new git worktree at ../-wt-\n on a new branch; interactive worktrees and branches are kept\n exec removes only clean worktrees with no new commits\n\nThe product runtime is native Rust; no Node.js or Bun runtime is required."; const VERSION: &str = concat!("deixic-code ", env!("CARGO_PKG_VERSION")); fn sync_command_output(command: &Command) -> Option<&'static str> { diff --git a/packages/tui-rs/src/cli_commands.rs b/packages/tui-rs/src/cli_commands.rs index d96a75af8..6cd253f4a 100644 --- a/packages/tui-rs/src/cli_commands.rs +++ b/packages/tui-rs/src/cli_commands.rs @@ -39,6 +39,7 @@ pub async fn run_cli_command(args: &[String]) -> Result { | "openai" | "codex" | "remote" + | "thread" | "computer" | "orb" | "operating-plane" @@ -131,6 +132,7 @@ pub async fn run_cli_command(args: &[String]) -> Result { Ok(1) } "remote" => crate::remote_cli::run_remote(&args[1..]).await, + "thread" => crate::thread_cli::run_thread(&args[1..]).await, "value" => crate::value_cli::run_value(&args[1..]).await, "scenario" => crate::scenario_cli::run_scenario(&args[1..]).await, "codex" => crate::codex_cli::run_codex(&args[1..]).await, diff --git a/packages/tui-rs/src/entrypoint.rs b/packages/tui-rs/src/entrypoint.rs index 5a9ff0dfa..3eeb06374 100644 --- a/packages/tui-rs/src/entrypoint.rs +++ b/packages/tui-rs/src/entrypoint.rs @@ -58,7 +58,7 @@ mod shutdown_signal; /// utility handler instead of the interactive TUI, headless server, or /// exec/print bridges; `packages/maestro-rs` no longer keeps an independent /// copy of this list (see `maestro::cli::classify`). -pub const NATIVE_UTILITY_COMMANDS: [&str; 45] = [ +pub const NATIVE_UTILITY_COMMANDS: [&str; 46] = [ "acp", "sessions", "search", @@ -91,6 +91,7 @@ pub const NATIVE_UTILITY_COMMANDS: [&str; 45] = [ "evalops", "operating-plane", "remote", + "thread", "value", "scenario", "codex", diff --git a/packages/tui-rs/src/lib.rs b/packages/tui-rs/src/lib.rs index 6b487a7c5..6e44ff0cd 100644 --- a/packages/tui-rs/src/lib.rs +++ b/packages/tui-rs/src/lib.rs @@ -232,6 +232,7 @@ pub mod rubber_duck; pub mod run_cli; pub mod scenario_cli; pub mod search_cli; +pub mod thread_cli; pub mod update_cli; pub mod value_cli; pub use maestro_local_host::video; diff --git a/packages/tui-rs/src/thread_cli.rs b/packages/tui-rs/src/thread_cli.rs new file mode 100644 index 000000000..947f23fff --- /dev/null +++ b/packages/tui-rs/src/thread_cli.rs @@ -0,0 +1,911 @@ +//! Attach the native terminal to a Platform-owned Dex operating thread. +//! +//! The wire projections below retain only the fields this client reads. Their +//! field numbers come from proto/console/v1/console.proto. Unknown fields stay +//! opaque; Platform remains the sole owner of execution and approval state. + +use std::collections::{HashMap, HashSet}; +use std::io::{self, IsTerminal, Write}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use maestro_local_host::credential_mode::{PlatformSession, verified_current_identity_session}; +use prost::Message; +use reqwest::{Client, Url}; +use serde::Serialize; +use tokio::time::sleep; +use uuid::Uuid; + +const SERVICE: &str = "/deixic.v1.DeixicService"; +const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const FOLLOW_LIMIT: Duration = Duration::from_secs(120); +const USAGE: &str = "Usage: deixic-code thread attach [--message ] [--json] [--base-url ]\n\ +The thread id is a Deixic thread id (thread:...). A managed Deixic login is required.\n\ +Interactive commands: /quit, /respond [text]."; + +// A narrow wire projection of console.v1. Field numbers and kinds mirror the +// canonical protobuf contract; unknown fields are ignored by prost. +#[derive(Clone, PartialEq, Message)] +struct Query { + #[prost(string, tag = "1")] + workspace_id: String, + #[prost(string, tag = "13")] + organization_id: String, +} + +#[derive(Clone, PartialEq, Message)] +struct GetRequest { + #[prost(message, optional, tag = "1")] + query: Option, + #[prost(string, tag = "2")] + channel_id: String, + #[prost(int32, tag = "3")] + limit: i32, +} + +#[derive(Clone, PartialEq, Message)] +struct Channel { + #[prost(string, tag = "1")] + id: String, +} + +#[derive(Clone, PartialEq, Message)] +struct OperatingMessage { + #[prost(string, tag = "1")] + id: String, + #[prost(string, tag = "3")] + role: String, + #[prost(string, tag = "5")] + body: String, +} + +#[derive(Clone, PartialEq, Message)] +struct GetResponse { + #[prost(message, optional, tag = "1")] + channel: Option, + #[prost(message, repeated, tag = "2")] + messages: Vec, + #[prost(int64, tag = "7")] + replay_cursor: i64, +} + +#[derive(Clone, PartialEq, Message)] +struct ListRequest { + #[prost(message, optional, tag = "1")] + query: Option, + #[prost(string, tag = "2")] + channel_id: String, + #[prost(int64, tag = "3")] + after_cursor: i64, + #[prost(int32, tag = "4")] + limit: i32, +} + +#[derive(Clone, PartialEq, Message)] +struct Event { + #[prost(int64, tag = "1")] + cursor: i64, + #[prost(string, tag = "2")] + event_id: String, + #[prost(string, tag = "3")] + turn_id: String, + #[prost(int32, tag = "4")] + kind: i32, + #[prost(string, tag = "5")] + safe_text: String, + #[prost(string, tag = "9")] + request_id: String, + #[prost(int32, tag = "10")] + request_type: i32, + #[prost(string, tag = "12")] + request_call_id: String, +} + +#[derive(Clone, PartialEq, Message)] +struct ListResponse { + #[prost(message, repeated, tag = "1")] + events: Vec, + #[prost(int64, tag = "2")] + next_cursor: i64, + #[prost(bool, tag = "3")] + has_more: bool, + #[prost(bool, tag = "4")] + reset_required: bool, +} + +#[derive(Clone, PartialEq, Message)] +struct SubmitRequest { + #[prost(message, optional, tag = "1")] + query: Option, + #[prost(string, tag = "2")] + channel_id: String, + #[prost(string, tag = "3")] + body: String, + #[prost(string, tag = "4")] + idempotency_key: String, +} + +#[derive(Clone, PartialEq, Message)] +struct Turn { + #[prost(string, tag = "1")] + turn_id: String, +} + +#[derive(Clone, PartialEq, Message)] +struct SubmitResponse { + #[prost(message, optional, tag = "6")] + accepted_turn: Option, + #[prost(int64, tag = "7")] + replay_cursor: i64, +} + +#[derive(Clone, PartialEq, Message)] +struct ThreadResponse { + #[prost(string, tag = "1")] + request_id: String, + #[prost(string, tag = "2")] + call_id: String, + #[prost(int32, tag = "3")] + request_type: i32, + #[prost(int32, tag = "4")] + action: i32, + #[prost(string, tag = "5")] + text: String, + #[prost(string, tag = "7")] + idempotency_key: String, +} + +#[derive(Clone, PartialEq, Message)] +struct RespondRequest { + #[prost(message, optional, tag = "1")] + query: Option, + #[prost(string, tag = "2")] + channel_id: String, + #[prost(string, tag = "3")] + turn_id: String, + #[prost(message, optional, tag = "4")] + response: Option, + #[prost(string, tag = "5")] + idempotency_key: String, +} + +#[derive(Clone, PartialEq, Message)] +struct RespondResponse { + #[prost(int64, tag = "5")] + replay_cursor: i64, +} + +#[derive(Debug)] +struct Options { + channel_id: String, + message: Option, + json: bool, + base_url: Option, +} + +fn parse(args: &[String]) -> Result> { + if args.is_empty() + || args + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + return Ok(None); + } + if args.first().map(String::as_str) != Some("attach") { + bail!("unknown thread command; {USAGE}"); + } + let mut id = None; + let mut message = None; + let mut json = false; + let mut base_url = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--message" | "--base-url" => { + let value = args.get(i + 1).context("option value required")?.clone(); + if args[i] == "--message" { + message = Some(value); + } else { + base_url = Some(value); + } + i += 2; + } + "--json" => { + json = true; + i += 1; + } + value if value.starts_with('-') => bail!("unknown option: {value}"), + value if id.is_none() => { + id = Some(value.to_owned()); + i += 1; + } + _ => bail!("only one thread id is allowed"), + } + } + let id: String = id.context("thread id required")?; + let channel_id = if id.starts_with("thread:") { + id + } else { + format!("thread:{id}") + }; + if channel_id.len() > 256 + || channel_id == "thread:" + || channel_id.chars().any(char::is_whitespace) + { + bail!("invalid Deixic thread id"); + } + if message + .as_ref() + .is_some_and(|body| body.trim().is_empty() || body.len() > 20_000) + { + bail!("message must contain 1 to 20000 bytes"); + } + if json && message.is_none() { + bail!("--json requires --message"); + } + Ok(Some(Options { + channel_id, + message, + json, + base_url, + })) +} + +struct ThreadClient { + http: Client, + base: Url, + session: PlatformSession, + channel_id: String, +} + +impl ThreadClient { + fn new(session: PlatformSession, channel_id: String, base_url: &str) -> Result { + let base = Url::parse(base_url).context("invalid Platform URL")?; + if !matches!(base.scheme(), "https" | "http") + || base.host_str().is_none() + || (base.scheme() == "http" + && !matches!(base.host_str(), Some("127.0.0.1" | "localhost" | "::1"))) + { + bail!("Platform URL must use HTTPS or loopback HTTP"); + } + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { + http, + base, + session, + channel_id, + }) + } + + fn query(&self) -> Result { + let workspace_id = self + .session + .workspace_id + .as_ref() + .filter(|id| !id.trim().is_empty()) + .context("managed login must select a workspace")? + .clone(); + Ok(Query { + workspace_id, + organization_id: self.session.organization_id.clone(), + }) + } + + async fn call( + &self, + method: &str, + request: Req, + ) -> Result { + let url = self.base.join(&format!("{SERVICE}/{method}"))?; + let workspace = self.query()?.workspace_id; + let mut response = self + .http + .post(url) + .bearer_auth(&self.session.access_token) + .header("X-Organization-ID", &self.session.organization_id) + .header("X-Workspace-ID", workspace) + .header("Connect-Protocol-Version", "1") + .header("Content-Type", "application/proto") + .header("Accept", "application/proto") + .body(request.encode_to_vec()) + .send() + .await + .context("Platform request failed")?; + let status = response.status(); + if !status.is_success() { + bail!("Platform {method} returned HTTP {status}"); + } + if response + .content_length() + .is_some_and(|size| size as usize > MAX_RESPONSE_BYTES) + { + bail!("Platform response exceeds limit"); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + bail!("Platform response exceeds limit"); + } + bytes.extend_from_slice(&chunk); + } + Resp::decode(bytes.as_slice()).context("invalid Platform protobuf response") + } + + async fn get(&self) -> Result { + let result: GetResponse = self + .call( + "GetOperatingThread", + GetRequest { + query: Some(self.query()?), + channel_id: self.channel_id.clone(), + limit: 50, + }, + ) + .await?; + if result + .channel + .as_ref() + .is_none_or(|channel| channel.id != self.channel_id) + { + bail!("Platform returned a different or missing thread"); + } + Ok(result) + } + + async fn events(&self, cursor: i64) -> Result { + self.call( + "ListOperatingThreadEvents", + ListRequest { + query: Some(self.query()?), + channel_id: self.channel_id.clone(), + after_cursor: cursor, + limit: 200, + }, + ) + .await + } + + async fn submit(&self, body: String) -> Result { + if body.trim().is_empty() || body.len() > 20_000 { + bail!("message must contain 1 to 20000 bytes"); + } + let result: SubmitResponse = self + .call( + "SubmitOperatingMessage", + SubmitRequest { + query: Some(self.query()?), + channel_id: self.channel_id.clone(), + body, + idempotency_key: Uuid::new_v4().to_string(), + }, + ) + .await?; + if result + .accepted_turn + .as_ref() + .is_none_or(|turn| turn.turn_id.is_empty()) + { + bail!("Platform did not return an accepted turn"); + } + Ok(result) + } + + async fn respond(&self, pending: &Event, action: i32, text: String) -> Result { + let key = Uuid::new_v4().to_string(); + self.call( + "RespondOperatingThread", + RespondRequest { + query: Some(self.query()?), + channel_id: self.channel_id.clone(), + turn_id: pending.turn_id.clone(), + response: Some(ThreadResponse { + request_id: pending.request_id.clone(), + call_id: pending.request_call_id.clone(), + request_type: pending.request_type, + action, + text, + idempotency_key: key.clone(), + }), + idempotency_key: key, + }, + ) + .await + } +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum Output<'a> { + Message { + role: &'a str, + body: &'a str, + }, + Event { + cursor: i64, + turn_id: &'a str, + kind: i32, + text: &'a str, + request_id: &'a str, + }, + Accepted { + turn_id: &'a str, + }, + Waiting { + turn_id: &'a str, + }, +} + +fn emit(output: Output<'_>, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string(&output)?); + } else { + match output { + Output::Message { role, body } => println!("{role}: {body}"), + Output::Event { + kind, + text, + request_id, + .. + } => { + if !text.is_empty() { + println!("{text}"); + } + if !request_id.is_empty() { + println!( + "Request {request_id} (kind {kind}); use /respond after reviewing it." + ); + } + } + Output::Accepted { turn_id } => println!("Accepted turn {turn_id}"), + Output::Waiting { turn_id } => { + println!("Turn {turn_id} is still running. Reattach to continue watching.") + } + } + } + Ok(()) +} + +async fn follow( + client: &ThreadClient, + mut cursor: i64, + turn_id: &str, + json: bool, + pending: &mut HashMap, + known_messages: &mut HashSet, +) -> Result { + let deadline = Instant::now() + FOLLOW_LIMIT; + loop { + let page = client.events(cursor).await?; + if page.reset_required { + let snapshot = client.get().await?; + cursor = snapshot.replay_cursor; + for message in &snapshot.messages { + if known_messages.insert(message.id.clone()) { + emit( + Output::Message { + role: &message.role, + body: &message.body, + }, + json, + )?; + } + } + } else { + if page.next_cursor < cursor { + bail!("Platform event cursor moved backwards"); + } + let before = cursor; + for event in &page.events { + if event.cursor <= cursor { + continue; + } + cursor = event.cursor; + if !event.request_id.is_empty() && matches!(event.kind, 4 | 5 | 14 | 15) { + pending.insert(event.request_id.clone(), event.clone()); + } + emit( + Output::Event { + cursor: event.cursor, + turn_id: &event.turn_id, + kind: event.kind, + text: &event.safe_text, + request_id: &event.request_id, + }, + json, + )?; + if event.turn_id == turn_id && matches!(event.kind, 7..=9) { + pending.retain(|_, request| request.turn_id != turn_id); + let snapshot = client.get().await?; + for message in &snapshot.messages { + if known_messages.insert(message.id.clone()) { + emit( + Output::Message { + role: &message.role, + body: &message.body, + }, + json, + )?; + } + } + return Ok(cursor); + } + if event.turn_id == turn_id && matches!(event.kind, 4 | 5 | 14 | 15) { + return Ok(cursor); + } + } + cursor = cursor.max(page.next_cursor); + if page.has_more { + if cursor == before { + bail!("Platform event page made no progress"); + } + continue; + } + } + if Instant::now() >= deadline { + emit(Output::Waiting { turn_id }, json)?; + return Ok(cursor); + } + sleep(Duration::from_secs(1)).await; + } +} + +fn parse_response(line: &str) -> Result<(&str, i32, String)> { + let mut parts = line.splitn(4, ' '); + if parts.next() != Some("/respond") { + bail!("not a response command"); + } + let request_id = parts.next().context("request id required")?; + let action = match parts.next().context("action required")? { + "approve" => 1, + "deny" => 2, + "answer" => 3, + "retry" => 4, + "skip" => 5, + "abort" => 6, + other => bail!("unknown action: {other}"), + }; + let text = parts.next().unwrap_or("").to_owned(); + if action == 3 && text.trim().is_empty() { + bail!("answer text required"); + } + if text.len() > 65_536 { + bail!("response text exceeds 65536 bytes"); + } + Ok((request_id, action, text)) +} + +pub async fn run_thread(args: &[String]) -> Result { + let Some(options) = parse(args)? else { + println!("{USAGE}"); + return Ok(0); + }; + maestro_local_host::safety::require_vendor_network()?; + let session = verified_current_identity_session()?; + let base_url = options + .base_url + .or_else(maestro_local_host::managed_setup::platform_base_url) + .context("Platform URL unavailable")?; + let client = ThreadClient::new(session, options.channel_id, &base_url)?; + let snapshot = client.get().await?; + let mut known_messages = HashSet::new(); + for message in &snapshot.messages { + known_messages.insert(message.id.clone()); + emit( + Output::Message { + role: &message.role, + body: &message.body, + }, + options.json, + )?; + } + let mut pending = HashMap::new(); + if let Some(body) = options.message { + let submitted = client.submit(body).await?; + let turn = submitted.accepted_turn.context("accepted turn missing")?; + emit( + Output::Accepted { + turn_id: &turn.turn_id, + }, + options.json, + )?; + follow( + &client, + snapshot.replay_cursor, + &turn.turn_id, + options.json, + &mut pending, + &mut known_messages, + ) + .await?; + return Ok(0); + } + if !io::stdin().is_terminal() || options.json { + bail!("interactive attach requires a terminal; pass --message for one-shot use"); + } + // Recover recent owner-issued requests when attaching to a turn that was + // already waiting before this terminal opened. Platform still validates + // request identity and current generation when a response is submitted. + let mut history_cursor = snapshot.replay_cursor.saturating_sub(200); + for _ in 0..10 { + let page = client.events(history_cursor).await?; + if page.reset_required { + break; + } + for event in page.events { + if !event.request_id.is_empty() && matches!(event.kind, 4 | 5 | 14 | 15) { + pending.insert(event.request_id.clone(), event.clone()); + } + if matches!(event.kind, 7..=9) { + pending.retain(|_, request| request.turn_id != event.turn_id); + } + } + if !page.has_more || page.next_cursor <= history_cursor { + break; + } + history_cursor = page.next_cursor; + } + for event in pending.values() { + println!( + "Waiting request {} for turn {}: {}", + event.request_id, event.turn_id, event.safe_text + ); + } + let mut cursor = snapshot.replay_cursor; + loop { + print!("deixic> "); + io::stdout().flush()?; + let mut line = String::new(); + if io::stdin().read_line(&mut line)? == 0 { + break; + } + let line = line.trim(); + if line.is_empty() { + continue; + } + if matches!(line, "/quit" | "/exit") { + break; + } + if line.starts_with("/respond ") { + let (request_id, action, text) = parse_response(line)?; + let event = pending + .get(request_id) + .context("unknown pending request id")?; + let turn_id = event.turn_id.clone(); + let response = client.respond(event, action, text).await?; + pending.remove(request_id); + cursor = follow( + &client, + cursor.min(response.replay_cursor), + &turn_id, + false, + &mut pending, + &mut known_messages, + ) + .await?; + continue; + } + if line.starts_with('/') { + eprintln!("{USAGE}"); + continue; + } + let submitted = client.submit(line.to_owned()).await?; + let turn = submitted.accepted_turn.context("accepted turn missing")?; + emit( + Output::Accepted { + turn_id: &turn.turn_id, + }, + false, + )?; + cursor = follow( + &client, + cursor, + &turn.turn_id, + false, + &mut pending, + &mut known_messages, + ) + .await?; + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + #[test] + fn parse_thread_attach() { + let options = parse(&[ + "attach".into(), + "abc".into(), + "--message".into(), + "hello".into(), + ]) + .unwrap() + .unwrap(); + assert_eq!(options.channel_id, "thread:abc"); + assert_eq!(options.message.as_deref(), Some("hello")); + assert!(parse(&["attach".into(), "thread:".into()]).is_err()); + assert!(parse(&["attach".into(), "a".into(), "b".into()]).is_err()); + } + + #[test] + fn response_requires_explicit_action() { + assert_eq!(parse_response("/respond req-1 deny").unwrap().1, 2); + assert!(parse_response("/respond req-1 answer").is_err()); + assert!(parse_response("/respond req-1 yes").is_err()); + } + + #[test] + fn refuses_missing_tenant_and_plaintext_remote_origin() { + let session = PlatformSession { + access_token: "token".into(), + organization_id: "org-a".into(), + workspace_id: None, + provider_ref: serde_json::Value::Null, + email: None, + user_id: Some("user-a".into()), + }; + assert!( + ThreadClient::new(session.clone(), "thread:one".into(), "http://example.com").is_err() + ); + let client = + ThreadClient::new(session, "thread:one".into(), "https://app.deixic.com").unwrap(); + assert!(client.query().is_err()); + } + + #[tokio::test] + async fn typed_owner_calls_keep_tenant_and_request_identity() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + for method in [ + "GetOperatingThread", + "SubmitOperatingMessage", + "ListOperatingThreadEvents", + "RespondOperatingThread", + ] { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let body_offset; + let body_length; + loop { + let mut chunk = [0u8; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0, "request closed before body"); + bytes.extend_from_slice(&chunk[..n]); + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]).to_ascii_lowercase(); + assert!(headers.contains(&format!( + "/deixic.v1.deixicservice/{}", + method.to_ascii_lowercase() + ))); + assert!(headers.contains("authorization: bearer scoped-token")); + assert!(headers.contains("x-organization-id: org-a")); + assert!(headers.contains("x-workspace-id: ws-a")); + assert!(headers.contains("content-type: application/proto")); + body_length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse::() + .unwrap(); + body_offset = end + 4; + break; + } + } + while bytes.len() - body_offset < body_length { + let mut chunk = [0u8; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0); + bytes.extend_from_slice(&chunk[..n]); + } + let body = &bytes[body_offset..body_offset + body_length]; + let result = match method { + "GetOperatingThread" => { + let request = GetRequest::decode(body).unwrap(); + assert_eq!(request.query.unwrap().organization_id, "org-a"); + assert_eq!(request.channel_id, "thread:one"); + GetResponse { + channel: Some(Channel { + id: "thread:one".into(), + }), + messages: vec![OperatingMessage { + id: "msg-1".into(), + role: "user".into(), + body: "prior".into(), + }], + replay_cursor: 9, + } + .encode_to_vec() + } + "SubmitOperatingMessage" => { + let request = SubmitRequest::decode(body).unwrap(); + assert_eq!(request.query.unwrap().workspace_id, "ws-a"); + assert_eq!(request.channel_id, "thread:one"); + assert_eq!(request.body, "continue"); + assert!(!request.idempotency_key.is_empty()); + SubmitResponse { + accepted_turn: Some(Turn { + turn_id: "turn-2".into(), + }), + replay_cursor: 10, + } + .encode_to_vec() + } + "ListOperatingThreadEvents" => { + let request = ListRequest::decode(body).unwrap(); + assert_eq!(request.after_cursor, 9); + ListResponse { + events: vec![Event { + cursor: 10, + event_id: "event-10".into(), + turn_id: "turn-2".into(), + kind: 4, + safe_text: "Review the action".into(), + request_id: "request-1".into(), + request_type: 1, + request_call_id: "call-1".into(), + }], + next_cursor: 10, + has_more: false, + reset_required: false, + } + .encode_to_vec() + } + "RespondOperatingThread" => { + let request = RespondRequest::decode(body).unwrap(); + assert_eq!(request.query.unwrap().organization_id, "org-a"); + assert_eq!(request.turn_id, "turn-2"); + let response = request.response.unwrap(); + assert_eq!(response.request_id, "request-1"); + assert_eq!(response.action, 2); + assert_eq!(response.idempotency_key, request.idempotency_key); + RespondResponse { replay_cursor: 11 }.encode_to_vec() + } + _ => unreachable!(), + }; + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/proto\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + result.len() + ); + socket.write_all(header.as_bytes()).await.unwrap(); + socket.write_all(&result).await.unwrap(); + } + }); + + let client = ThreadClient::new( + PlatformSession { + access_token: "scoped-token".into(), + organization_id: "org-a".into(), + workspace_id: Some("ws-a".into()), + provider_ref: serde_json::Value::Null, + email: None, + user_id: Some("user-a".into()), + }, + "thread:one".into(), + &base, + ) + .unwrap(); + let snapshot = client.get().await.unwrap(); + assert_eq!(snapshot.messages[0].body, "prior"); + let submitted = client.submit("continue".into()).await.unwrap(); + assert_eq!(submitted.accepted_turn.unwrap().turn_id, "turn-2"); + let page = client.events(9).await.unwrap(); + assert_eq!(page.events[0].safe_text, "Review the action"); + assert_eq!( + client + .respond(&page.events[0], 2, String::new()) + .await + .unwrap() + .replay_cursor, + 11 + ); + server.await.unwrap(); + } +} From 2e482efb0164b1c08cdedfe5e15106cbe2fc54ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 23 Sep 2026 08:34:50 +0000 Subject: [PATCH 2/2] chore: sync public mirror from internal --- .repository-projection.json | 4 +- CHANGELOG.md | 38 ++++++ Cargo.lock | 2 +- package-lock.json | 4 +- package.json | 2 +- packages/ai-rs/src/openai.rs | 124 +++++++++++++----- packages/maestro-rs/Cargo.toml | 2 +- .../src/components/deixic_logo.rs | 13 +- packages/runtime-rs/src/agent/native/tests.rs | 2 +- packages/tui-rs/src/thread_cli.rs | 4 +- packages/ui-preview-rs/src/lib.rs | 4 +- scripts/tag-release-contract.test.mjs | 6 +- 12 files changed, 154 insertions(+), 51 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 2a473b30b..e189dde04 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "1727afbfb234f32fb7b0300a78a074dd96994d67", + "sourceSha": "06b74765cc5e7353134d868de4140e69fa634f38", "destinationRepository": "dx-corp/code", "priorProjectedBase": "85879e61077a7fd73f0dedcfd21e7c06e7f2f280", "definitionDigest": "82936441c776e3e8edb5d215a75007ec9714a233f489d460075d79d5ef5ba32f", "toolDigest": "45502ff0478e541d02f34cc39ac332935f8d3c0030fa221afe5bcc3b5e51f88e", - "contentDigest": "6d75f98142a29d2bcc0ff7bd13cd92424b13e73e9622eba5e65550596c6a4ec3", + "contentDigest": "65e63c3da4396f8ae48cd156df59c171580f0159ba3d1d943b9fe667879f9856", "publicationEligible": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c586c714..37ef43e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,44 @@ versioning when releases are cut. and keep scheduled public runs inert so public publishing stays downstream of the internal source-of-truth release. +## [0.10.94] - 2026-09-23 + +### Added + +- Attach CLI to hosted Dex threads (#10258). +- Use Dex orbit across terminal and desktop (#10250). +- Refine Dex orb across desktop and terminal (#10264). + +### Changed + +- Expect budget terminal after spent fallback turn (#10259). +- Release v0.10.93 with Dex orbit (#10255). +- Update dtolnay/rust-toolchain requirement to 02cb101ec7c40f2c49e1d9714d64511d8e1b74de (#10221). +- Refresh bundled model catalog (#10254). +- Bump lucide-react in /products/maestro/desktop (#10224). +- Offload small verdict jobs to desktop (#10249). +- Require accepted source ref for execution workspace creation (#10245). +- Borrow synthetic credential fixture for scan (#10238). +- Reject overflowing canary timeout values (#10237). +- Cover execution workspace admission fencing (#10232). +- Create execution workspace on project task admission (#10229). +- Enumerate bounded state-machine histories (#10228). + +### Fixed + +- Repair provider request borrows (#10262). +- Dispatch Maestro release without gh CLI (#10263). +- Keep incomplete provider usage unknown (#10261). +- Satisfy Clippy for thread attach (#10260). +- Reject chat streams without a finish reason (#10257). +- Remove redundant provider request borrows (#10256). +- Stop model fallback after turn budget exhaustion (#10248). +- Cap hosted retry delay after jitter (#10247). +- End final controller turn without retry wake (#10253). +- Attest credential references at provider boundary (#10252). +- Scope Mission test constructors (#10251). +- Stop retries after turn step budget exhaustion (#10246). + ## [0.10.93] - 2026-09-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5fa08db34..79af83e25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3738,7 +3738,7 @@ checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "maestro" -version = "0.10.93" +version = "0.10.94" dependencies = [ "anyhow", "ctor", diff --git a/package-lock.json b/package-lock.json index 3f3f57205..c76687eb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@evalops/maestro", - "version": "0.10.93", + "version": "0.10.94", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@evalops/maestro", - "version": "0.10.93", + "version": "0.10.94", "license": "BUSL-1.1", "bin": { "deixic-code": "bin/deixic-code", diff --git a/package.json b/package.json index 3cbe80764..24e603496 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@evalops/deixic-code", "description": "Deixic Code — native Rust coding agent, CLI, TUI, and runtime gateway", - "version": "0.10.93", + "version": "0.10.94", "private": false, "type": "module", "bin": { diff --git a/packages/ai-rs/src/openai.rs b/packages/ai-rs/src/openai.rs index 84124460c..cb1e77058 100644 --- a/packages/ai-rs/src/openai.rs +++ b/packages/ai-rs/src/openai.rs @@ -2734,12 +2734,10 @@ impl OpenAiClient { if let Some(usage) = resp.get("usage") { let input = usage .get("input_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); + .and_then(serde_json::Value::as_u64); let output = usage .get("output_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); + .and_then(serde_json::Value::as_u64); // Extract cached tokens from input_tokens_details let cache_read = usage .get("input_tokens_details") @@ -2768,16 +2766,18 @@ impl OpenAiClient { details.get("cache_write_tokens") }) .and_then(serde_json::Value::as_u64); - let _ = tx.send(StreamEvent::Usage { - input_tokens: uncached_input_tokens( - input, - cache_read, - cache_write, - ), - output_tokens: output, - cache_read_tokens: cache_read, - cache_creation_tokens: cache_write, - }); + if let (Some(input), Some(output)) = (input, output) { + let _ = tx.send(StreamEvent::Usage { + input_tokens: uncached_input_tokens( + input, + cache_read, + cache_write, + ), + output_tokens: output, + cache_read_tokens: cache_read, + cache_creation_tokens: cache_write, + }); + } } } let _ = tx.send(StreamEvent::MessageStop { stop_reason: None }); @@ -3067,28 +3067,28 @@ impl OpenAiClient { let _ = tx.send(StreamEvent::ProviderCost { cost_usd }); } - let _ = tx.send(StreamEvent::Usage { - input_tokens: uncached_input_tokens( - usage.prompt_tokens.unwrap_or(0), - usage - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens), - usage - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cache_write_tokens), - ), - output_tokens: usage.completion_tokens.unwrap_or(0), - cache_read_tokens: usage + if let (Some(input), Some(output)) = + (usage.prompt_tokens, usage.completion_tokens) + { + let cache_read = usage .prompt_tokens_details .as_ref() - .and_then(|d| d.cached_tokens), - cache_creation_tokens: usage + .and_then(|d| d.cached_tokens); + let cache_write = usage .prompt_tokens_details .as_ref() - .and_then(|d| d.cache_write_tokens), - }); + .and_then(|d| d.cache_write_tokens); + let _ = tx.send(StreamEvent::Usage { + input_tokens: uncached_input_tokens( + input, + cache_read, + cache_write, + ), + output_tokens: output, + cache_read_tokens: cache_read, + cache_creation_tokens: cache_write, + }); + } } } } @@ -4149,6 +4149,66 @@ data: {"type":"response.completed","response":{"output":[{"type":"message","cont } } + #[tokio::test] + async fn managed_chat_partial_usage_does_not_become_zero_usage() { + for usage in ["{}", "{\"prompt_tokens\":9}", "{\"completion_tokens\":4}"] { + let sse = format!( + "data: {{\"id\":\"chat-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5.6\",\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"ok\"}},\"finish_reason\":\"stop\"}}]}}\n\ndata: {{\"id\":\"chat-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5.6\",\"choices\":[],\"usage\":{usage}}}\n\ndata: [DONE]\n\n" + ); + let (mut client, _request_rx) = + managed_gateway_test_client(&sse, &managed_receipt_headers()); + let mut authorization: serde_json::Value = + serde_json::from_str(&managed_authorization_fixture("lineage-receipt")).unwrap(); + authorization["claims"]["endpoint"] = "chat.completions".into(); + client.set_managed_inference_authorization(Some(authorization.to_string())); + let stream = client + .stream( + &[], + &RequestConfig { + model: "gpt-5.6".into(), + ..Default::default() + }, + ) + .await + .unwrap(); + let events = collect_stream_events(stream).await; + assert!( + !events + .iter() + .any(|event| matches!(event, StreamEvent::Usage { .. })), + "partial usage must remain unknown: {events:?}" + ); + } + } + + #[tokio::test] + async fn managed_responses_partial_usage_does_not_become_zero_usage() { + let sse = "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}],\"usage\":{\"input_tokens\":9}}}\n\n"; + let (mut client, _request_rx) = + managed_gateway_test_client(sse, &managed_receipt_headers()); + let mut authorization: serde_json::Value = + serde_json::from_str(&managed_authorization_fixture("lineage-receipt")).unwrap(); + authorization["claims"]["endpoint"] = "responses".into(); + client.set_managed_inference_authorization(Some(authorization.to_string())); + let stream = client + .stream( + &[], + &RequestConfig { + model: "gpt-5.6".into(), + ..Default::default() + }, + ) + .await + .unwrap(); + let events = collect_stream_events(stream).await; + assert!( + !events + .iter() + .any(|event| matches!(event, StreamEvent::Usage { .. })), + "partial usage must remain unknown: {events:?}" + ); + } + #[tokio::test] async fn managed_endpoint_selects_url_body_and_stream_parser_over_catalog() { for (endpoint, model, sse, path, field) in [ diff --git a/packages/maestro-rs/Cargo.toml b/packages/maestro-rs/Cargo.toml index ce2997eab..aedfb0627 100644 --- a/packages/maestro-rs/Cargo.toml +++ b/packages/maestro-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maestro" -version = "0.10.93" +version = "0.10.94" edition = "2021" license = "MIT" description = "Canonical native Rust CLI for Deixic Code" diff --git a/packages/presentation-rs/src/components/deixic_logo.rs b/packages/presentation-rs/src/components/deixic_logo.rs index 43337b172..5e334d5a7 100644 --- a/packages/presentation-rs/src/components/deixic_logo.rs +++ b/packages/presentation-rs/src/components/deixic_logo.rs @@ -17,15 +17,15 @@ pub const PRODUCT_TITLE: &str = "Dex Code"; /// Empty-composer hint shared by the welcome and status surfaces. pub const COMPOSER_HINT: &str = "Type a message or press ? for commands."; -/// A transparent dot silhouette: two negative-space eyes, a tiny nub at right, -/// and a blue arc across the lower edge. Each glyph occupies one terminal cell. +/// A transparent dot silhouette with staggered negative-space eyes and a blue +/// arc across the lower edge. Each glyph occupies one terminal cell. pub const LOGO_FULL: &str = r" ·••●••· ·••●••●•●•••· ·•●••●•●••●•●•••· •••●•●••●•●••●•●•• - •●•●••● ●● ●●●●•●• - •••●•●••●•●••●•●••••• + •●•●••● ●●●•●●●●•●• + •••●•●••●•● ••●•••• •●••●•●••●•●••●••· ·•●••●•●••●•●••· ·•●••●•●•••· @@ -642,9 +642,10 @@ mod tests { } #[test] - fn full_mark_keeps_orb_eyes_and_nub() { + fn full_mark_keeps_round_orb_and_staggered_eyes() { assert_eq!(logo_lines(LOGO_FULL).len(), 10); - assert!(LOGO_FULL.contains(" ●● ")); + assert!(logo_lines(LOGO_FULL)[4].contains(" ●●")); + assert!(logo_lines(LOGO_FULL)[5].contains("● •")); assert!(LOGO_FULL.contains("•••")); assert_eq!(logo_lines(LOGO_COMPACT).len(), 4); } diff --git a/packages/runtime-rs/src/agent/native/tests.rs b/packages/runtime-rs/src/agent/native/tests.rs index 5a393ad96..3e8e46d83 100644 --- a/packages/runtime-rs/src/agent/native/tests.rs +++ b/packages/runtime-rs/src/agent/native/tests.rs @@ -3304,7 +3304,7 @@ async fn exhausted_one_step_turn_does_not_switch_to_unreachable_fallback() { .await .expect("one-step turn must terminate promptly"); agent.shutdown().await; - assert!(terminal.contains("Agent error"), "{terminal}"); + assert!(terminal.contains("step_budget_exhausted"), "{terminal}"); } #[tokio::test] diff --git a/packages/tui-rs/src/thread_cli.rs b/packages/tui-rs/src/thread_cli.rs index 947f23fff..62699fcf6 100644 --- a/packages/tui-rs/src/thread_cli.rs +++ b/packages/tui-rs/src/thread_cli.rs @@ -18,7 +18,7 @@ use uuid::Uuid; const SERVICE: &str = "/deixic.v1.DeixicService"; const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; -const FOLLOW_LIMIT: Duration = Duration::from_secs(120); +const FOLLOW_LIMIT: Duration = Duration::from_mins(2); const USAGE: &str = "Usage: deixic-code thread attach [--message ] [--json] [--base-url ]\n\ The thread id is a Deixic thread id (thread:...). A managed Deixic login is required.\n\ Interactive commands: /quit, /respond [text]."; @@ -461,7 +461,7 @@ fn emit(output: Output<'_>, json: bool) -> Result<()> { } Output::Accepted { turn_id } => println!("Accepted turn {turn_id}"), Output::Waiting { turn_id } => { - println!("Turn {turn_id} is still running. Reattach to continue watching.") + println!("Turn {turn_id} is still running. Reattach to continue watching."); } } } diff --git a/packages/ui-preview-rs/src/lib.rs b/packages/ui-preview-rs/src/lib.rs index 8680ab03c..ca1fc297d 100644 --- a/packages/ui-preview-rs/src/lib.rs +++ b/packages/ui-preview-rs/src/lib.rs @@ -193,7 +193,7 @@ fn legacy_render(scene: &Scene) -> Result { SessionHeaderWidget::new(Some("~/projects/maestro"), Some("main")) .with_context(Some(9500), Some(500000)) .render(Rect::new(0, 0, area.width, 1), &mut buf); - } else if scene.id == "startup" || action.is_some() || scene.id == "pet" { + } else if scene.id == "startup" { // The startup title has no animation; the explicit clock controls the portrait. render_welcome_with_summary( area, @@ -205,6 +205,8 @@ fn legacy_render(scene: &Scene) -> Result { ); render_welcome_portrait(area, &mut buf, look, state, motion); } else { + // Appearance stories use the live companion renderer. The shared + // startup mark is intentionally independent of cosmetic choices. let companion = DexCompanion::new(state) .look(look) .personality(personality) diff --git a/scripts/tag-release-contract.test.mjs b/scripts/tag-release-contract.test.mjs index 2bd504caa..6171fb290 100644 --- a/scripts/tag-release-contract.test.mjs +++ b/scripts/tag-release-contract.test.mjs @@ -28,8 +28,10 @@ test("internal tag-release dispatches public-release-mirror after creating a tag assert.match(internalWorkflow, /dispatch-public-release-mirror:/); assert.match(internalWorkflow, /github\.repository == 'dx-corp\/mono'/); assert.match(internalWorkflow, /needs\.tag-current-version\.outputs\.tag_exists != 'true'/); - assert.match(internalWorkflow, /gh workflow run maestro-public-release-mirror\.yml/); - assert.match(internalWorkflow, /--field "publish_npm=false"/); + assert.match(internalWorkflow, /dispatch maestro-public-release-mirror\.yml/); + assert.match(internalWorkflow, /dispatch maestro-release\.yml/); + assert.match(internalWorkflow, /curl --config - --fail-with-body/); + assert.match(internalWorkflow, /"publish_npm":"false"/); assert.match(internalWorkflow, /resolve-maestro-version-commit\.py/); assert.match(internalWorkflow, /tag-target-sha: \$\{\{ steps\.tag-target\.outputs\.commit \}\}/); });