From 37ceab4a1f0245d58030006f79afa897ee24a4a7 Mon Sep 17 00:00:00 2001 From: David VIEJO Date: Mon, 12 Oct 2026 20:53:30 +0200 Subject: [PATCH 1/7] feat: ask the harness to title a session Add TurnRequest::request_session_title, TurnEvent::SessionTitle and TurnResult::session_title so a caller can have the harness title a new session the way its own client does. Claude Code answers generate_session_title, the Codex app-server replays its TUI's hidden title thread, and opencode serve titles through its session API. Generation runs alongside the turn and never delays or fails it. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 + src/protocol.rs | 9 + src/protocol_client.rs | 1 + src/protocol_host.rs | 2 + src/providers/claude.rs | 169 +++- src/providers/codex_app_server.rs | 1209 ++++++++++++++++++++++++++++- src/providers/mod.rs | 18 + src/providers/opencode_serve.rs | 132 +++- src/retained.rs | 4 + src/types.rs | 21 + tests/codex_app_server.rs | 163 +++- 11 files changed, 1714 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4715535..de74006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ Versioning and Keep a Changelog conventions. ### Added +- `TurnRequest::request_session_title`, `TurnEvent::SessionTitle` and + `TurnResult::session_title`: ask the harness to title a session that has + none yet, from the user's own description of the task, the way its own + client does. Claude Code answers `generate_session_title`, the Codex + app-server replays its TUI's hidden title thread, and `opencode serve` + titles through its session API. Generation runs alongside the turn and + never delays or fails it; a turn that ends first reports no title, so keep + setting the field until one arrives. The request is carried over the + protocol (`ProtocolTurnInput::request_session_title`) and is never logged. - `AgentTask::model`: the model a Claude Code subagent runs on, as its own messages report it (for example `claude-haiku-5-5`), including a subagent that inherits the parent's model. It is `None` until the subagent's first diff --git a/src/protocol.rs b/src/protocol.rs index b844d6f..2ce867c 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -204,6 +204,10 @@ pub struct ProtocolTurnInput { pub attachments: Vec, /// Required approval/question handling. pub interaction_policy: InteractionPolicy, + /// See [`crate::TurnRequest::request_session_title`]. Protocol logging + /// must redact the full frame. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_session_title: Option, } impl fmt::Debug for ProtocolTurnInput { @@ -234,6 +238,10 @@ impl fmt::Debug for ProtocolTurnInput { ) .field("attachment_count", &self.attachments.len()) .field("interaction_policy", &self.interaction_policy) + .field( + "request_session_title_bytes", + &self.request_session_title.as_ref().map(String::len), + ) .finish_non_exhaustive() } } @@ -770,6 +778,7 @@ mod tests { }], attachments: vec![TurnAttachment::new("uploads/report.pdf")], interaction_policy: InteractionPolicy::RequireHandler, + request_session_title: None, }, }, } diff --git a/src/protocol_client.rs b/src/protocol_client.rs index b6be807..451c3fe 100644 --- a/src/protocol_client.rs +++ b/src/protocol_client.rs @@ -1106,6 +1106,7 @@ fn protocol_turn_input(input: TurnInput) -> ProtocolTurnInput { environment: protocol_environment(input.environment), attachments: input.attachments, interaction_policy: input.interaction_policy, + request_session_title: input.request_session_title, } } diff --git a/src/protocol_host.rs b/src/protocol_host.rs index e540d71..ba0e766 100644 --- a/src/protocol_host.rs +++ b/src/protocol_host.rs @@ -936,6 +936,7 @@ fn protocol_turn_input(input: ProtocolTurnInput) -> Result, + /// Description to title the session from; taken when the title request + /// is written after the turn's first frame. + #[serde(default)] + session_title_description: Option, } impl ClaudeNativeState { @@ -1097,6 +1104,12 @@ impl AgentAdapter for Claude { fn prepare_turn(&self, request: &TurnRequest, state: &mut AdapterState) -> Result<()> { let mut native = take_native_state(state); native.manual_compaction_turn = is_manual_compaction_prompt(&request.prompt); + native.session_title_description = (!request.continuation) + .then_some(request.request_session_title.as_deref()) + .flatten() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(|description| description.chars().take(SESSION_TITLE_DESCRIPTION_CHARS).collect()); native .own_commands .insert(new_message_uuid(), "pending".to_string()); @@ -1414,12 +1427,25 @@ impl AgentAdapter for Claude { })?; let mut output = AdapterOutput::default(); let mut native = take_native_state(state); + // Claude answers `generate_session_title` only once a session holds + // the turn's message, so ask after its first frame. Claude Code's + // interactive client makes the same request; `persist` saves the + // title into the session, and an already-titled session answers with + // its existing title without generating another. + if let Some(description) = native.session_title_description.take() { + output.writes.push(session_title_frame(&description)?); + } match value .get("type") .and_then(Value::as_str) .unwrap_or_default() { "system" => { + if value.get("subtype").and_then(Value::as_str) == Some("session_title_changed") { + if let Some(title) = value.get("title").and_then(Value::as_str) { + super::report_session_title(title, state, &mut output); + } + } if native.result_seen && value.get("subtype").and_then(Value::as_str) == Some("init") { @@ -1862,12 +1888,19 @@ impl AgentAdapter for Claude { } } "control_response" => { - if value - .pointer("/response/request_id") - .and_then(Value::as_str) - == Some(INTERRUPT_REQUEST_ID) - { - native.interrupting = true; + match value.pointer("/response/request_id").and_then(Value::as_str) { + Some(INTERRUPT_REQUEST_ID) => native.interrupting = true, + // A failed request leaves the session untitled; the + // caller asks again on a later turn. + Some(SESSION_TITLE_REQUEST_ID) => { + if let Some(title) = value + .pointer("/response/response/title") + .and_then(Value::as_str) + { + super::report_session_title(title, state, &mut output); + } + } + _ => {} } } "rate_limit_event" => { @@ -1932,6 +1965,22 @@ impl AgentAdapter for Claude { } } +fn session_title_frame(description: &str) -> Result> { + serde_json::to_vec(&json!({ + "type": "control_request", + "request_id": SESSION_TITLE_REQUEST_ID, + "request": { + "subtype": "generate_session_title", + "description": description, + "persist": true, + }, + })) + .map_err(|error| RuntimeError::Protocol { + provider: Provider::Claude, + message: format!("could not encode the session title request: {error}"), + }) +} + fn encode_control_response(request_id: &str, response: Value) -> Result> { serde_json::to_vec(&json!({ "type": "control_response", @@ -4230,6 +4279,114 @@ setTimeout(() => { ); } + fn titled_turn(description: Option<&str>, continuation: bool) -> AdapterState { + let adapter = Claude::default(); + let mut request = TurnRequest::new(Provider::Claude, ".", "\nfix the login loop"); + request.request_session_title = description.map(str::to_owned); + request.continuation = continuation; + let mut state = AdapterState::default(); + adapter.prepare_turn(&request, &mut state).unwrap(); + state + } + + #[test] + fn asks_claude_to_title_the_session_once_after_its_first_frame() { + let adapter = Claude::default(); + let mut state = titled_turn(Some(" fix the login loop on Safari "), false); + let first = adapter + .parse_line(r#"{"type":"system","subtype":"init","session_id":"s-1"}"#, &mut state) + .unwrap(); + assert_eq!(first.writes.len(), 1); + let frame: Value = serde_json::from_slice(&first.writes[0]).unwrap(); + assert_eq!(frame["type"], "control_request"); + assert_eq!(frame["request_id"], SESSION_TITLE_REQUEST_ID); + assert_eq!(frame["request"]["subtype"], "generate_session_title"); + assert_eq!(frame["request"]["description"], "fix the login loop on Safari"); + assert_eq!(frame["request"]["persist"], true); + + let later = adapter + .parse_line(r#"{"type":"rate_limit_event"}"#, &mut state) + .unwrap(); + assert!(later.writes.is_empty(), "the title is requested once per turn"); + } + + #[test] + fn bounds_the_title_description_like_claude_code() { + let adapter = Claude::default(); + let long = "é".repeat(SESSION_TITLE_DESCRIPTION_CHARS + 50); + let mut state = titled_turn(Some(&long), false); + let output = adapter + .parse_line(r#"{"type":"system","subtype":"init","session_id":"s-1"}"#, &mut state) + .unwrap(); + let frame: Value = serde_json::from_slice(&output.writes[0]).unwrap(); + assert_eq!( + frame["request"]["description"].as_str().unwrap().chars().count(), + SESSION_TITLE_DESCRIPTION_CHARS + ); + } + + #[test] + fn no_title_is_requested_without_a_description_or_for_a_continuation() { + let adapter = Claude::default(); + for mut state in [ + titled_turn(None, false), + titled_turn(Some(" "), false), + titled_turn(Some("fix it"), true), + ] { + let output = adapter + .parse_line(r#"{"type":"system","subtype":"init","session_id":"s-1"}"#, &mut state) + .unwrap(); + assert!(output.writes.is_empty()); + } + } + + #[test] + fn the_title_response_reports_the_session_title() { + let adapter = Claude::default(); + let mut state = AdapterState::default(); + let output = adapter + .parse_line( + r#"{"type":"control_response","response":{"subtype":"success","request_id":"temps-agent-runtime-session-title","response":{"title":" Login redirect loop on Safari "}}}"#, + &mut state, + ) + .unwrap(); + assert_eq!( + output.events, + vec![TurnEvent::SessionTitle { title: "Login redirect loop on Safari".into() }] + ); + assert_eq!(state.result.session_title.as_deref(), Some("Login redirect loop on Safari")); + + // The same title announced again is not reported twice. + let repeated = adapter + .parse_line( + r#"{"type":"system","subtype":"session_title_changed","title":"Login redirect loop on Safari"}"#, + &mut state, + ) + .unwrap(); + assert!(repeated.events.is_empty()); + let renamed = adapter + .parse_line( + r#"{"type":"system","subtype":"session_title_changed","title":"Safari OAuth loop"}"#, + &mut state, + ) + .unwrap(); + assert_eq!(renamed.events, vec![TurnEvent::SessionTitle { title: "Safari OAuth loop".into() }]); + } + + #[test] + fn a_failed_title_request_is_silent() { + let adapter = Claude::default(); + let mut state = AdapterState::default(); + let output = adapter + .parse_line( + r#"{"type":"control_response","response":{"subtype":"error","request_id":"temps-agent-runtime-session-title","error":"no session"}}"#, + &mut state, + ) + .unwrap(); + assert!(output.events.is_empty()); + assert!(state.result.session_title.is_none()); + } + #[test] fn repeated_resumed_session_id_is_not_started_again() { let adapter = Claude::default(); diff --git a/src/providers/codex_app_server.rs b/src/providers/codex_app_server.rs index 9db1185..b0b8a94 100644 --- a/src/providers/codex_app_server.rs +++ b/src/providers/codex_app_server.rs @@ -39,6 +39,23 @@ const ID_TURN: u64 = 4; const ID_INTERRUPT: u64 = 5; /// Written only by [`keepalive_probe`] between turns. const ID_KEEPALIVE: &str = "sdk-idle-keepalive"; +/// Prefix of every request id the session-title flow writes. The rest of the +/// id names the flow and the step, so a late answer for an abandoned flow can +/// neither advance a newer one nor be mistaken for a keepalive answer. +const TITLE_ID_PREFIX: &str = "sdk-title-"; + +/// Model Codex's own TUI titles threads with (`codex-rs/tui/src/app/thread_title.rs`). +const TITLE_MODEL: &str = "gpt-5.6-luna"; +/// Longest title the TUI asks for and keeps. +const TITLE_MAX_CHARS: usize = 36; +/// Byte bound of the whole title prompt, instructions included. +const TITLE_PROMPT_MAX_BYTES: usize = 960; +/// Longest structured answer the hidden title turn may return. +const TITLE_RESPONSE_MAX_BYTES: usize = 8 * 1024; +/// `ThreadSource::Feature("thread_title")`, which serializes as its name. +const TITLE_THREAD_SOURCE: &str = "thread_title"; +/// `codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY`. +const READ_ONLY_PERMISSION_PROFILE: &str = ":read-only"; /// Reply sent for a question the turn did not wait on. An empty answer set /// reads to the model as "the user chose nothing"; this says what actually @@ -86,6 +103,64 @@ struct TurnState { /// This invocation compacts the thread (`thread/compact/start`) instead of /// starting a turn. manual_compaction: bool, + /// Hidden thread that titles a new session, when one was requested. + title: Option, +} + +/// Where the session-title flow is. Every step waits on exactly one answer. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TitleStep { + /// Waiting for the user's thread to open. + #[default] + Waiting, + /// `config/read`, to learn which MCP servers to disable. + ReadingConfig, + /// `thread/start` of the hidden thread. + StartingThread, + /// `turn/start` on the hidden thread. + StartingTurn, + /// The hidden turn is generating the title. + Running, + /// Finished, failed, or abandoned. Nothing more is written. + Done, +} + +/// Codex's TUI titles a new thread from a hidden, ephemeral, read-only thread +/// with every tool disabled, then names the visible thread with the result +/// (`codex-rs/tui/src/app/thread_title.rs`, `temporary_structured_request.rs`). +/// This replays it on the turn's own app-server connection. It runs alongside +/// the user's turn and never delays or fails it: every error ends the flow +/// silently, and a turn that ends first abandons it. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(default)] +struct TitleFlow { + /// Distinguishes this flow's request ids from every other flow's. + nonce: String, + /// The bounded prompt, built exactly like the TUI's `thread_title_prompt`. + prompt: String, + cwd: String, + /// MCP servers the hidden thread must not start. + mcp_servers: Vec, + /// Model of the user's thread, the fallback when the title model fails. + main_model: Option, + /// Model provider of the user's thread. + model_provider: Option, + /// Model of the current attempt; `None` is the server's default model. + model: Option, + /// The single fallback attempt was already used. + retried: bool, + step: TitleStep, + /// Hidden thread of the current attempt. + thread_id: Option, + /// Every hidden thread this flow opened, so frames of an abandoned + /// attempt stay out of the user's turn too. + hidden_threads: Vec, + /// Hidden turn of the current attempt. + turn_id: Option, + /// Latest final agent message of the hidden turn. + response: Option, + /// The user's thread was named some other way meanwhile; that name wins. + superseded: bool, } pub(super) fn mark_retained(state: &mut AdapterState) { @@ -215,6 +290,20 @@ pub(super) fn prepare_turn(request: &TurnRequest, state: &mut AdapterState) -> R } } + let manual_compaction = super::is_manual_compaction_prompt(&request.prompt); + let title = request + .request_session_title + .as_deref() + .filter(|_| !request.continuation && !manual_compaction) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(|description| TitleFlow { + nonce: title_nonce(), + prompt: title_prompt(description), + cwd: request.working_directory.to_string_lossy().into_owned(), + mcp_servers: request.launch_context.mcp_servers.keys().cloned().collect(), + ..TitleFlow::default() + }); store( state, &TurnState { @@ -222,7 +311,8 @@ pub(super) fn prepare_turn(request: &TurnRequest, state: &mut AdapterState) -> R turn_params, resume: request.session_id.is_some(), model: request.model.clone(), - manual_compaction: super::is_manual_compaction_prompt(&request.prompt), + manual_compaction, + title, ..TurnState::default() }, ); @@ -286,6 +376,16 @@ pub(super) fn classify_idle_frame(line: &str) -> IdleFrame { .is_some_and(|terminals| !terminals.is_empty()), } } + // A session-title flow the finished turn abandoned can still be + // answered. It never owes the process anything. + Some(id) + if !has_method + && id + .as_str() + .is_some_and(|id| id.starts_with(TITLE_ID_PREFIX)) => + { + IdleFrame::Background + } _ => IdleFrame::Unexpected, } } @@ -301,6 +401,19 @@ pub(super) fn parse_line(line: &str, state: &mut AdapterState) -> Result