diff --git a/code-rs/app-server-protocol/schema/json/EventMsg.json b/code-rs/app-server-protocol/schema/json/EventMsg.json index 660385b08a0..643041a241d 100644 --- a/code-rs/app-server-protocol/schema/json/EventMsg.json +++ b/code-rs/app-server-protocol/schema/json/EventMsg.json @@ -147,6 +147,75 @@ ], "type": "string" }, + "AutomationOrigin": { + "properties": { + "actor": { + "description": "Actor reported by the source system as applying the trigger.", + "type": [ + "string", + "null" + ] + }, + "event_id": { + "description": "GitHub event delivery id, webhook id, or local request id.", + "type": [ + "string", + "null" + ] + }, + "issue_number": { + "description": "GitHub issue or pull request number associated with the trigger.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/AutomationTriggerKind" + }, + "label": { + "description": "Label that triggered automation, such as `every-code`.", + "type": [ + "string", + "null" + ] + }, + "repository": { + "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Tool, worker, or integration that launched this automated session.", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "AutomationTriggerKind": { + "enum": [ + "github_label", + "other" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -893,6 +962,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" @@ -6149,6 +6229,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" diff --git a/code-rs/app-server-protocol/schema/json/ServerNotification.json b/code-rs/app-server-protocol/schema/json/ServerNotification.json index 8aec5661e0c..fe6d669b59a 100644 --- a/code-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/code-rs/app-server-protocol/schema/json/ServerNotification.json @@ -331,6 +331,75 @@ ], "type": "string" }, + "AutomationOrigin": { + "properties": { + "actor": { + "description": "Actor reported by the source system as applying the trigger.", + "type": [ + "string", + "null" + ] + }, + "event_id": { + "description": "GitHub event delivery id, webhook id, or local request id.", + "type": [ + "string", + "null" + ] + }, + "issue_number": { + "description": "GitHub issue or pull request number associated with the trigger.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/AutomationTriggerKind" + }, + "label": { + "description": "Label that triggered automation, such as `every-code`.", + "type": [ + "string", + "null" + ] + }, + "repository": { + "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Tool, worker, or integration that launched this automated session.", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "AutomationTriggerKind": { + "enum": [ + "github_label", + "other" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1563,6 +1632,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" diff --git a/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0c24eadf9cb..3c77b5daa44 100644 --- a/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -386,6 +386,75 @@ ], "type": "string" }, + "AutomationOrigin": { + "properties": { + "actor": { + "description": "Actor reported by the source system as applying the trigger.", + "type": [ + "string", + "null" + ] + }, + "event_id": { + "description": "GitHub event delivery id, webhook id, or local request id.", + "type": [ + "string", + "null" + ] + }, + "issue_number": { + "description": "GitHub issue or pull request number associated with the trigger.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/AutomationTriggerKind" + }, + "label": { + "description": "Label that triggered automation, such as `every-code`.", + "type": [ + "string", + "null" + ] + }, + "repository": { + "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Tool, worker, or integration that launched this automated session.", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "AutomationTriggerKind": { + "enum": [ + "github_label", + "other" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -3069,6 +3138,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" diff --git a/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 3aa62513218..2858dc68866 100644 --- a/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -147,6 +147,75 @@ ], "type": "string" }, + "AutomationOrigin": { + "properties": { + "actor": { + "description": "Actor reported by the source system as applying the trigger.", + "type": [ + "string", + "null" + ] + }, + "event_id": { + "description": "GitHub event delivery id, webhook id, or local request id.", + "type": [ + "string", + "null" + ] + }, + "issue_number": { + "description": "GitHub issue or pull request number associated with the trigger.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/AutomationTriggerKind" + }, + "label": { + "description": "Label that triggered automation, such as `every-code`.", + "type": [ + "string", + "null" + ] + }, + "repository": { + "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Tool, worker, or integration that launched this automated session.", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "AutomationTriggerKind": { + "enum": [ + "github_label", + "other" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -893,6 +962,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" diff --git a/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index c25fd693996..006bd38a0c1 100644 --- a/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -147,6 +147,75 @@ ], "type": "string" }, + "AutomationOrigin": { + "properties": { + "actor": { + "description": "Actor reported by the source system as applying the trigger.", + "type": [ + "string", + "null" + ] + }, + "event_id": { + "description": "GitHub event delivery id, webhook id, or local request id.", + "type": [ + "string", + "null" + ] + }, + "issue_number": { + "description": "GitHub issue or pull request number associated with the trigger.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/AutomationTriggerKind" + }, + "label": { + "description": "Label that triggered automation, such as `every-code`.", + "type": [ + "string", + "null" + ] + }, + "repository": { + "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Tool, worker, or integration that launched this automated session.", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "AutomationTriggerKind": { + "enum": [ + "github_label", + "other" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -893,6 +962,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" diff --git a/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 97fd0022aff..6415956f092 100644 --- a/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -147,6 +147,75 @@ ], "type": "string" }, + "AutomationOrigin": { + "properties": { + "actor": { + "description": "Actor reported by the source system as applying the trigger.", + "type": [ + "string", + "null" + ] + }, + "event_id": { + "description": "GitHub event delivery id, webhook id, or local request id.", + "type": [ + "string", + "null" + ] + }, + "issue_number": { + "description": "GitHub issue or pull request number associated with the trigger.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/definitions/AutomationTriggerKind" + }, + "label": { + "description": "Label that triggered automation, such as `every-code`.", + "type": [ + "string", + "null" + ] + }, + "repository": { + "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Tool, worker, or integration that launched this automated session.", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "AutomationTriggerKind": { + "enum": [ + "github_label", + "other" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -893,6 +962,17 @@ ], "description": "When to escalate for approval for execution" }, + "automation_origin": { + "anyOf": [ + { + "$ref": "#/definitions/AutomationOrigin" + }, + { + "type": "null" + } + ], + "description": "Structured metadata for automated sessions, if the launcher provided it." + }, "cwd": { "description": "Working directory that should be treated as the *root* of the session.", "type": "string" diff --git a/code-rs/app-server-protocol/schema/typescript/AutomationOrigin.ts b/code-rs/app-server-protocol/schema/typescript/AutomationOrigin.ts new file mode 100644 index 00000000000..c0029b47a15 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/AutomationOrigin.ts @@ -0,0 +1,34 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AutomationTriggerKind } from "./AutomationTriggerKind"; + +export type AutomationOrigin = { kind: AutomationTriggerKind, +/** + * Tool, worker, or integration that launched this automated session. + */ +source?: string, +/** + * Repository name in `owner/repo` form, when the trigger came from GitHub. + */ +repository?: string, +/** + * GitHub issue or pull request number associated with the trigger. + */ +issue_number?: bigint, +/** + * Label that triggered automation, such as `every-code`. + */ +label?: string, +/** + * GitHub event delivery id, webhook id, or local request id. + */ +event_id?: string, +/** + * Actor reported by the source system as applying the trigger. + */ +actor?: string, +/** + * Direct URL to the triggering issue, PR, event, or worker record. + */ +url?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AutomationTriggerKind.ts b/code-rs/app-server-protocol/schema/typescript/AutomationTriggerKind.ts new file mode 100644 index 00000000000..807f519f7da --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/AutomationTriggerKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AutomationTriggerKind = "github_label" | "other"; diff --git a/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts b/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts index 2e1896a3968..9f26d783bb6 100644 --- a/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts +++ b/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts @@ -2,50 +2,55 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AskForApproval } from "./AskForApproval"; +import type { AutomationOrigin } from "./AutomationOrigin"; import type { EventMsg } from "./EventMsg"; import type { ReasoningEffort } from "./ReasoningEffort"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { ThreadId } from "./ThreadId"; -export type SessionConfiguredEvent = { session_id: ThreadId, forked_from_id: ThreadId | null, +export type SessionConfiguredEvent = { session_id: ThreadId, forked_from_id: ThreadId | null, /** * Optional user-facing thread name (may be unset). */ -thread_name?: string, +thread_name?: string, /** * Tell the client what model is being queried. */ -model: string, model_provider_id: string, +model: string, model_provider_id: string, /** * When to escalate for approval for execution */ -approval_policy: AskForApproval, +approval_policy: AskForApproval, /** * How to sandbox commands executed in the system */ -sandbox_policy: SandboxPolicy, +sandbox_policy: SandboxPolicy, /** * Working directory that should be treated as the *root* of the * session. */ -cwd: string, +cwd: string, /** * The effort the model is putting into reasoning about the user's request. */ -reasoning_effort: ReasoningEffort | null, +reasoning_effort: ReasoningEffort | null, /** * Identifier of the history log file (inode on Unix, 0 otherwise). */ -history_log_id: bigint, +history_log_id: bigint, /** * Current number of entries in the history log. */ -history_entry_count: number, +history_entry_count: number, +/** + * Structured metadata for automated sessions, if the launcher provided it. + */ +automation_origin?: AutomationOrigin, /** * Optional initial messages (as events) for resumed sessions. * When present, UIs can use these to seed the history. */ -initial_messages: Array | null, +initial_messages: Array | null, /** * Path in which the rollout is stored. Can be `None` for ephemeral threads */ diff --git a/code-rs/app-server-protocol/schema/typescript/index.ts b/code-rs/app-server-protocol/schema/typescript/index.ts index 94110512dc5..2c19833c0a7 100644 --- a/code-rs/app-server-protocol/schema/typescript/index.ts +++ b/code-rs/app-server-protocol/schema/typescript/index.ts @@ -24,6 +24,8 @@ export type { AuthMode } from "./AuthMode"; export type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; export type { AutoContextCheckEvent } from "./AutoContextCheckEvent"; export type { AutoContextPhase } from "./AutoContextPhase"; +export type { AutomationOrigin } from "./AutomationOrigin"; +export type { AutomationTriggerKind } from "./AutomationTriggerKind"; export type { BackgroundEventEvent } from "./BackgroundEventEvent"; export type { ByteRange } from "./ByteRange"; export type { CallToolResult } from "./CallToolResult"; diff --git a/code-rs/app-server-protocol/src/schema_fixtures.rs b/code-rs/app-server-protocol/src/schema_fixtures.rs index 4d8b1d1b88e..83e2493a38d 100644 --- a/code-rs/app-server-protocol/src/schema_fixtures.rs +++ b/code-rs/app-server-protocol/src/schema_fixtures.rs @@ -55,6 +55,7 @@ pub fn write_schema_fixtures_with_options( ..crate::GenerateTsOptions::default() }, )?; + normalize_typescript_tree(&typescript_out_dir)?; crate::generate_json_with_experimental(&json_out_dir, options.experimental_api)?; Ok(()) @@ -85,12 +86,60 @@ fn read_file_bytes(path: &Path) -> Result> { // fixture test is platform-independent. let text = String::from_utf8(bytes) .with_context(|| format!("expected UTF-8 TypeScript in {}", path.display()))?; - let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let text = normalize_typescript_text(&text); return Ok(text.into_bytes()); } Ok(bytes) } +fn normalize_typescript_tree(root: &Path) -> Result<()> { + for rel in collect_type_script_paths(root)? { + let path = root.join(rel); + let text = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let normalized = normalize_typescript_text(&text); + std::fs::write(&path, normalized) + .with_context(|| format!("failed to write {}", path.display()))?; + } + Ok(()) +} + +fn normalize_typescript_text(text: &str) -> String { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut normalized = text + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + if text.ends_with('\n') { + normalized.push('\n'); + } + normalized +} + +fn collect_type_script_paths(root: &Path) -> Result> { + let mut paths = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir) + .with_context(|| format!("failed to read dir {}", dir.display()))? + { + let entry = + entry.with_context(|| format!("failed to read dir entry in {}", dir.display()))?; + let path = entry.path(); + let metadata = std::fs::metadata(&path) + .with_context(|| format!("failed to stat {}", path.display()))?; + if metadata.is_dir() { + stack.push(path); + } else if metadata.is_file() && path.extension().is_some_and(|ext| ext == "ts") { + paths.push(path.strip_prefix(root)?.to_path_buf()); + } + } + } + paths.sort(); + Ok(paths) +} + fn canonicalize_json(value: &Value) -> Value { match value { Value::Array(items) => { @@ -234,4 +283,3 @@ mod tests { assert_eq!(canonicalize_json(&value), expected); } } - diff --git a/code-rs/cli/src/main.rs b/code-rs/cli/src/main.rs index 6f67ca0e534..5260d49d4be 100644 --- a/code-rs/cli/src/main.rs +++ b/code-rs/cli/src/main.rs @@ -1678,6 +1678,7 @@ where originator: "test".to_string(), cli_version: "0.0.0-test".to_string(), source, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index 83fc8b54399..72678a5257e 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -886,6 +886,7 @@ pub(super) async fn submission_loop( model, history_log_id, history_entry_count, + automation_origin: config.automation_origin.clone(), }), )) .chain(mcp_connection_errors.into_iter().map(|message| { diff --git a/code-rs/core/src/config.rs b/code-rs/core/src/config.rs index 216b10378c5..abf92540d4e 100644 --- a/code-rs/core/src/config.rs +++ b/code-rs/core/src/config.rs @@ -48,6 +48,7 @@ use crate::config_types::ContextMode; use crate::config_types::ServiceTier; use crate::project_features::{load_project_commands, ProjectCommand, ProjectHooks}; use code_app_server_protocol::AuthMode; +use code_protocol::protocol::AutomationOrigin; use code_protocol::config_types::SandboxMode; use code_protocol::dynamic_tools::DynamicToolSpec; use std::time::Instant; @@ -65,8 +66,31 @@ mod validation; use defaults::{default_responses_originator, default_review_model, default_true_local}; const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL"; +const AUTOMATION_ORIGIN_ENV_VAR: &str = "CODE_AUTOMATION_ORIGIN"; const RESERVED_MODEL_PROVIDER_IDS: [&str; 2] = ["openai", "oss"]; +fn load_automation_origin_from_env() -> Option { + let raw = std::env::var(AUTOMATION_ORIGIN_ENV_VAR).ok()?; + parse_automation_origin(&raw) +} + +fn parse_automation_origin(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + match serde_json::from_str::(trimmed) { + Ok(origin) => Some(origin), + Err(err) => { + tracing::warn!( + "ignoring invalid automation origin metadata: {err}" + ); + None + } + } +} + fn validate_reserved_model_provider_ids( model_providers: &HashMap, ) -> Result<(), String> { @@ -480,6 +504,9 @@ pub struct Config { /// Optional remote inbox bridge used by local companion UIs. pub remote_inbox: RemoteInboxConfig, + /// Structured metadata for externally launched automation sessions. + pub automation_origin: Option, + /// Shared Auto Drive defaults. pub auto_drive: AutoDriveSettings, /// Whether Auto Drive should inherit the chat model instead of a dedicated override. @@ -1460,6 +1487,7 @@ impl Config { let responses_originator_header: String = cfg .responses_originator_header_internal_override .unwrap_or_else(|| default_responses_originator()); + let automation_origin = load_automation_origin_from_env(); let agents: Vec = merge_with_default_agents(cfg.agents); @@ -1756,6 +1784,7 @@ impl Config { file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), tui: cfg.tui.clone().unwrap_or_default(), remote_inbox: cfg.remote_inbox.unwrap_or_default(), + automation_origin, auto_drive, auto_drive_use_chat_model, code_linux_sandbox_exe, @@ -1976,6 +2005,30 @@ mod tests { } } + #[test] + fn automation_origin_parses_github_label_metadata() { + let origin = parse_automation_origin( + r#"{"kind":"github_label","source":"launchplane","repository":"cbusillo/code","issue_number":160,"label":"every-code","event_id":"delivery-123","actor":"cbusillo","url":"https://github.com/cbusillo/code/issues/160"}"#, + ) + .expect("automation origin metadata"); + assert_eq!(origin.kind, code_protocol::protocol::AutomationTriggerKind::GithubLabel); + assert_eq!(origin.source.as_deref(), Some("launchplane")); + assert_eq!(origin.repository.as_deref(), Some("cbusillo/code")); + assert_eq!(origin.issue_number, Some(160)); + assert_eq!(origin.label.as_deref(), Some("every-code")); + assert_eq!(origin.event_id.as_deref(), Some("delivery-123")); + assert_eq!(origin.actor.as_deref(), Some("cbusillo")); + assert_eq!( + origin.url.as_deref(), + Some("https://github.com/cbusillo/code/issues/160") + ); + } + + #[test] + fn automation_origin_ignores_invalid_json() { + assert!(parse_automation_origin("not json").is_none()); + } + #[test] fn test_toml_parsing() { let history_with_persistence = r#" diff --git a/code-rs/core/src/protocol.rs b/code-rs/core/src/protocol.rs index e2b359969ba..1f826ce55be 100644 --- a/code-rs/core/src/protocol.rs +++ b/code-rs/core/src/protocol.rs @@ -41,6 +41,7 @@ pub use code_protocol::protocol::ReviewFinding; pub use code_protocol::protocol::ReviewLineRange; pub use code_protocol::protocol::ReviewOutputEvent; pub use code_protocol::protocol::{ReviewContextMetadata, ReviewRequest}; +pub use code_protocol::protocol::AutomationOrigin; pub use code_protocol::protocol::GitInfo; pub use code_protocol::protocol::ImageGenerationBeginEvent; pub use code_protocol::protocol::ImageGenerationEndEvent; @@ -1563,6 +1564,10 @@ pub struct SessionConfiguredEvent { /// Current number of entries in the history log. pub history_entry_count: usize, + + /// Structured metadata for automated sessions, if the launcher provided it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub automation_origin: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -1732,12 +1737,13 @@ mod tests { let event = Event { id: "1234".to_string(), event_seq: 0, - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - model: "codex-mini-latest".to_string(), - history_log_id: 0, - history_entry_count: 0, - }), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id, + model: "codex-mini-latest".to_string(), + history_log_id: 0, + history_entry_count: 0, + automation_origin: None, + }), order: None, }; let serialized = serde_json::to_string(&event).unwrap(); @@ -1746,4 +1752,36 @@ mod tests { r#"{"id":"1234","event_seq":0,"msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"# ); } + + #[test] + fn serialize_session_configured_with_automation_origin() { + let session_id: Uuid = uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"); + let event = Event { + id: "1234".to_string(), + event_seq: 0, + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id, + model: "gpt-5.5".to_string(), + history_log_id: 0, + history_entry_count: 0, + automation_origin: Some(AutomationOrigin { + kind: code_protocol::protocol::AutomationTriggerKind::GithubLabel, + source: Some("launchplane".to_string()), + repository: Some("cbusillo/code".to_string()), + issue_number: Some(160), + label: Some("every-code".to_string()), + event_id: Some("delivery-123".to_string()), + actor: Some("cbusillo".to_string()), + url: Some("https://github.com/cbusillo/code/issues/160".to_string()), + }), + }), + order: None, + }; + let value = serde_json::to_value(&event).unwrap(); + assert_eq!(value["msg"]["automation_origin"]["kind"], "github_label"); + assert_eq!(value["msg"]["automation_origin"]["source"], "launchplane"); + assert_eq!(value["msg"]["automation_origin"]["repository"], "cbusillo/code"); + assert_eq!(value["msg"]["automation_origin"]["issue_number"], 160); + assert_eq!(value["msg"]["automation_origin"]["label"], "every-code"); + } } diff --git a/code-rs/core/src/rollout/recorder.rs b/code-rs/core/src/rollout/recorder.rs index 463182e130f..562efa7bb3b 100644 --- a/code-rs/core/src/rollout/recorder.rs +++ b/code-rs/core/src/rollout/recorder.rs @@ -162,6 +162,7 @@ impl RolloutRecorder { originator: DEFAULT_ORIGINATOR.to_string(), cli_version: code_version::version().to_string(), source, + automation_origin: config.automation_origin.clone(), model_provider: None, base_instructions: instructions.map(|text| BaseInstructions { text }), dynamic_tools: None, @@ -598,3 +599,63 @@ impl JsonlWriter { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::config::ConfigOverrides; + use code_protocol::protocol::AutomationOrigin; + use code_protocol::protocol::AutomationTriggerKind; + use tempfile::TempDir; + + #[tokio::test] + async fn recorder_writes_automation_origin_when_present() { + let tmp = TempDir::new().expect("temp dir"); + let mut config = Config::load_default_with_cli_overrides( + vec![], + ConfigOverrides { + cwd: Some(tmp.path().to_path_buf()), + ..Default::default() + }, + ) + .expect("config"); + config.code_home = tmp.path().join("code-home"); + config.automation_origin = Some(AutomationOrigin { + kind: AutomationTriggerKind::GithubLabel, + source: Some("launchplane".to_string()), + repository: Some("cbusillo/code".to_string()), + issue_number: Some(160), + label: Some("every-code".to_string()), + event_id: Some("delivery-123".to_string()), + actor: Some("cbusillo".to_string()), + url: Some("https://github.com/cbusillo/code/issues/160".to_string()), + }); + + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + ConversationId::new(), + None, + SessionSource::Exec, + ), + ) + .await + .expect("recorder"); + let rollout_path = recorder.rollout_path.clone(); + recorder.shutdown().await.expect("shutdown"); + + let text = tokio::fs::read_to_string(rollout_path).await.expect("rollout"); + let first_line = text.lines().next().expect("session meta line"); + let line: RolloutLine = serde_json::from_str(first_line).expect("rollout line"); + let RolloutItem::SessionMeta(meta) = line.item else { + panic!("expected session metadata"); + }; + let origin = meta.meta.automation_origin.expect("automation origin"); + assert_eq!(origin.kind, AutomationTriggerKind::GithubLabel); + assert_eq!(origin.source.as_deref(), Some("launchplane")); + assert_eq!(origin.repository.as_deref(), Some("cbusillo/code")); + assert_eq!(origin.issue_number, Some(160)); + assert_eq!(origin.label.as_deref(), Some("every-code")); + } +} diff --git a/code-rs/core/src/rollout/tests.rs b/code-rs/core/src/rollout/tests.rs index 103b9e83a06..853b7acf276 100644 --- a/code-rs/core/src/rollout/tests.rs +++ b/code-rs/core/src/rollout/tests.rs @@ -128,6 +128,7 @@ fn write_session_file( originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: source.unwrap_or_default(), + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -194,6 +195,7 @@ async fn test_resume_reconstruct_history_drops_user_events() { originator: "regression-test".to_string(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Cli, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/code-rs/core/tests/session_catalog_resume.rs b/code-rs/core/tests/session_catalog_resume.rs index 7ccd4c02699..84a75e2ee3d 100644 --- a/code-rs/core/tests/session_catalog_resume.rs +++ b/code-rs/core/tests/session_catalog_resume.rs @@ -40,6 +40,7 @@ fn write_rollout_transcript( originator: "test".to_string(), cli_version: "0.0.0-test".to_string(), source, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/code-rs/exec/src/lib.rs b/code-rs/exec/src/lib.rs index 83f6c8eb5d6..4dc0619bb25 100644 --- a/code-rs/exec/src/lib.rs +++ b/code-rs/exec/src/lib.rs @@ -2998,6 +2998,7 @@ mod tests { originator: "test".to_string(), cli_version: "0.0.0-test".to_string(), source, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/code-rs/mcp-server/src/outgoing_message.rs b/code-rs/mcp-server/src/outgoing_message.rs index e07ff95a903..beec945c1dc 100644 --- a/code-rs/mcp-server/src/outgoing_message.rs +++ b/code-rs/mcp-server/src/outgoing_message.rs @@ -99,6 +99,7 @@ mod tests { model: "gpt-4o".to_string(), history_log_id: 1, history_entry_count: 1000, + automation_origin: None, }), }; @@ -130,6 +131,7 @@ mod tests { model: "gpt-4o".to_string(), history_log_id: 1, history_entry_count: 1000, + automation_origin: None, }; let event = Event { id: "1".to_string(), diff --git a/code-rs/protocol/src/protocol.rs b/code-rs/protocol/src/protocol.rs index 56179fc26f9..22b466c2c1e 100644 --- a/code-rs/protocol/src/protocol.rs +++ b/code-rs/protocol/src/protocol.rs @@ -1811,6 +1811,54 @@ pub enum SubAgentSource { Other(String), } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum AutomationTriggerKind { + GithubLabel, + Other, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] +pub struct AutomationOrigin { + pub kind: AutomationTriggerKind, + + /// Tool, worker, or integration that launched this automated session. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub source: Option, + + /// Repository name in `owner/repo` form, when the trigger came from GitHub. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub repository: Option, + + /// GitHub issue or pull request number associated with the trigger. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub issue_number: Option, + + /// Label that triggered automation, such as `every-code`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub label: Option, + + /// GitHub event delivery id, webhook id, or local request id. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub event_id: Option, + + /// Actor reported by the source system as applying the trigger. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub actor: Option, + + /// Direct URL to the triggering issue, PR, event, or worker record. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub url: Option, +} + impl fmt::Display for SessionSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -1857,6 +1905,9 @@ pub struct SessionMeta { pub cli_version: String, #[serde(default)] pub source: SessionSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub automation_origin: Option, pub model_provider: Option, /// base_instructions for the session. This *should* always be present when creating a new session, /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions @@ -1876,6 +1927,7 @@ impl Default for SessionMeta { originator: String::new(), cli_version: String::new(), source: SessionSource::default(), + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -2555,6 +2607,11 @@ pub struct SessionConfiguredEvent { /// Current number of entries in the history log. pub history_entry_count: usize, + /// Structured metadata for automated sessions, if the launcher provided it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub automation_origin: Option, + /// Optional initial messages (as events) for resumed sessions. /// When present, UIs can use these to seed the history. #[serde(skip_serializing_if = "Option::is_none")] @@ -2995,6 +3052,7 @@ mod tests { reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, + automation_origin: None, initial_messages: None, rollout_path: Some(rollout_file.path().to_path_buf()), }), @@ -3022,6 +3080,53 @@ mod tests { Ok(()) } + #[test] + fn serialize_session_configured_with_automation_origin() -> Result<()> { + let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let event = Event { + id: "1234".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "gpt-5.5".to_string(), + model_provider_id: "openai".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + automation_origin: Some(AutomationOrigin { + kind: AutomationTriggerKind::GithubLabel, + source: Some("launchplane".to_string()), + repository: Some("cbusillo/code".to_string()), + issue_number: Some(160), + label: Some("every-code".to_string()), + event_id: Some("delivery-123".to_string()), + actor: Some("cbusillo".to_string()), + url: Some("https://github.com/cbusillo/code/issues/160".to_string()), + }), + initial_messages: None, + rollout_path: None, + }), + }; + + let value = serde_json::to_value(&event)?; + assert_eq!(value["msg"]["automation_origin"]["kind"], "github_label"); + assert_eq!(value["msg"]["automation_origin"]["source"], "launchplane"); + assert_eq!(value["msg"]["automation_origin"]["repository"], "cbusillo/code"); + assert_eq!(value["msg"]["automation_origin"]["issue_number"], 160); + assert_eq!(value["msg"]["automation_origin"]["label"], "every-code"); + assert_eq!(value["msg"]["automation_origin"]["event_id"], "delivery-123"); + assert_eq!(value["msg"]["automation_origin"]["actor"], "cbusillo"); + assert_eq!( + value["msg"]["automation_origin"]["url"], + "https://github.com/cbusillo/code/issues/160" + ); + Ok(()) + } + #[test] fn vec_u8_as_base64_serialization_and_deserialization() -> Result<()> { let event = ExecCommandOutputDeltaEvent { diff --git a/code-rs/tui/src/resume/tests.rs b/code-rs/tui/src/resume/tests.rs index 220ebf763d6..fee1f39af53 100644 --- a/code-rs/tui/src/resume/tests.rs +++ b/code-rs/tui/src/resume/tests.rs @@ -30,6 +30,7 @@ fn write_event_only_session(path: &Path, cwd: &Path) { originator: "resume-test".to_string(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Cli, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/code-rs/tui/tests/resume_catalog_integration.rs b/code-rs/tui/tests/resume_catalog_integration.rs index d1df15dc143..64ddf47c966 100644 --- a/code-rs/tui/tests/resume_catalog_integration.rs +++ b/code-rs/tui/tests/resume_catalog_integration.rs @@ -40,6 +40,7 @@ fn write_rollout( originator: "test".to_string(), cli_version: "0.0.0-test".to_string(), source, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -202,6 +203,7 @@ fn resume_picker_excludes_current_path_and_empty_sessions() { originator: "test".to_string(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Cli, + automation_origin: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/code-rs/tui/tests/ui_smoke.rs b/code-rs/tui/tests/ui_smoke.rs index f81e8a0a291..57a3f5e93f4 100644 --- a/code-rs/tui/tests/ui_smoke.rs +++ b/code-rs/tui/tests/ui_smoke.rs @@ -866,6 +866,7 @@ fn seed_session(harness: &mut ChatWidgetHarness) { model: "gpt-5.1-codex".into(), history_log_id: 0, history_entry_count: 0, + automation_origin: None, }), order: None, }); diff --git a/docs/config.md b/docs/config.md index c2e3d22f129..cd4d0824084 100644 --- a/docs/config.md +++ b/docs/config.md @@ -720,6 +720,30 @@ flag; the official prebuilt binaries ship with the feature enabled. When the feature is disabled the telemetry hooks become no-ops so the CLI continues to function without the extra dependencies. +## Automation Origin + +External launchers can set `CODE_AUTOMATION_ORIGIN` to a JSON object when they +start Code on behalf of another system. Code records that value in the rollout +session metadata and includes it on the initial `session_configured` event so +clients can distinguish human-started sessions from automation. + +For GitHub label triggers, use `kind = "github_label"` and include the source +tool, repository, issue or PR number, triggering label, delivery/request id, +actor, and URL when known: + +```json +{ + "kind": "github_label", + "source": "launchplane", + "repository": "cbusillo/code", + "issue_number": 160, + "label": "every-code", + "event_id": "delivery-123", + "actor": "cbusillo", + "url": "https://github.com/cbusillo/code/issues/160" +} +``` + ## notify Specify a program that will be executed to get notified about events generated by Code. Note that the program will receive the notification argument as a string of JSON, e.g.: