Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ 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.
- `AdapterOutput::optional_writes` and `AgentAdapter::idle_frame_writes`:
provider frames the runtime writes without ever waiting for them. A frame
the provider's stdin does not accept at once is dropped and a failed write
is ignored, so housekeeping such as the session-title request and the
cleanup of its hidden Codex thread can neither delay a turn nor replace a
completed turn's success with a write error. Code that constructs
`AdapterOutput` with struct syntax must now set `optional_writes`.
- `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
Expand Down
21 changes: 21 additions & 0 deletions src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,16 @@ pub struct AdapterOutput {
/// issuing the next call once the previous response arrived. An adapter
/// that uses this field must set [`CommandSpec::interactive_stdin`].
pub writes: Vec<Vec<u8>>,
/// Provider-native frames the runtime may write after [`Self::writes`] but
/// never waits for: housekeeping such as a session-title request or the
/// cleanup of a hidden thread.
///
/// Each frame is written only if the provider's stdin accepts it at once.
/// One that would block is dropped, and a failed write is ignored, so these
/// frames can neither delay a turn nor fail it, even one that has just
/// completed. An adapter must only put frames here whose loss costs the
/// caller a nicety, not the turn.
pub optional_writes: Vec<Vec<u8>>,
/// True after a provider terminal frame. The runtime then closes stdin.
pub terminal: bool,
/// The provider acknowledged the submitted prompt for this turn.
Expand Down Expand Up @@ -582,6 +592,17 @@ pub trait AgentAdapter: Send + Sync {
IdleFrame::Unexpected
}

/// Frames to write in answer to a frame [`Self::classify_idle_frame`] read
/// between turns, such as cleanup for work an earlier turn left pending.
///
/// They are written like [`AdapterOutput::optional_writes`]: only if stdin
/// accepts them at once, and a failure is ignored. The default writes
/// nothing.
fn idle_frame_writes(&self, line: &str) -> Vec<Vec<u8>> {
let _ = line;
Vec::new()
}

/// Supply a protocol carrier to use instead of the child's stdout and stdin.
///
/// Called once, after the provider process is spawned and before the first
Expand Down
9 changes: 9 additions & 0 deletions src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ pub struct ProtocolTurnInput {
pub attachments: Vec<TurnAttachment>,
/// 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<String>,
}

impl fmt::Debug for ProtocolTurnInput {
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -770,6 +778,7 @@ mod tests {
}],
attachments: vec![TurnAttachment::new("uploads/report.pdf")],
interaction_policy: InteractionPolicy::RequireHandler,
request_session_title: None,
},
},
}
Expand Down
1 change: 1 addition & 0 deletions src/protocol_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/protocol_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ fn protocol_turn_input(input: ProtocolTurnInput) -> Result<TurnInput, RuntimeFai
turn.environment = protocol_environment(input.environment);
turn.attachments = input.attachments;
turn.interaction_policy = input.interaction_policy;
turn.request_session_title = input.request_session_title;
Ok(turn)
}

Expand Down Expand Up @@ -1158,6 +1159,7 @@ mod tests {
environment: Vec::new(),
attachments: Vec::new(),
interaction_policy: InteractionPolicy::Deny,
request_session_title: None,
},
},
}
Expand Down
206 changes: 203 additions & 3 deletions src/providers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const MAX_BACKGROUND_SUMMARY: usize = 5;
const FOLLOW_UP_GRACE: Duration = Duration::from_secs(3);
/// Request ID of the native interrupt the runtime sends to a retained turn.
const INTERRUPT_REQUEST_ID: &str = "temps-agent-runtime-interrupt";
const SESSION_TITLE_REQUEST_ID: &str = "temps-agent-runtime-session-title";
/// Claude Code's own client titles from at most this much of the prompt.
const SESSION_TITLE_DESCRIPTION_CHARS: usize = 1000;
/// Upper bound on user messages one turn may submit, its prompt included.
const MAX_TURN_MESSAGES: usize = 32;

Expand Down Expand Up @@ -446,6 +449,10 @@ struct ClaudeNativeState {
/// emitted.
#[serde(default)]
workflow_unsent: BTreeSet<String>,
/// 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<String>,
}

impl ClaudeNativeState {
Expand Down Expand Up @@ -1097,6 +1104,17 @@ 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());
Expand Down Expand Up @@ -1414,12 +1432,27 @@ 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
.optional_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")
{
Expand Down Expand Up @@ -1862,12 +1895,22 @@ impl AgentAdapter for Claude {
}
}
"control_response" => {
if value
match value
.pointer("/response/request_id")
.and_then(Value::as_str)
== Some(INTERRUPT_REQUEST_ID)
{
native.interrupting = true;
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" => {
Expand Down Expand Up @@ -1932,6 +1975,22 @@ impl AgentAdapter for Claude {
}
}

fn session_title_frame(description: &str) -> Result<Vec<u8>> {
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<Vec<u8>> {
serde_json::to_vec(&json!({
"type": "control_response",
Expand Down Expand Up @@ -4230,6 +4289,147 @@ setTimeout(() => {
);
}

fn titled_turn(description: Option<&str>, continuation: bool) -> AdapterState {
let adapter = Claude::default();
let mut request = TurnRequest::new(Provider::Claude, ".", "<context/>\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();
// An optional write: a blocked or closed stdin can neither delay nor
// fail the turn over a title.
assert_eq!(first.writes.len(), 0);
assert_eq!(first.optional_writes.len(), 1);
let frame: Value = serde_json::from_slice(&first.optional_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_eq!(
later.optional_writes.len(),
0,
"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.optional_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_eq!(output.writes.len() + output.optional_writes.len(), 0);
}
}

#[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_eq!(repeated.events.len(), 0);
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_eq!(output.events.len(), 0);
assert!(state.result.session_title.is_none());
}

#[test]
fn repeated_resumed_session_id_is_not_started_again() {
let adapter = Claude::default();
Expand Down
8 changes: 8 additions & 0 deletions src/providers/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,14 @@ impl AgentAdapter for Codex {
}
}

fn idle_frame_writes(&self, line: &str) -> Vec<Vec<u8>> {
if self.app_server_mode() {
codex_app_server::idle_frame_writes(line)
} else {
Vec::new()
}
}

fn interrupt_request(&self, state: &AdapterState) -> Option<Vec<u8>> {
self.app_server_mode()
.then(|| codex_app_server::interrupt(state))
Expand Down
Loading
Loading