Expose thread-level multi-agent mode (#28792)

## Why

Once multi-agent mode can be selected per turn, clients also need to
choose the initial selection when creating a thread and observe that
selection through lifecycle and settings APIs.

The selected value is intentionally distinct from the effective
model-visible value: no client selection is represented as `null`, even
though an eligible multi-agent v2 turn derives `explicitRequestOnly` as
its effective default.

## What changed

- Add the optional experimental `thread/start.multiAgentMode` parameter
and pass it through thread creation.
- Preserve an omitted initial value as an unset selection rather than
eagerly storing `explicitRequestOnly`.
- Apply an explicit `thread/start` selection to the first turn through
the session configuration established at thread creation.
- Restore the latest persisted effective mode as the selected baseline
on cold resume when rollout history contains one.
- Inherit the optional selected mode from a loaded parent when creating
related runtime threads.
- Return the current selected `multiAgentMode` from `thread/start`,
`thread/resume`, `thread/fork`, and thread settings, using `null` when
no mode is selected.
- Keep lifecycle reporting independent from model capability and feature
eligibility; core turn construction remains responsible for calculating
and persisting the effective mode.

## Not covered

- Clearing an existing loaded-session selection back to unset through
`turn/start`; omitted or `null` currently retains the session's
selection.
- A TUI control, slash command, or `config.toml` preference.

## Verification

- `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol`
- `CARGO_INCREMENTAL=0 just test -p codex-app-server multi_agent_mode`

The focused app-server coverage verifies explicit `thread/start`
initialization, first-turn prompting, nullable reporting for an omitted
selection, and retention of selections that are not currently
runtime-eligible.

## Stack

Stacked on #28685. This PR contains only the thread initialization and
lifecycle/settings API layer.
This commit is contained in:
Shijie Rao
2026-06-19 01:50:44 -07:00
committed by GitHub
Unverified
parent fc8c6b7384
commit 7abfcf220b
46 changed files with 620 additions and 36 deletions
+45 -17
View File
@@ -1992,6 +1992,8 @@ pub struct ThreadSettingsSnapshot {
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
#[serde(default)]
pub multi_agent_mode: Option<MultiAgentMode>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
@@ -2554,12 +2556,24 @@ impl InitialHistory {
}
}
pub fn get_multi_agent_mode(&self) -> Option<MultiAgentMode> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => multi_agent_mode_from_items(&resumed.history),
InitialHistory::Forked(items) => multi_agent_mode_from_items(items),
}
pub fn get_latest_effective_multi_agent_mode(&self) -> Option<MultiAgentMode> {
let items = match self {
InitialHistory::New | InitialHistory::Cleared => return None,
InitialHistory::Resumed(resumed) => &resumed.history,
InitialHistory::Forked(items) => items,
};
items
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => Some(turn_context),
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::EventMsg(_) => None,
})
.and_then(|turn_context| turn_context.multi_agent_mode)
}
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
@@ -2874,17 +2888,6 @@ fn multi_agent_version_from_items(
})
}
fn multi_agent_mode_from_items(items: &[RolloutItem]) -> Option<MultiAgentMode> {
items.iter().rev().find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_mode,
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::EventMsg(_) => None,
})
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -5399,6 +5402,31 @@ mod tests {
Ok(())
}
#[test]
fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> {
let turn_context_item = |multi_agent_mode| -> Result<RolloutItem> {
let mut value = json!({
"cwd": test_path_buf("/tmp"),
"approval_policy": "never",
"sandbox_policy": { "type": "danger-full-access" },
"model": "gpt-5",
"summary": "auto",
});
value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?;
Ok(RolloutItem::TurnContext(serde_json::from_value(value)?))
};
assert_eq!(
InitialHistory::Forked(vec![
turn_context_item(Some(MultiAgentMode::Proactive))?,
turn_context_item(/*multi_agent_mode*/ None)?,
])
.get_latest_effective_multi_agent_mode(),
None
);
Ok(())
}
#[test]
fn turn_context_item_serializes_network_when_present() -> Result<()> {
let item = TurnContextItem {