mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Merge remote-tracking branch 'origin/main' into pr-15478
This commit is contained in:
@@ -2343,13 +2343,27 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name-based selector.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Path-based selector."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"path"
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -11352,6 +11352,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"interface": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11377,6 +11380,7 @@
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"enabled",
|
||||
"name",
|
||||
"path"
|
||||
],
|
||||
@@ -11433,13 +11437,27 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name-based selector.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Path-based selector."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"path"
|
||||
"enabled"
|
||||
],
|
||||
"title": "SkillsConfigWriteParams",
|
||||
"type": "object"
|
||||
|
||||
@@ -9112,6 +9112,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"interface": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -9137,6 +9140,7 @@
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"enabled",
|
||||
"name",
|
||||
"path"
|
||||
],
|
||||
@@ -9193,13 +9197,27 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name-based selector.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Path-based selector."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"path"
|
||||
"enabled"
|
||||
],
|
||||
"title": "SkillsConfigWriteParams",
|
||||
"type": "object"
|
||||
|
||||
@@ -318,6 +318,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"interface": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -343,6 +346,7 @@
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"enabled",
|
||||
"name",
|
||||
"path"
|
||||
],
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name-based selector.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Path-based selector."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"path"
|
||||
"enabled"
|
||||
],
|
||||
"title": "SkillsConfigWriteParams",
|
||||
"type": "object"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SkillInterface } from "./SkillInterface";
|
||||
|
||||
export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, };
|
||||
export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, enabled: boolean, };
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type SkillsConfigWriteParams = { path: string, enabled: boolean, };
|
||||
export type SkillsConfigWriteParams = {
|
||||
/**
|
||||
* Path-based selector.
|
||||
*/
|
||||
path?: AbsolutePathBuf | null,
|
||||
/**
|
||||
* Name-based selector.
|
||||
*/
|
||||
name?: string | null, enabled: boolean, };
|
||||
|
||||
@@ -3340,6 +3340,7 @@ pub struct SkillSummary {
|
||||
pub short_description: Option<String>,
|
||||
pub interface: Option<SkillInterface>,
|
||||
pub path: PathBuf,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -3378,7 +3379,12 @@ pub enum PluginSource {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillsConfigWriteParams {
|
||||
pub path: PathBuf,
|
||||
/// Path-based selector.
|
||||
#[ts(optional = nullable)]
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
/// Name-based selector.
|
||||
#[ts(optional = nullable)]
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -164,10 +164,10 @@ Example with notification opt-out:
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
- `skills/config/write` — write user-level skill config by path.
|
||||
- `skills/config/write` — write user-level skill config by name or absolute path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
@@ -1145,14 +1145,29 @@ The server also emits `skills/changed` notifications when watched local skill fi
|
||||
}
|
||||
```
|
||||
|
||||
To enable or disable a skill by path:
|
||||
To enable or disable a skill by absolute path:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "skills/config/write",
|
||||
"id": 26,
|
||||
"params": {
|
||||
"path": "/Users/me/.codex/skills/skill-creator/SKILL.md",
|
||||
"path": "/Users/alice/.codex/skills/skill-creator/SKILL.md",
|
||||
"name": null,
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enable or disable a skill by name:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "skills/config/write",
|
||||
"id": 27,
|
||||
"params": {
|
||||
"path": null,
|
||||
"name": "github:yeet",
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5672,7 +5672,7 @@ impl CodexMessageProcessor {
|
||||
interface: outcome.plugin.interface.map(plugin_interface_to_info),
|
||||
},
|
||||
description: outcome.plugin.description,
|
||||
skills: plugin_skills_to_info(&visible_skills),
|
||||
skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths),
|
||||
apps: app_summaries,
|
||||
mcp_servers: outcome.plugin.mcp_server_names,
|
||||
};
|
||||
@@ -5687,8 +5687,30 @@ impl CodexMessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: SkillsConfigWriteParams,
|
||||
) {
|
||||
let SkillsConfigWriteParams { path, enabled } = params;
|
||||
let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }];
|
||||
let SkillsConfigWriteParams {
|
||||
path,
|
||||
name,
|
||||
enabled,
|
||||
} = params;
|
||||
let edit = match (path, name) {
|
||||
(Some(path), None) => ConfigEdit::SetSkillConfig {
|
||||
path: path.into_path_buf(),
|
||||
enabled,
|
||||
},
|
||||
(None, Some(name)) if !name.trim().is_empty() => {
|
||||
ConfigEdit::SetSkillConfigByName { name, enabled }
|
||||
}
|
||||
_ => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_PARAMS_ERROR_CODE,
|
||||
message: "skills/config/write requires exactly one of path or name".to_string(),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let edits = vec![edit];
|
||||
let result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
@@ -5696,6 +5718,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.thread_manager.plugins_manager().clear_cache();
|
||||
self.thread_manager.skills_manager().clear_cache();
|
||||
self.outgoing
|
||||
.send_response(
|
||||
@@ -7669,7 +7692,10 @@ fn skills_to_info(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec<SkillSummary> {
|
||||
fn plugin_skills_to_info(
|
||||
skills: &[codex_core::skills::SkillMetadata],
|
||||
disabled_skill_paths: &std::collections::HashSet<PathBuf>,
|
||||
) -> Vec<SkillSummary> {
|
||||
skills
|
||||
.iter()
|
||||
.map(|skill| SkillSummary {
|
||||
@@ -7687,6 +7713,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec<Sk
|
||||
}
|
||||
}),
|
||||
path: skill.path_to_skills_md.clone(),
|
||||
enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -162,6 +162,10 @@ description: Visible only for ChatGPT
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "demo-plugin:thread-summarizer"
|
||||
enabled = false
|
||||
|
||||
[plugins."demo-plugin@codex-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
@@ -244,6 +248,7 @@ enabled = true
|
||||
response.plugin.skills[0].description,
|
||||
"Summarize email threads"
|
||||
);
|
||||
assert!(!response.plugin.skills[0].enabled);
|
||||
assert_eq!(response.plugin.apps.len(), 1);
|
||||
assert_eq!(response.plugin.apps[0].id, "gmail");
|
||||
assert_eq!(response.plugin.apps[0].name, "gmail");
|
||||
|
||||
@@ -883,6 +883,24 @@ mod tests {
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> serde_json::Value {
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
plan_type,
|
||||
chatgpt_user_id,
|
||||
account_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
"2025-01-01T00:00:00Z",
|
||||
)
|
||||
}
|
||||
|
||||
fn chatgpt_auth_json_with_last_refresh(
|
||||
plan_type: &str,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
last_refresh: &str,
|
||||
) -> serde_json::Value {
|
||||
chatgpt_auth_json_with_mode(
|
||||
plan_type,
|
||||
@@ -890,6 +908,7 @@ mod tests {
|
||||
account_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
last_refresh,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -900,6 +919,7 @@ mod tests {
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
last_refresh: &str,
|
||||
auth_mode: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let header = json!({ "alg": "none", "typ": "JWT" });
|
||||
@@ -925,7 +945,7 @@ mod tests {
|
||||
"refresh_token": refresh_token,
|
||||
"account_id": account_id,
|
||||
},
|
||||
"last_refresh": "2025-01-01T00:00:00Z",
|
||||
"last_refresh": last_refresh,
|
||||
});
|
||||
if let Some(auth_mode) = auth_mode {
|
||||
auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string());
|
||||
@@ -1262,24 +1282,43 @@ enabled = false
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() {
|
||||
let auth = managed_auth_context(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
);
|
||||
let auth_home = tempdir().expect("tempdir");
|
||||
write_auth_json(
|
||||
auth._home.path(),
|
||||
chatgpt_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
// Keep auth "fresh" so the first request hits unauthorized recovery
|
||||
// instead of AuthManager::auth() proactively reloading from disk.
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write initial auth");
|
||||
let auth_manager = Arc::new(AuthManager::new(
|
||||
auth_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
));
|
||||
|
||||
write_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"fresh-access-token",
|
||||
"test-refresh-token",
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write refreshed auth");
|
||||
let auth = ManagedAuthContext {
|
||||
_home: auth_home,
|
||||
manager: auth_manager,
|
||||
};
|
||||
|
||||
let fetcher = Arc::new(TokenFetcher {
|
||||
expected_token: "fresh-access-token".to_string(),
|
||||
@@ -1314,24 +1353,41 @@ enabled = false
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() {
|
||||
let auth = managed_auth_context(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
);
|
||||
let auth_home = tempdir().expect("tempdir");
|
||||
write_auth_json(
|
||||
auth._home.path(),
|
||||
chatgpt_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write initial auth");
|
||||
let auth_manager = Arc::new(AuthManager::new(
|
||||
auth_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
));
|
||||
|
||||
write_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-99999"),
|
||||
Some("account-12345"),
|
||||
"fresh-access-token",
|
||||
"test-refresh-token",
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write refreshed auth");
|
||||
let auth = ManagedAuthContext {
|
||||
_home: auth_home,
|
||||
manager: auth_manager,
|
||||
};
|
||||
|
||||
let fetcher = Arc::new(TokenFetcher {
|
||||
expected_token: "fresh-access-token".to_string(),
|
||||
@@ -1432,6 +1488,7 @@ enabled = false
|
||||
Some("account-12345"),
|
||||
"test-access-token",
|
||||
"test-refresh-token",
|
||||
"2025-01-01T00:00:00Z",
|
||||
Some("chatgptAuthTokens"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1524,13 +1524,21 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name-based selector.",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Path-based selector."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"path"
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::agent::registry::AgentMetadata;
|
||||
use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
@@ -454,20 +456,53 @@ impl AgentControl {
|
||||
items: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
let result = state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if matches!(result, Err(CodexErr::InternalAgentDied)) {
|
||||
let _ = state.remove_thread(&agent_id).await;
|
||||
self.state.release_spawned_thread(agent_id);
|
||||
}
|
||||
result
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn append_message(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
message: ResponseItem,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state.append_message(agent_id, message).await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.deliver_inter_agent_instruction(agent_id, instruction, delivery)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Interrupt the current task for an existing agent thread.
|
||||
@@ -476,6 +511,19 @@ impl AgentControl {
|
||||
state.send_op(agent_id, Op::Interrupt).await
|
||||
}
|
||||
|
||||
async fn handle_thread_request_result(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
result: CodexResult<String>,
|
||||
) -> CodexResult<String> {
|
||||
if matches!(result, Err(CodexErr::InternalAgentDied)) {
|
||||
let _ = state.remove_thread(&agent_id).await;
|
||||
self.state.release_spawned_thread(agent_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Submit a shutdown request for a live agent without marking it explicitly closed in
|
||||
/// persisted spawn-edge state.
|
||||
pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
|
||||
@@ -382,6 +382,61 @@ async fn send_input_submits_user_message() {
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_message_records_assistant_message() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, thread) = harness.start_thread().await;
|
||||
let message =
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests";
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.append_message(
|
||||
thread_id,
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: message.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("append_message should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == message
|
||||
))
|
||||
)
|
||||
});
|
||||
if recorded {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("assistant message should be recorded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum InterAgentDelivery {
|
||||
CurrentTurn,
|
||||
NextTurn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct InterAgentInstruction {
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl InterAgentInstruction {
|
||||
pub(crate) fn new(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
author,
|
||||
recipient,
|
||||
other_recipients,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_response_item(&self) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: self.as_text(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_message_content(content: &[ContentItem]) -> bool {
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
Self::is_instruction_text(text)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn as_text(&self) -> String {
|
||||
let other_recipients = self
|
||||
.other_recipients
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}",
|
||||
self.author, self.recipient, self.content
|
||||
)
|
||||
}
|
||||
|
||||
fn is_instruction_text(text: &str) -> bool {
|
||||
text.starts_with("author: ")
|
||||
&& text.contains("\nrecipient: ")
|
||||
&& text.contains("\nother_recipients: [")
|
||||
&& text.contains("]\nContent: ")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) mod agent_resolver;
|
||||
pub(crate) mod control;
|
||||
pub(crate) mod inter_agent_instruction;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
+56
-25
@@ -798,6 +798,7 @@ pub(crate) struct Session {
|
||||
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) conversation: Arc<RealtimeConversationManager>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
|
||||
pub(crate) guardian_review_session: GuardianReviewSessionManager,
|
||||
pub(crate) services: SessionServices,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
@@ -809,6 +810,7 @@ pub(crate) struct TurnSkillsContext {
|
||||
pub(crate) outcome: Arc<SkillLoadOutcome>,
|
||||
pub(crate) implicit_invocation_seen_skills: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl TurnSkillsContext {
|
||||
pub(crate) fn new(outcome: Arc<SkillLoadOutcome>) -> Self {
|
||||
Self {
|
||||
@@ -1895,6 +1897,7 @@ impl Session {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
guardian_review_session: GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -3912,7 +3915,7 @@ impl Session {
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
/// Returns the input if there was no task running to inject into
|
||||
/// Returns the input if there was no task running to inject into.
|
||||
pub async fn inject_response_items(
|
||||
&self,
|
||||
input: Vec<ResponseInputItem>,
|
||||
@@ -3953,6 +3956,24 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue response items to be injected into the next active turn created for this session.
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut idle_pending_input = self.idle_pending_input.lock().await;
|
||||
idle_pending_input.extend(items);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
@@ -5441,7 +5462,7 @@ pub(crate) async fn run_turn(
|
||||
prewarmed_client_session: Option<ModelClientSession>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
if input.is_empty() {
|
||||
if input.is_empty() && !sess.has_pending_input().await {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -5583,25 +5604,33 @@ pub(crate) async fn run_turn(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
|
||||
let response_item: ResponseItem = initial_input_for_turn.clone().into();
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
if run_pending_session_start_hooks(&sess, &turn_context).await {
|
||||
return last_agent_message;
|
||||
return None;
|
||||
}
|
||||
let user_prompt_submit_outcome =
|
||||
run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message())
|
||||
.await;
|
||||
if user_prompt_submit_outcome.should_stop {
|
||||
record_additional_contexts(
|
||||
let additional_contexts = if input.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
|
||||
let response_item: ResponseItem = initial_input_for_turn.clone().into();
|
||||
let user_prompt_submit_outcome = run_user_prompt_submit_hooks(
|
||||
&sess,
|
||||
&turn_context,
|
||||
user_prompt_submit_outcome.additional_contexts,
|
||||
UserMessageItem::new(&input).message(),
|
||||
)
|
||||
.await;
|
||||
return last_agent_message;
|
||||
}
|
||||
let additional_contexts = user_prompt_submit_outcome.additional_contexts;
|
||||
if user_prompt_submit_outcome.should_stop {
|
||||
record_additional_contexts(
|
||||
&sess,
|
||||
&turn_context,
|
||||
user_prompt_submit_outcome.additional_contexts,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
user_prompt_submit_outcome.additional_contexts
|
||||
};
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_app_mentioned(tracking.clone(), mentioned_app_invocations);
|
||||
@@ -5612,17 +5641,17 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
|
||||
.await;
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
record_additional_contexts(&sess, &turn_context, additional_contexts).await;
|
||||
// Track the previous-turn baseline from the regular user-turn path only so
|
||||
// standalone tasks (compact/shell/review/undo) cannot suppress future
|
||||
// model/realtime injections.
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
if !input.is_empty() {
|
||||
// Track the previous-turn baseline from the regular user-turn path only so
|
||||
// standalone tasks (compact/shell/review/undo) cannot suppress future
|
||||
// model/realtime injections.
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
if !skill_items.is_empty() {
|
||||
sess.record_conversation_items(&turn_context, &skill_items)
|
||||
@@ -5633,8 +5662,10 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
}
|
||||
|
||||
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
.await;
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
let mut stop_hook_active = false;
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
|
||||
// Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with
|
||||
// the resume/fork hydration metadata derived from the same replay.
|
||||
@@ -201,9 +202,12 @@ impl Session {
|
||||
);
|
||||
}
|
||||
}
|
||||
RolloutItem::ResponseItem(_)
|
||||
| RolloutItem::EventMsg(_)
|
||||
| RolloutItem::SessionMeta(_) => {}
|
||||
RolloutItem::ResponseItem(response_item) => {
|
||||
let active_segment =
|
||||
active_segment.get_or_insert_with(ActiveReplaySegment::default);
|
||||
active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item);
|
||||
}
|
||||
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {}
|
||||
}
|
||||
|
||||
if base_replacement_history.is_some()
|
||||
|
||||
@@ -33,6 +33,18 @@ fn assistant_message(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings()
|
||||
{
|
||||
@@ -430,6 +442,96 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let first_context_item = turn_context.to_turn_context_item();
|
||||
let first_turn_id = first_context_item
|
||||
.turn_id
|
||||
.clone()
|
||||
.expect("turn context should have turn_id");
|
||||
let assistant_turn_id = "assistant-instruction-turn".to_string();
|
||||
let assistant_turn_context = TurnContextItem {
|
||||
turn_id: Some(assistant_turn_id.clone()),
|
||||
..first_context_item.clone()
|
||||
};
|
||||
let assistant_instruction = inter_agent_assistant_message(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let assistant_reply = assistant_message("worker reply");
|
||||
|
||||
let rollout_items = vec![
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
codex_protocol::protocol::UserMessageEvent {
|
||||
message: "turn 1 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
RolloutItem::ResponseItem(user_message("turn 1 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 1 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id: first_turn_id,
|
||||
last_agent_message: None,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: assistant_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(assistant_turn_context),
|
||||
RolloutItem::ResponseItem(assistant_instruction),
|
||||
RolloutItem::ResponseItem(assistant_reply),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id: assistant_turn_id,
|
||||
last_agent_message: None,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
)),
|
||||
];
|
||||
|
||||
let reconstructed = session
|
||||
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
reconstructed.history,
|
||||
vec![
|
||||
user_message("turn 1 user"),
|
||||
assistant_message("turn 1 assistant")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
reconstructed.previous_turn_settings,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reconstructed.reference_context_item)
|
||||
.expect("serialize reconstructed reference context item"),
|
||||
serde_json::to_value(Some(first_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding_user_turns() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -2682,6 +2682,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -3481,6 +3482,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -4631,6 +4633,32 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() {
|
||||
assert_eq!(sess.get_pending_input().await, vec![later, newer]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_response_items_for_next_turn_move_into_next_active_turn() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let queued_item = ResponseInputItem::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "queued before wake".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
sess.queue_response_items_for_next_turn(vec![queued_item.clone()])
|
||||
.await;
|
||||
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(sess.get_pending_input().await, vec![queued_item]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::SteerInputError;
|
||||
use crate::config::ConstraintResult;
|
||||
@@ -122,29 +124,90 @@ impl CodexThread {
|
||||
|
||||
/// Records a user-role session-prefix message without creating a new user turn boundary.
|
||||
pub(crate) async fn inject_user_message_without_turn(&self, message: String) {
|
||||
let pending_item = ResponseInputItem::Message {
|
||||
let message = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: message }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
let pending_items = vec![pending_item];
|
||||
let Err(items_without_active_turn) = self
|
||||
let pending_item = match pending_message_input_item(&message) {
|
||||
Ok(pending_item) => pending_item,
|
||||
Err(err) => {
|
||||
debug_assert!(false, "session-prefix message append should succeed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self
|
||||
.codex
|
||||
.session
|
||||
.inject_response_items(pending_items)
|
||||
.inject_response_items(vec![pending_item])
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
.is_err()
|
||||
{
|
||||
let turn_context = self.codex.session.new_default_turn().await;
|
||||
self.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[message])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let turn_context = self.codex.session.new_default_turn().await;
|
||||
let items: Vec<ResponseItem> = items_without_active_turn
|
||||
.into_iter()
|
||||
.map(ResponseItem::from)
|
||||
.collect();
|
||||
/// Append a prebuilt message to the thread history without treating it as a user turn.
|
||||
///
|
||||
/// If the thread already has an active turn, the message is queued as pending input for that
|
||||
/// turn. Otherwise it is queued at session scope and a regular turn is started so the agent
|
||||
/// can consume that pending input through the normal turn pipeline.
|
||||
pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult<String> {
|
||||
let submission_id = uuid::Uuid::new_v4().to_string();
|
||||
let pending_item = pending_message_input_item(&message)?;
|
||||
if let Err(items) = self
|
||||
.codex
|
||||
.session
|
||||
.inject_response_items(vec![pending_item])
|
||||
.await
|
||||
{
|
||||
self.codex
|
||||
.session
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
self.codex
|
||||
.session
|
||||
.ensure_task_for_queued_response_items()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let message = instruction.to_response_item();
|
||||
match delivery {
|
||||
InterAgentDelivery::CurrentTurn => self.append_message(message).await,
|
||||
InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a prebuilt message so the next turn records it before any submitted user input.
|
||||
pub(crate) async fn queue_message_for_next_turn(
|
||||
&self,
|
||||
message: ResponseItem,
|
||||
) -> CodexResult<String> {
|
||||
let submission_id = uuid::Uuid::new_v4().to_string();
|
||||
let pending_item = pending_message_input_item(&message)?;
|
||||
self.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &items)
|
||||
.queue_response_items_for_next_turn(vec![pending_item])
|
||||
.await;
|
||||
self.codex
|
||||
.session
|
||||
.ensure_task_for_queued_response_items()
|
||||
.await;
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub fn rollout_path(&self) -> Option<PathBuf> {
|
||||
@@ -198,3 +261,15 @@ impl CodexThread {
|
||||
Ok(*guard)
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {
|
||||
match message {
|
||||
ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message {
|
||||
role: role.clone(),
|
||||
content: content.clone(),
|
||||
}),
|
||||
_ => Err(CodexErr::InvalidRequest(
|
||||
"append_message only supports ResponseItem::Message".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ pub enum ConfigEdit {
|
||||
RecordModelMigrationSeen { from: String, to: String },
|
||||
/// Replace the entire `[mcp_servers]` table.
|
||||
ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
|
||||
/// Set or clear a skill config entry under `[[skills.config]]`.
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by path.
|
||||
SetSkillConfig { path: PathBuf, enabled: bool },
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by name.
|
||||
SetSkillConfigByName { name: String, enabled: bool },
|
||||
/// Set trust_level under `[projects."<path>"]`,
|
||||
/// migrating inline tables to explicit tables.
|
||||
SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
|
||||
@@ -60,6 +62,12 @@ pub enum ConfigEdit {
|
||||
ClearPath { segments: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum SkillConfigSelector {
|
||||
Name(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].theme = "<name>"`.
|
||||
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
|
||||
ConfigEdit::SetPath {
|
||||
@@ -387,7 +395,10 @@ impl ConfigDocument {
|
||||
)),
|
||||
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
|
||||
ConfigEdit::SetSkillConfig { path, enabled } => {
|
||||
Ok(self.set_skill_config(path.as_path(), *enabled))
|
||||
Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled))
|
||||
}
|
||||
ConfigEdit::SetSkillConfigByName { name, enabled } => {
|
||||
Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))
|
||||
}
|
||||
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
|
||||
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
|
||||
@@ -478,8 +489,16 @@ impl ConfigDocument {
|
||||
true
|
||||
}
|
||||
|
||||
fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool {
|
||||
let normalized_path = normalize_skill_config_path(path);
|
||||
fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool {
|
||||
let selector = match selector {
|
||||
SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()),
|
||||
SkillConfigSelector::Path(path) => {
|
||||
SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path)))
|
||||
}
|
||||
};
|
||||
if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
let mut remove_skills_table = false;
|
||||
let mut mutated = false;
|
||||
|
||||
@@ -538,12 +557,8 @@ impl ConfigDocument {
|
||||
};
|
||||
|
||||
let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {
|
||||
table
|
||||
.get("path")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(Path::new)
|
||||
.map(normalize_skill_config_path)
|
||||
.filter(|value| *value == normalized_path)
|
||||
skill_config_selector_from_table(table)
|
||||
.filter(|value| value == &selector)
|
||||
.map(|_| idx)
|
||||
});
|
||||
|
||||
@@ -561,7 +576,7 @@ impl ConfigDocument {
|
||||
} else if let Some(index) = existing_index {
|
||||
for (idx, table) in overrides.iter_mut().enumerate() {
|
||||
if idx == index {
|
||||
table["path"] = value(normalized_path);
|
||||
write_skill_config_selector(table, &selector);
|
||||
table["enabled"] = value(false);
|
||||
mutated = true;
|
||||
break;
|
||||
@@ -570,7 +585,7 @@ impl ConfigDocument {
|
||||
} else {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
entry["path"] = value(normalized_path);
|
||||
write_skill_config_selector(&mut entry, &selector);
|
||||
entry["enabled"] = value(false);
|
||||
overrides.push(entry);
|
||||
mutated = true;
|
||||
@@ -699,6 +714,38 @@ fn normalize_skill_config_path(path: &Path) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn skill_config_selector_from_table(table: &TomlTable) -> Option<SkillConfigSelector> {
|
||||
let path = table
|
||||
.get("path")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(Path::new)
|
||||
.map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path))));
|
||||
let name = table
|
||||
.get("name")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(|name| SkillConfigSelector::Name(name.to_string()));
|
||||
|
||||
match (path, name) {
|
||||
(Some(selector), None) | (None, Some(selector)) => Some(selector),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) {
|
||||
match selector {
|
||||
SkillConfigSelector::Name(name) => {
|
||||
table.remove("path");
|
||||
table["name"] = value(name.clone());
|
||||
}
|
||||
SkillConfigSelector::Path(path) => {
|
||||
table.remove("name");
|
||||
table["path"] = value(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist edits using a blocking strategy.
|
||||
pub fn apply_blocking(
|
||||
codex_home: &Path,
|
||||
|
||||
@@ -110,6 +110,27 @@ enabled = false
|
||||
assert_eq!(contents, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_skill_config_writes_name_selector_entry() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits([ConfigEdit::SetSkillConfigByName {
|
||||
name: "github:yeet".to_string(),
|
||||
enabled: false,
|
||||
}])
|
||||
.apply_blocking()
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_model_preserves_inline_table_contents() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
||||
@@ -804,7 +804,12 @@ impl Notice {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillConfig {
|
||||
pub path: AbsolutePathBuf,
|
||||
/// Path-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
/// Name-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,47 @@ fn deserialize_server_config_with_tool_filters() {
|
||||
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_name_selector() {
|
||||
let cfg: SkillConfig = toml::from_str(
|
||||
r#"
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize skill config with name selector");
|
||||
|
||||
assert_eq!(cfg.name.as_deref(), Some("github:yeet"));
|
||||
assert_eq!(cfg.path, None);
|
||||
assert!(!cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_path_selector() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let cfg: SkillConfig = toml::from_str(&format!(
|
||||
r#"
|
||||
path = {path:?}
|
||||
enabled = false
|
||||
"#,
|
||||
path = skill_path.display().to_string(),
|
||||
))
|
||||
.expect("should deserialize skill config with path selector");
|
||||
|
||||
assert_eq!(
|
||||
cfg,
|
||||
SkillConfig {
|
||||
path: Some(
|
||||
AbsolutePathBuf::from_absolute_path(&skill_path)
|
||||
.expect("skill path should be absolute"),
|
||||
),
|
||||
name: None,
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_command_and_url() {
|
||||
toml::from_str::<McpServerConfig>(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
@@ -204,9 +205,10 @@ impl ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the last `num_turns` user turns from this history.
|
||||
/// Drop the last `num_turns` instruction turns from this history.
|
||||
///
|
||||
/// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`.
|
||||
/// Instruction turns are history messages that should behave like a new prompt boundary:
|
||||
/// ordinary user messages and structured assistant inter-agent instructions.
|
||||
///
|
||||
/// This mirrors thread-rollback semantics:
|
||||
/// - `num_turns == 0` is a no-op
|
||||
@@ -248,7 +250,7 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
fn get_non_last_reasoning_items_tokens(&self) -> i64 {
|
||||
// Get reasoning items excluding all the ones after the last user message.
|
||||
// Get reasoning items excluding all the ones after the last instruction boundary.
|
||||
let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else {
|
||||
return 0;
|
||||
};
|
||||
@@ -631,7 +633,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
role == "user" && !is_contextual_user_message_content(content)
|
||||
(role == "user" && !is_contextual_user_message_content(content))
|
||||
|| (role == "assistant" && is_inter_agent_instruction_content(content))
|
||||
}
|
||||
|
||||
fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool {
|
||||
InterAgentInstruction::is_message_content(content)
|
||||
}
|
||||
|
||||
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
|
||||
|
||||
@@ -38,6 +38,18 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_history_with_items(items: Vec<ResponseItem>) -> ContextManager {
|
||||
let mut h = ContextManager::new();
|
||||
// Use a generous but fixed token budget; tests only rely on truncation
|
||||
@@ -225,6 +237,35 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
let item = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
|
||||
assert!(is_user_turn_boundary(&item));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
|
||||
let first_turn = user_input_text_msg("first");
|
||||
let first_reply = assistant_msg("done");
|
||||
let inter_agent_turn = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let inter_agent_reply = assistant_msg("worker reply");
|
||||
let mut history = create_history_with_items(vec![
|
||||
first_turn.clone(),
|
||||
first_reply.clone(),
|
||||
inter_agent_turn,
|
||||
inter_agent_reply,
|
||||
]);
|
||||
|
||||
history.drop_last_n_user_turns(1);
|
||||
|
||||
assert_eq!(history.raw_items(), &vec![first_turn, first_reply]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_token_usage_includes_all_items_after_last_model_generated_item() {
|
||||
let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]);
|
||||
|
||||
@@ -72,7 +72,7 @@ fn parse_agent_message(
|
||||
let mut content: Vec<AgentMessageContent> = Vec::new();
|
||||
for content_item in message.iter() {
|
||||
match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
content.push(AgentMessageContent::Text { text: text.clone() });
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -95,6 +95,43 @@ fn skips_local_image_label_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_assistant_message_input_text_for_backward_compatibility() {
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
|
||||
let turn_item = parse_turn_item(&item).expect("expected assistant message turn item");
|
||||
|
||||
match turn_item {
|
||||
TurnItem::AgentMessage(message) => {
|
||||
let rendered = message
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|content| {
|
||||
let AgentMessageContent::Text { text } = content;
|
||||
text
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec![
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
other => panic!("expected TurnItem::AgentMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unnamed_image_label_text() {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
|
||||
@@ -577,6 +577,7 @@ async fn wait_for_guardian_review(
|
||||
) -> (GuardianReviewSessionOutcome, bool) {
|
||||
let timeout = tokio::time::sleep_until(deadline);
|
||||
tokio::pin!(timeout);
|
||||
let mut last_error_message: Option<String> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -598,11 +599,22 @@ async fn wait_for_guardian_review(
|
||||
match event {
|
||||
Ok(event) => match event.msg {
|
||||
EventMsg::TurnComplete(turn_complete) => {
|
||||
if turn_complete.last_agent_message.is_none()
|
||||
&& let Some(error_message) = last_error_message
|
||||
{
|
||||
return (
|
||||
GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))),
|
||||
true,
|
||||
);
|
||||
}
|
||||
return (
|
||||
GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
EventMsg::Error(error) => {
|
||||
last_error_message = Some(error.message);
|
||||
}
|
||||
EventMsg::TurnAborted(_) => {
|
||||
return (GuardianReviewSessionOutcome::Aborted, true);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_response_once;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
@@ -717,6 +718,100 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let error_message =
|
||||
"Item 'rs_test' of type 'reasoning' was provided without its required following item.";
|
||||
let _request_log = mount_response_once(
|
||||
&server,
|
||||
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": error_message,
|
||||
"type": "invalid_request_error",
|
||||
"param": "input"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (mut session, mut turn, rx) = crate::codex::make_session_and_context_with_rx().await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||
config.user_instructions = None;
|
||||
let config = Arc::new(config);
|
||||
let models_manager = Arc::new(test_support::models_manager_with_provider(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&session.services.auth_manager),
|
||||
config.model_provider.clone(),
|
||||
));
|
||||
Arc::get_mut(&mut session)
|
||||
.expect("session should be uniquely owned")
|
||||
.services
|
||||
.models_manager = models_manager;
|
||||
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
|
||||
turn_mut.config = Arc::clone(&config);
|
||||
turn_mut.provider = config.model_provider.clone();
|
||||
turn_mut.user_instructions = None;
|
||||
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-error".to_string(),
|
||||
command: vec!["git".to_string(), "push".to_string()],
|
||||
cwd: PathBuf::from("/repo/codex-rs/core"),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let mut denial_rationales = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event.msg {
|
||||
EventMsg::Warning(event) => warnings.push(event.message),
|
||||
EventMsg::GuardianAssessment(event)
|
||||
if event.status == GuardianAssessmentStatus::Denied =>
|
||||
{
|
||||
denial_rationales.push(event.rationale)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|message| message.contains(error_message)),
|
||||
"warning should include the underlying responses api error"
|
||||
);
|
||||
assert!(
|
||||
denial_rationales
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|message| message.contains(error_message)),
|
||||
"denial rationale should include the underlying responses api error"
|
||||
);
|
||||
assert!(
|
||||
denial_rationales.iter().flatten().all(|message| {
|
||||
!message.contains("guardian review completed without an assessment payload")
|
||||
}),
|
||||
"denial rationale should not fall back to the generic missing payload error"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
|
||||
let first_assessment = serde_json::json!({
|
||||
|
||||
@@ -19,7 +19,6 @@ use super::remote::fetch_remote_featured_plugin_ids;
|
||||
use super::remote::fetch_remote_plugin_status;
|
||||
use super::remote::uninstall_remote_plugin;
|
||||
use super::startup_sync::start_startup_remote_plugin_sync_once;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use super::store::PluginInstallResult as StorePluginInstallResult;
|
||||
@@ -38,6 +37,9 @@ use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -152,6 +154,7 @@ pub struct PluginDetail {
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
}
|
||||
@@ -183,6 +186,8 @@ pub struct LoadedPlugin {
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
@@ -235,7 +240,7 @@ impl PluginCapabilitySummary {
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: !plugin.skill_roots.is_empty(),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
@@ -258,11 +263,16 @@ impl PluginCapabilitySummary {
|
||||
|
||||
impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetail) -> Self {
|
||||
let has_skills = value.skills.iter().any(|skill| {
|
||||
!value
|
||||
.disabled_skill_paths
|
||||
.contains(&skill.path_to_skills_md)
|
||||
});
|
||||
Self {
|
||||
config_name: value.id,
|
||||
display_name: value.name,
|
||||
description: prompt_safe_plugin_description(value.description.as_deref()),
|
||||
has_skills: !value.skills.is_empty(),
|
||||
has_skills,
|
||||
mcp_server_names: value.mcp_server_names,
|
||||
app_connector_ids: value.apps,
|
||||
}
|
||||
@@ -531,7 +541,11 @@ impl PluginsManager {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let outcome = load_plugins_from_layer_stack(&config.config_layer_stack, &self.store);
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
&config.config_layer_stack,
|
||||
&self.store,
|
||||
self.restriction_product,
|
||||
);
|
||||
log_plugin_load_errors(&outcome);
|
||||
let mut cache = match self.cached_enabled_outcome.write() {
|
||||
Ok(cache) => cache,
|
||||
@@ -1070,6 +1084,11 @@ impl PluginsManager {
|
||||
let source_path = match &plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path.clone(),
|
||||
};
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(MarketplaceError::InvalidPlugin(
|
||||
"path does not exist or is not a directory".to_string(),
|
||||
));
|
||||
}
|
||||
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(
|
||||
"missing or invalid .codex-plugin/plugin.json".to_string(),
|
||||
@@ -1077,15 +1096,13 @@ impl PluginsManager {
|
||||
})?;
|
||||
let description = manifest.description.clone();
|
||||
let manifest_paths = &manifest.paths;
|
||||
let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths);
|
||||
let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}))
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product))
|
||||
.collect();
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
source_path.as_path(),
|
||||
manifest_paths,
|
||||
self.restriction_product,
|
||||
&skill_config_rules,
|
||||
);
|
||||
let apps = load_plugin_apps(source_path.as_path());
|
||||
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths);
|
||||
let mut mcp_server_names = Vec::new();
|
||||
@@ -1111,7 +1128,8 @@ impl PluginsManager {
|
||||
interface: plugin.interface,
|
||||
installed: installed_plugins.contains(&plugin_key),
|
||||
enabled: enabled_plugins.contains(&plugin_key),
|
||||
skills,
|
||||
skills: resolved_skills.skills,
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
apps,
|
||||
mcp_server_names,
|
||||
},
|
||||
@@ -1347,7 +1365,9 @@ struct PluginAppConfig {
|
||||
pub(crate) fn load_plugins_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
store: &PluginStore,
|
||||
restriction_product: Option<Product>,
|
||||
) -> PluginLoadOutcome {
|
||||
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
|
||||
let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack)
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -1356,7 +1376,13 @@ pub(crate) fn load_plugins_from_layer_stack(
|
||||
let mut plugins = Vec::with_capacity(configured_plugins.len());
|
||||
let mut seen_mcp_server_names = HashMap::<String, String>::new();
|
||||
for (configured_name, plugin) in configured_plugins {
|
||||
let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store);
|
||||
let loaded_plugin = load_plugin(
|
||||
configured_name.clone(),
|
||||
&plugin,
|
||||
store,
|
||||
restriction_product,
|
||||
&skill_config_rules,
|
||||
);
|
||||
for name in loaded_plugin.mcp_servers.keys() {
|
||||
if let Some(previous_plugin) =
|
||||
seen_mcp_server_names.insert(name.clone(), configured_name.clone())
|
||||
@@ -1463,16 +1489,24 @@ fn configured_plugins_from_stack(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin {
|
||||
let plugin_root = PluginId::parse(&config_name).map(|plugin_id| {
|
||||
store
|
||||
.active_plugin_root(&plugin_id)
|
||||
.unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION))
|
||||
});
|
||||
let root = match &plugin_root {
|
||||
Ok(plugin_root) => plugin_root.clone(),
|
||||
Err(_) => store.root().clone(),
|
||||
};
|
||||
fn load_plugin(
|
||||
config_name: String,
|
||||
plugin: &PluginConfig,
|
||||
store: &PluginStore,
|
||||
restriction_product: Option<Product>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> LoadedPlugin {
|
||||
let plugin_id = PluginId::parse(&config_name);
|
||||
let active_plugin_root = plugin_id
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|plugin_id| store.active_plugin_root(plugin_id));
|
||||
let root = active_plugin_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| match &plugin_id {
|
||||
Ok(plugin_id) => store.plugin_base_root(plugin_id),
|
||||
Err(_) => store.root().clone(),
|
||||
});
|
||||
let mut loaded_plugin = LoadedPlugin {
|
||||
config_name,
|
||||
manifest_name: None,
|
||||
@@ -1480,6 +1514,8 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
root,
|
||||
enabled: plugin.enabled,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
@@ -1489,8 +1525,14 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let plugin_root = match plugin_root {
|
||||
Ok(plugin_root) => plugin_root,
|
||||
let plugin_root = match plugin_id {
|
||||
Ok(_) => match active_plugin_root {
|
||||
Some(plugin_root) => plugin_root,
|
||||
None => {
|
||||
loaded_plugin.error = Some("plugin is not installed".to_string());
|
||||
return loaded_plugin;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
loaded_plugin.error = Some(err.to_string());
|
||||
return loaded_plugin;
|
||||
@@ -1511,6 +1553,15 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
loaded_plugin.manifest_name = Some(manifest.name.clone());
|
||||
loaded_plugin.manifest_description = manifest.description.clone();
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
plugin_root.as_path(),
|
||||
manifest_paths,
|
||||
restriction_product,
|
||||
skill_config_rules,
|
||||
);
|
||||
let has_enabled_skills = resolved_skills.has_enabled_skills();
|
||||
loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths;
|
||||
loaded_plugin.has_enabled_skills = has_enabled_skills;
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path);
|
||||
@@ -1530,6 +1581,52 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
loaded_plugin
|
||||
}
|
||||
|
||||
struct ResolvedPluginSkills {
|
||||
skills: Vec<SkillMetadata>,
|
||||
disabled_skill_paths: HashSet<PathBuf>,
|
||||
had_errors: bool,
|
||||
}
|
||||
|
||||
impl ResolvedPluginSkills {
|
||||
fn has_enabled_skills(&self) -> bool {
|
||||
// Keep the plugin visible in capability summaries if skill loading was partial.
|
||||
self.had_errors
|
||||
|| self
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_plugin_skills(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
restriction_product: Option<Product>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> ResolvedPluginSkills {
|
||||
let outcome = load_skills_from_roots(
|
||||
plugin_skill_roots(plugin_root, manifest_paths)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
let had_errors = !outcome.errors.is_empty();
|
||||
let skills = outcome
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|skill| skill.matches_product_restriction_for_product(restriction_product))
|
||||
.collect::<Vec<_>>();
|
||||
let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules);
|
||||
|
||||
ResolvedPluginSkills {
|
||||
skills,
|
||||
disabled_skill_paths,
|
||||
had_errors,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_skill_roots(plugin_root: &Path, manifest_paths: &PluginManifestPaths) -> Vec<PathBuf> {
|
||||
let mut paths = default_skill_roots(plugin_root);
|
||||
if let Some(path) = &manifest_paths.skills {
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::protocol::Product;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
@@ -139,6 +140,8 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
|
||||
enabled: true,
|
||||
skill_roots: vec![plugin_root.join("skills")],
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: true,
|
||||
mcp_servers: HashMap::from([(
|
||||
"sample".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -185,6 +188,89 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
let skill_path = plugin_root.join("skills/sample-search/SKILL.md");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(
|
||||
&skill_path,
|
||||
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
||||
);
|
||||
|
||||
let config_toml = r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:sample-search"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].disabled_skill_paths,
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
assert!(!outcome.plugins[0].has_enabled_skills);
|
||||
assert!(outcome.capability_summaries().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_ignores_unknown_disabled_skill_names() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/sample-search/SKILL.md"),
|
||||
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
||||
);
|
||||
|
||||
let config_toml = r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:missing-skill"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
|
||||
assert!(outcome.plugins[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins[0].has_enabled_skills);
|
||||
assert_eq!(
|
||||
outcome.capability_summaries(),
|
||||
&[PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_telemetry_metadata_uses_default_mcp_config_path() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -540,6 +626,8 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
||||
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
||||
enabled: false,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
@@ -651,6 +739,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
|
||||
enabled: true,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
@@ -664,6 +754,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
let outcome = PluginLoadOutcome::from_plugins(vec![
|
||||
LoadedPlugin {
|
||||
skill_roots: vec![codex_home.path().join("skills-plugin/skills")],
|
||||
has_enabled_skills: true,
|
||||
..plugin("skills@test", "skills-plugin", "skills-plugin")
|
||||
},
|
||||
LoadedPlugin {
|
||||
@@ -1166,6 +1257,70 @@ enabled = true
|
||||
assert!(matches!(err, MarketplaceError::PluginsDisabled));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("enabled-plugin");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "enabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./enabled-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"enabled-plugin"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/sample-search/SKILL.md"),
|
||||
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."enabled-plugin@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
write_file(
|
||||
&repo_root.join(".codex/config.toml"),
|
||||
r#"[[skills.config]]
|
||||
name = "enabled-plugin:sample-search"
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.read_plugin_for_config(
|
||||
&config,
|
||||
&PluginReadRequest {
|
||||
plugin_name: "enabled-plugin".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.plugin.disabled_skill_paths.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2082,8 +2237,11 @@ fn load_plugins_ignores_project_config_files() {
|
||||
)
|
||||
.expect("config layer stack should build");
|
||||
|
||||
let outcome =
|
||||
load_plugins_from_layer_stack(&stack, &PluginStore::new(codex_home.path().to_path_buf()));
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
&stack,
|
||||
&PluginStore::new(codex_home.path().to_path_buf()),
|
||||
Some(Product::Codex),
|
||||
);
|
||||
|
||||
assert_eq!(outcome, PluginLoadOutcome::default());
|
||||
}
|
||||
|
||||
@@ -110,10 +110,15 @@ impl PluginStore {
|
||||
.filter(|version| validate_plugin_segment(version, "plugin version").is_ok())
|
||||
.collect::<Vec<_>>();
|
||||
discovered_versions.sort_unstable();
|
||||
if discovered_versions.len() == 1 {
|
||||
discovered_versions.pop()
|
||||
} else {
|
||||
if discovered_versions.is_empty() {
|
||||
None
|
||||
} else if discovered_versions
|
||||
.iter()
|
||||
.any(|version| version == DEFAULT_PLUGIN_VERSION)
|
||||
{
|
||||
Some(DEFAULT_PLUGIN_VERSION.to_string())
|
||||
} else {
|
||||
discovered_versions.pop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,50 @@ fn active_plugin_version_reads_version_directory_name() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/fedcba9876543210",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("fedcba9876543210".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_rejects_path_separators_in_key_segments() {
|
||||
let err = PluginId::parse("../../etc@debug").unwrap_err();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::types::SkillConfig;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub(crate) enum SkillConfigRuleSelector {
|
||||
Name(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRule {
|
||||
pub selector: SkillConfigRuleSelector,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRules {
|
||||
pub entries: Vec<SkillConfigRule>,
|
||||
}
|
||||
|
||||
pub(crate) fn skill_config_rules_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> SkillConfigRules {
|
||||
let mut entries = Vec::new();
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
if !matches!(
|
||||
layer.name,
|
||||
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(skills_value) = layer.config.get("skills") else {
|
||||
continue;
|
||||
};
|
||||
let skills: SkillsConfig = match skills_value.clone().try_into() {
|
||||
Ok(skills) => skills,
|
||||
Err(err) => {
|
||||
warn!("invalid skills config: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in skills.config {
|
||||
let Some(selector) = skill_config_rule_selector(&entry) else {
|
||||
continue;
|
||||
};
|
||||
// Preserve layer order so a later name selector can override an earlier path selector
|
||||
// for the same loaded skill.
|
||||
entries.retain(|entry: &SkillConfigRule| entry.selector != selector);
|
||||
entries.push(SkillConfigRule {
|
||||
selector,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SkillConfigRules { entries }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_disabled_skill_paths(
|
||||
skills: &[SkillMetadata],
|
||||
rules: &SkillConfigRules,
|
||||
) -> HashSet<PathBuf> {
|
||||
let mut disabled_paths = HashSet::new();
|
||||
|
||||
for entry in &rules.entries {
|
||||
match &entry.selector {
|
||||
SkillConfigRuleSelector::Path(path) => {
|
||||
if entry.enabled {
|
||||
disabled_paths.remove(path);
|
||||
} else {
|
||||
disabled_paths.insert(path.clone());
|
||||
}
|
||||
}
|
||||
SkillConfigRuleSelector::Name(name) => {
|
||||
for path in skills
|
||||
.iter()
|
||||
.filter(|skill| skill.name == *name)
|
||||
.map(|skill| skill.path_to_skills_md.clone())
|
||||
{
|
||||
if entry.enabled {
|
||||
disabled_paths.remove(&path);
|
||||
} else {
|
||||
disabled_paths.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disabled_paths
|
||||
}
|
||||
|
||||
fn skill_config_rule_selector(entry: &SkillConfig) -> Option<SkillConfigRuleSelector> {
|
||||
match (entry.path.as_ref(), entry.name.as_deref()) {
|
||||
(Some(path), None) => Some(SkillConfigRuleSelector::Path(normalize_rule_path(
|
||||
path.as_path(),
|
||||
))),
|
||||
(None, Some(name)) => {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
warn!("ignoring empty skills.config name override");
|
||||
None
|
||||
} else {
|
||||
Some(SkillConfigRuleSelector::Name(name.to_string()))
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
warn!("ignoring skills.config entry with both path and name selectors");
|
||||
None
|
||||
}
|
||||
(None, None) => {
|
||||
warn!("ignoring skills.config entry without a path or name selector");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_rule_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -16,12 +15,14 @@ use tracing::warn;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::build_implicit_skill_path_indexes;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::skill_roots;
|
||||
@@ -81,15 +82,13 @@ impl SkillsManager {
|
||||
/// to share a directory.
|
||||
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(config);
|
||||
let cache_key = config_skills_cache_key(&roots, &config.config_layer_stack);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack);
|
||||
let cache_key = config_skills_cache_key(&roots, &skill_config_rules);
|
||||
if let Some(outcome) = self.cached_outcome_for_config(&cache_key) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let outcome = crate::skills::filter_skill_load_outcome_for_product(
|
||||
finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack),
|
||||
self.restriction_product,
|
||||
);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_config
|
||||
.write()
|
||||
@@ -192,7 +191,8 @@ impl SkillsManager {
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
let outcome = self.build_skill_outcome(roots, &config_layer_stack);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
@@ -204,12 +204,14 @@ impl SkillsManager {
|
||||
fn build_skill_outcome(
|
||||
&self,
|
||||
roots: Vec<SkillRoot>,
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> SkillLoadOutcome {
|
||||
crate::skills::filter_skill_load_outcome_for_product(
|
||||
finalize_skill_outcome(load_skills_from_roots(roots), config_layer_stack),
|
||||
let outcome = crate::skills::filter_skill_load_outcome_for_product(
|
||||
load_skills_from_roots(roots),
|
||||
self.restriction_product,
|
||||
)
|
||||
);
|
||||
let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules);
|
||||
finalize_skill_outcome(outcome, disabled_paths)
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) {
|
||||
@@ -256,7 +258,7 @@ impl SkillsManager {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ConfigSkillsCacheKey {
|
||||
roots: Vec<(PathBuf, u8)>,
|
||||
disabled_paths: Vec<PathBuf>,
|
||||
skill_config_rules: SkillConfigRules,
|
||||
}
|
||||
|
||||
pub(crate) fn bundled_skills_enabled_from_stack(
|
||||
@@ -281,53 +283,10 @@ pub(crate) fn bundled_skills_enabled_from_stack(
|
||||
skills.bundled.unwrap_or_default().enabled
|
||||
}
|
||||
|
||||
fn disabled_paths_from_stack(
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
) -> HashSet<PathBuf> {
|
||||
let mut configs = HashMap::new();
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
if !matches!(
|
||||
layer.name,
|
||||
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(skills_value) = layer.config.get("skills") else {
|
||||
continue;
|
||||
};
|
||||
let skills: SkillsConfig = match skills_value.clone().try_into() {
|
||||
Ok(skills) => skills,
|
||||
Err(err) => {
|
||||
warn!("invalid skills config: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in skills.config {
|
||||
let path = normalize_override_path(entry.path.as_path());
|
||||
configs.insert(path, entry.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
configs
|
||||
.into_iter()
|
||||
.filter_map(|(path, enabled)| (!enabled).then_some(path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn config_skills_cache_key(
|
||||
roots: &[SkillRoot],
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> ConfigSkillsCacheKey {
|
||||
let mut disabled_paths: Vec<PathBuf> = disabled_paths_from_stack(config_layer_stack)
|
||||
.into_iter()
|
||||
.collect();
|
||||
disabled_paths.sort_unstable();
|
||||
|
||||
ConfigSkillsCacheKey {
|
||||
roots: roots
|
||||
.iter()
|
||||
@@ -341,15 +300,15 @@ fn config_skills_cache_key(
|
||||
(root.path.clone(), scope_rank)
|
||||
})
|
||||
.collect(),
|
||||
disabled_paths,
|
||||
skill_config_rules: skill_config_rules.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_skill_outcome(
|
||||
mut outcome: SkillLoadOutcome,
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
disabled_paths: HashSet<PathBuf>,
|
||||
) -> SkillLoadOutcome {
|
||||
outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack);
|
||||
outcome.disabled_paths = disabled_paths;
|
||||
let (by_scripts_dir, by_doc_path) =
|
||||
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
|
||||
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
|
||||
@@ -357,10 +316,6 @@ fn finalize_skill_outcome(
|
||||
outcome
|
||||
}
|
||||
|
||||
fn normalize_override_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut normalized: Vec<PathBuf> = extra_user_roots
|
||||
.iter()
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -17,6 +21,49 @@ fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &s
|
||||
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_plugin_skill(
|
||||
codex_home: &TempDir,
|
||||
marketplace: &str,
|
||||
plugin_name: &str,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> PathBuf {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace)
|
||||
.join(plugin_name)
|
||||
.join("local");
|
||||
let skill_dir = plugin_root.join("skills").join(dir);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
fs::write(&skill_path, content).unwrap();
|
||||
skill_path
|
||||
}
|
||||
|
||||
fn test_skill(name: &str, path: PathBuf) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "test".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile: None,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: path,
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
@@ -68,6 +115,68 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
assert_eq!(outcome2.skills, outcome1.skills);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_disables_plugin_skills_by_name() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_plugin_skill(
|
||||
&codex_home,
|
||||
"test",
|
||||
"sample",
|
||||
"sample-search",
|
||||
"sample-search",
|
||||
"search sample data",
|
||||
);
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:sample-search"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
plugins_manager,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "sample:sample-search")
|
||||
.expect("plugin skill should load");
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(skill.path_to_skills_md, skill_path);
|
||||
assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md));
|
||||
assert!(
|
||||
!outcome
|
||||
.allowed_skills_for_implicit_invocation()
|
||||
.iter()
|
||||
.any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
@@ -282,9 +391,10 @@ fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() {
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() {
|
||||
fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("demo-skill", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
@@ -316,14 +426,19 @@ enabled = true
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
assert_eq!(disabled_paths_from_stack(&stack), HashSet::new());
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() {
|
||||
fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("demo-skill", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
@@ -355,12 +470,88 @@ enabled = false
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
disabled_paths_from_stack(&stack),
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_disables_matching_name_selectors() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("github:yeet", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("github:yeet", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod config_rules;
|
||||
mod env_var_dependencies;
|
||||
pub mod injection;
|
||||
pub(crate) mod invocation_utils;
|
||||
|
||||
@@ -153,7 +153,15 @@ impl Session {
|
||||
) {
|
||||
self.abort_all_tasks(TurnAbortReason::Replaced).await;
|
||||
self.clear_connector_selection().await;
|
||||
self.start_task(turn_context, input, task).await;
|
||||
}
|
||||
|
||||
async fn start_task<T: SessionTask>(
|
||||
self: &Arc<Self>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
input: Vec<UserInput>,
|
||||
task: T,
|
||||
) {
|
||||
let task: Arc<dyn SessionTask> = Arc::new(task);
|
||||
let task_kind = task.kind();
|
||||
let span_name = task.span_name();
|
||||
@@ -224,6 +232,22 @@ impl Session {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
|
||||
if !self.has_queued_response_items_for_next_turn().await {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.active_turn.lock().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let turn_context = self.new_default_turn().await;
|
||||
self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
|
||||
.await;
|
||||
self.start_task(turn_context, Vec::new(), RegularTask::new())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) {
|
||||
if let Some(mut active_turn) = self.take_active_turn().await {
|
||||
for task in active_turn.drain_tasks() {
|
||||
@@ -233,6 +257,9 @@ impl Session {
|
||||
// in-flight approval wait can surface as a model-visible rejection before TurnAborted.
|
||||
active_turn.clear_pending().await;
|
||||
}
|
||||
if reason == TurnAbortReason::Interrupted {
|
||||
self.ensure_task_for_queued_response_items().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn on_task_finished(
|
||||
@@ -371,6 +398,9 @@ impl Session {
|
||||
let mut turn = ActiveTurn::default();
|
||||
let mut turn_state = turn.turn_state.lock().await;
|
||||
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
|
||||
for item in self.take_queued_response_items_for_next_turn().await {
|
||||
turn_state.push_pending_input(item);
|
||||
}
|
||||
drop(turn_state);
|
||||
turn.add_task(task);
|
||||
*active = Some(turn);
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::CodexAuth;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::OPENAI_PROVIDER_ID;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnArgs;
|
||||
use crate::codex::CodexSpawnOk;
|
||||
@@ -26,6 +28,8 @@ use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillsManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
@@ -606,6 +610,29 @@ impl ThreadManagerState {
|
||||
thread.submit(op).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Append a prebuilt message to a thread by ID outside the normal user-input path.
|
||||
pub(crate) async fn append_message(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
message: ResponseItem,
|
||||
) -> CodexResult<String> {
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
thread.append_message(message).await
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
thread
|
||||
.deliver_inter_agent_instruction(instruction, delivery)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remove a thread from the manager by ID, returning it when present.
|
||||
pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
|
||||
self.threads.write().await.remove(thread_id)
|
||||
|
||||
@@ -8,6 +8,8 @@ mod list_dir;
|
||||
mod mcp;
|
||||
mod mcp_resource;
|
||||
pub(crate) mod multi_agents;
|
||||
pub(crate) mod multi_agents_common;
|
||||
pub(crate) mod multi_agents_v2;
|
||||
mod plan;
|
||||
mod read_file;
|
||||
mod request_permissions;
|
||||
|
||||
@@ -11,45 +11,33 @@ use crate::agent::agent_resolver::resolve_agent_targets;
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::models_manager::manager::RefreshStrategy;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
pub(crate) use crate::tools::handlers::multi_agents_common::*;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use async_trait::async_trait;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentStatusEntry;
|
||||
use codex_protocol::protocol::CollabCloseBeginEvent;
|
||||
use codex_protocol::protocol::CollabCloseEndEvent;
|
||||
use codex_protocol::protocol::CollabResumeBeginEvent;
|
||||
use codex_protocol::protocol::CollabResumeEndEvent;
|
||||
use codex_protocol::protocol::CollabWaitingBeginEvent;
|
||||
use codex_protocol::protocol::CollabWaitingEndEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) use close_agent::Handler as CloseAgentHandler;
|
||||
pub(crate) use resume_agent::Handler as ResumeAgentHandler;
|
||||
@@ -57,368 +45,12 @@ pub(crate) use send_input::Handler as SendInputHandler;
|
||||
pub(crate) use spawn::Handler as SpawnAgentHandler;
|
||||
pub(crate) use wait::Handler as WaitAgentHandler;
|
||||
|
||||
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
|
||||
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000;
|
||||
|
||||
fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
|
||||
match payload {
|
||||
ToolPayload::Function { arguments } => Ok(arguments),
|
||||
_ => Err(FunctionCallError::RespondToModel(
|
||||
"collab handler received unsupported payload".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_output_json_text<T>(value: &T, tool_name: &str) -> String
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
serde_json::to_string(value).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_output_response_item<T>(
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
value: &T,
|
||||
success: Option<bool>,
|
||||
tool_name: &str,
|
||||
) -> ResponseInputItem
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success)
|
||||
.to_response_item(call_id, payload)
|
||||
}
|
||||
|
||||
fn tool_output_code_mode_result<T>(value: &T, tool_name: &str) -> JsonValue
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
serde_json::to_value(value).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize {tool_name} result: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub mod close_agent;
|
||||
mod resume_agent;
|
||||
mod send_input;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
|
||||
fn build_wait_agent_statuses(
|
||||
statuses: &HashMap<ThreadId, AgentStatus>,
|
||||
receiver_agents: &[CollabAgentRef],
|
||||
) -> Vec<CollabAgentStatusEntry> {
|
||||
if statuses.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(statuses.len());
|
||||
let mut seen = HashMap::with_capacity(receiver_agents.len());
|
||||
for receiver_agent in receiver_agents {
|
||||
seen.insert(receiver_agent.thread_id, ());
|
||||
if let Some(status) = statuses.get(&receiver_agent.thread_id) {
|
||||
entries.push(CollabAgentStatusEntry {
|
||||
thread_id: receiver_agent.thread_id,
|
||||
agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
agent_role: receiver_agent.agent_role.clone(),
|
||||
status: status.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut extras = statuses
|
||||
.iter()
|
||||
.filter(|(thread_id, _)| !seen.contains_key(thread_id))
|
||||
.map(|(thread_id, status)| CollabAgentStatusEntry {
|
||||
thread_id: *thread_id,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
status: status.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string()));
|
||||
entries.extend(extras);
|
||||
entries
|
||||
}
|
||||
|
||||
fn collab_spawn_error(err: CodexErr) -> FunctionCallError {
|
||||
match err {
|
||||
CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => {
|
||||
FunctionCallError::RespondToModel("collab manager unavailable".to_string())
|
||||
}
|
||||
CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message),
|
||||
err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
|
||||
match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
}
|
||||
CodexErr::InternalAgentDied => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed"))
|
||||
}
|
||||
CodexErr::UnsupportedOperation(_) => {
|
||||
FunctionCallError::RespondToModel("collab manager unavailable".to_string())
|
||||
}
|
||||
err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_spawn_source(
|
||||
parent_thread_id: ThreadId,
|
||||
parent_session_source: &SessionSource,
|
||||
depth: i32,
|
||||
agent_role: Option<&str>,
|
||||
task_name: Option<String>,
|
||||
) -> Result<SessionSource, FunctionCallError> {
|
||||
let agent_path = task_name
|
||||
.as_deref()
|
||||
.map(|task_name| {
|
||||
parent_session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root)
|
||||
.join(task_name)
|
||||
.map_err(FunctionCallError::RespondToModel)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path,
|
||||
agent_nickname: None,
|
||||
agent_role: agent_role.map(str::to_string),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_collab_input(
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
) -> Result<Vec<UserInput>, FunctionCallError> {
|
||||
match (message, items) {
|
||||
(Some(_), Some(_)) => Err(FunctionCallError::RespondToModel(
|
||||
"Provide either message or items, but not both".to_string(),
|
||||
)),
|
||||
(None, None) => Err(FunctionCallError::RespondToModel(
|
||||
"Provide one of: message or items".to_string(),
|
||||
)),
|
||||
(Some(message), None) => {
|
||||
if message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(vec![UserInput::Text {
|
||||
text: message,
|
||||
text_elements: Vec::new(),
|
||||
}])
|
||||
}
|
||||
(None, Some(items)) => {
|
||||
if items.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Items can't be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn input_preview(items: &[UserInput]) -> String {
|
||||
let parts: Vec<String> = items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
|
||||
UserInput::Skill { name, path } => {
|
||||
format!("[skill:${name}]({})", path.display())
|
||||
}
|
||||
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Builds the base config snapshot for a newly spawned sub-agent.
|
||||
///
|
||||
/// The returned config starts from the parent's effective config and then refreshes the
|
||||
/// runtime-owned fields carried on `turn`, including model selection, reasoning settings,
|
||||
/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step;
|
||||
/// skipping this helper and cloning stale config state directly can send the child agent out with
|
||||
/// the wrong provider or runtime policy.
|
||||
pub(crate) fn build_agent_spawn_config(
|
||||
base_instructions: &BaseInstructions,
|
||||
turn: &TurnContext,
|
||||
) -> Result<Config, FunctionCallError> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
config.base_instructions = Some(base_instructions.text.clone());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_agent_resume_config(
|
||||
turn: &TurnContext,
|
||||
child_depth: i32,
|
||||
) -> Result<Config, FunctionCallError> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
// For resume, keep base instructions sourced from rollout/session metadata.
|
||||
config.base_instructions = None;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
|
||||
let base_config = turn.config.clone();
|
||||
let mut config = (*base_config).clone();
|
||||
config.model = Some(turn.model_info.slug.clone());
|
||||
config.model_provider = turn.provider.clone();
|
||||
config.model_reasoning_effort = turn.reasoning_effort;
|
||||
config.model_reasoning_summary = Some(turn.reasoning_summary);
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
config.compact_prompt = turn.compact_prompt.clone();
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`.
|
||||
///
|
||||
/// These values are chosen by the live turn rather than persisted config, so leaving them stale
|
||||
/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing.
|
||||
fn apply_spawn_agent_runtime_overrides(
|
||||
config: &mut Config,
|
||||
turn: &TurnContext,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(turn.approval_policy.value())
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}"))
|
||||
})?;
|
||||
config.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
config.cwd = turn.cwd.clone();
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(turn.sandbox_policy.get().clone())
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}"))
|
||||
})?;
|
||||
config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone();
|
||||
config.permissions.network_sandbox_policy = turn.network_sandbox_policy;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
|
||||
if child_depth >= config.agent_max_depth {
|
||||
let _ = config.features.disable(Feature::SpawnCsv);
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_requested_spawn_agent_model_overrides(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
config: &mut Config,
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if requested_model.is_none() && requested_reasoning_effort.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(requested_model) = requested_model {
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::Offline)
|
||||
.await;
|
||||
let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?;
|
||||
let selected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&selected_model_name, config)
|
||||
.await;
|
||||
|
||||
config.model = Some(selected_model_name.clone());
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&selected_model_name,
|
||||
&selected_model_info.supported_reasoning_levels,
|
||||
reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
} else {
|
||||
config.model_reasoning_effort = selected_model_info.default_reasoning_level;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&turn.model_info.slug,
|
||||
&turn.model_info.supported_reasoning_levels,
|
||||
reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[codex_protocol::openai_models::ModelPreset],
|
||||
requested_model: &str,
|
||||
) -> Result<String, FunctionCallError> {
|
||||
available_models
|
||||
.iter()
|
||||
.find(|model| model.model == requested_model)
|
||||
.map(|model| model.model.clone())
|
||||
.ok_or_else(|| {
|
||||
let available = available_models
|
||||
.iter()
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"Unknown model `{requested_model}` for spawn_agent. Available models: {available}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_spawn_agent_reasoning_effort(
|
||||
model: &str,
|
||||
supported_reasoning_levels: &[ReasoningEffortPreset],
|
||||
requested_reasoning_effort: ReasoningEffort,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| preset.effort == requested_reasoning_effort)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported = supported_reasoning_levels
|
||||
.iter()
|
||||
.map(|preset| preset.effort.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "multi_agents_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
|
||||
@@ -52,9 +52,8 @@ impl ToolHandler for Handler {
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
let agent_control = session.services.agent_control.clone();
|
||||
let result = agent_control
|
||||
.send_input(receiver_thread_id, input_items)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err));
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use super::*;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::error::CodexErr;
|
||||
use futures::FutureExt;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch::Receiver;
|
||||
use tokio::time::Instant;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::models_manager::manager::RefreshStrategy;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentStatusEntry;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
|
||||
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000;
|
||||
|
||||
pub(crate) fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
|
||||
match payload {
|
||||
ToolPayload::Function { arguments } => Ok(arguments),
|
||||
_ => Err(FunctionCallError::RespondToModel(
|
||||
"collab handler received unsupported payload".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tool_output_json_text<T>(value: &T, tool_name: &str) -> String
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
serde_json::to_string(value).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn tool_output_response_item<T>(
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
value: &T,
|
||||
success: Option<bool>,
|
||||
tool_name: &str,
|
||||
) -> ResponseInputItem
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success)
|
||||
.to_response_item(call_id, payload)
|
||||
}
|
||||
|
||||
pub(crate) fn tool_output_code_mode_result<T>(value: &T, tool_name: &str) -> JsonValue
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
serde_json::to_value(value).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize {tool_name} result: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_wait_agent_statuses(
|
||||
statuses: &HashMap<ThreadId, AgentStatus>,
|
||||
receiver_agents: &[CollabAgentRef],
|
||||
) -> Vec<CollabAgentStatusEntry> {
|
||||
if statuses.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(statuses.len());
|
||||
let mut seen = HashMap::with_capacity(receiver_agents.len());
|
||||
for receiver_agent in receiver_agents {
|
||||
seen.insert(receiver_agent.thread_id, ());
|
||||
if let Some(status) = statuses.get(&receiver_agent.thread_id) {
|
||||
entries.push(CollabAgentStatusEntry {
|
||||
thread_id: receiver_agent.thread_id,
|
||||
agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
agent_role: receiver_agent.agent_role.clone(),
|
||||
status: status.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut extras = statuses
|
||||
.iter()
|
||||
.filter(|(thread_id, _)| !seen.contains_key(thread_id))
|
||||
.map(|(thread_id, status)| CollabAgentStatusEntry {
|
||||
thread_id: *thread_id,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
status: status.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string()));
|
||||
entries.extend(extras);
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError {
|
||||
match err {
|
||||
CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => {
|
||||
FunctionCallError::RespondToModel("collab manager unavailable".to_string())
|
||||
}
|
||||
CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message),
|
||||
err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
|
||||
match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
}
|
||||
CodexErr::InternalAgentDied => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed"))
|
||||
}
|
||||
CodexErr::UnsupportedOperation(_) => {
|
||||
FunctionCallError::RespondToModel("collab manager unavailable".to_string())
|
||||
}
|
||||
err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn thread_spawn_source(
|
||||
parent_thread_id: ThreadId,
|
||||
parent_session_source: &SessionSource,
|
||||
depth: i32,
|
||||
agent_role: Option<&str>,
|
||||
task_name: Option<String>,
|
||||
) -> Result<SessionSource, FunctionCallError> {
|
||||
let agent_path = task_name
|
||||
.as_deref()
|
||||
.map(|task_name| {
|
||||
parent_session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root)
|
||||
.join(task_name)
|
||||
.map_err(FunctionCallError::RespondToModel)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path,
|
||||
agent_nickname: None,
|
||||
agent_role: agent_role.map(str::to_string),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_collab_input(
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
) -> Result<Vec<UserInput>, FunctionCallError> {
|
||||
match (message, items) {
|
||||
(Some(_), Some(_)) => Err(FunctionCallError::RespondToModel(
|
||||
"Provide either message or items, but not both".to_string(),
|
||||
)),
|
||||
(None, None) => Err(FunctionCallError::RespondToModel(
|
||||
"Provide one of: message or items".to_string(),
|
||||
)),
|
||||
(Some(message), None) => {
|
||||
if message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(vec![UserInput::Text {
|
||||
text: message,
|
||||
text_elements: Vec::new(),
|
||||
}])
|
||||
}
|
||||
(None, Some(items)) => {
|
||||
if items.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Items can't be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn input_preview(items: &[UserInput]) -> String {
|
||||
let parts: Vec<String> = items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
|
||||
UserInput::Skill { name, path } => {
|
||||
format!("[skill:${name}]({})", path.display())
|
||||
}
|
||||
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Builds the base config snapshot for a newly spawned sub-agent.
|
||||
///
|
||||
/// The returned config starts from the parent's effective config and then refreshes the
|
||||
/// runtime-owned fields carried on `turn`, including model selection, reasoning settings,
|
||||
/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step;
|
||||
/// skipping this helper and cloning stale config state directly can send the child agent out with
|
||||
/// the wrong provider or runtime policy.
|
||||
pub(crate) fn build_agent_spawn_config(
|
||||
base_instructions: &BaseInstructions,
|
||||
turn: &TurnContext,
|
||||
) -> Result<Config, FunctionCallError> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
config.base_instructions = Some(base_instructions.text.clone());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn build_agent_resume_config(
|
||||
turn: &TurnContext,
|
||||
child_depth: i32,
|
||||
) -> Result<Config, FunctionCallError> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
// For resume, keep base instructions sourced from rollout/session metadata.
|
||||
config.base_instructions = None;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
|
||||
let base_config = turn.config.clone();
|
||||
let mut config = (*base_config).clone();
|
||||
config.model = Some(turn.model_info.slug.clone());
|
||||
config.model_provider = turn.provider.clone();
|
||||
config.model_reasoning_effort = turn.reasoning_effort;
|
||||
config.model_reasoning_summary = Some(turn.reasoning_summary);
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
config.compact_prompt = turn.compact_prompt.clone();
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`.
|
||||
///
|
||||
/// These values are chosen by the live turn rather than persisted config, so leaving them stale
|
||||
/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing.
|
||||
pub(crate) fn apply_spawn_agent_runtime_overrides(
|
||||
config: &mut Config,
|
||||
turn: &TurnContext,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(turn.approval_policy.value())
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}"))
|
||||
})?;
|
||||
config.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
config.cwd = turn.cwd.clone();
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(turn.sandbox_policy.get().clone())
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}"))
|
||||
})?;
|
||||
config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone();
|
||||
config.permissions.network_sandbox_policy = turn.network_sandbox_policy;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
|
||||
if child_depth >= config.agent_max_depth {
|
||||
let _ = config.features.disable(Feature::SpawnCsv);
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_requested_spawn_agent_model_overrides(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
config: &mut Config,
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if requested_model.is_none() && requested_reasoning_effort.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(requested_model) = requested_model {
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::Offline)
|
||||
.await;
|
||||
let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?;
|
||||
let selected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&selected_model_name, config)
|
||||
.await;
|
||||
|
||||
config.model = Some(selected_model_name.clone());
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&selected_model_name,
|
||||
&selected_model_info.supported_reasoning_levels,
|
||||
reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
} else {
|
||||
config.model_reasoning_effort = selected_model_info.default_reasoning_level;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&turn.model_info.slug,
|
||||
&turn.model_info.supported_reasoning_levels,
|
||||
reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[codex_protocol::openai_models::ModelPreset],
|
||||
requested_model: &str,
|
||||
) -> Result<String, FunctionCallError> {
|
||||
available_models
|
||||
.iter()
|
||||
.find(|model| model.model == requested_model)
|
||||
.map(|model| model.model.clone())
|
||||
.ok_or_else(|| {
|
||||
let available = available_models
|
||||
.iter()
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"Unknown model `{requested_model}` for spawn_agent. Available models: {available}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_spawn_agent_reasoning_effort(
|
||||
model: &str,
|
||||
supported_reasoning_levels: &[ReasoningEffortPreset],
|
||||
requested_reasoning_effort: ReasoningEffort,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| preset.effort == requested_reasoning_effort)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported = supported_reasoning_levels
|
||||
.iter()
|
||||
.map(|preset| preset.effort.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}"
|
||||
)))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::codex::make_session_and_context;
|
||||
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::AgentStatus;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::FileSystemSandboxPolicy;
|
||||
use crate::protocol::NetworkSandboxPolicy;
|
||||
@@ -14,16 +15,24 @@ use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionSource;
|
||||
use crate::protocol::SubAgentSource;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -33,6 +42,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn invocation(
|
||||
session: Arc<crate::codex::Session>,
|
||||
@@ -68,6 +78,31 @@ fn thread_manager() -> ThreadManager {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionTask for NeverEndingTask {
|
||||
fn kind(&self) -> TaskKind {
|
||||
TaskKind::Regular
|
||||
}
|
||||
|
||||
fn span_name(&self) -> &'static str {
|
||||
"session_task.multi_agent_never_ending"
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Arc<Self>,
|
||||
_session: Arc<SessionTaskContext>,
|
||||
_ctx: Arc<TurnContext>,
|
||||
_input: Vec<UserInput>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
cancellation_token.cancelled().await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text_output<T>(output: T) -> (String, Option<bool>)
|
||||
where
|
||||
T: ToolOutput,
|
||||
@@ -286,7 +321,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let spawn_output = SpawnAgentHandler
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
@@ -325,7 +360,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
Some("/root/test_process")
|
||||
);
|
||||
|
||||
SendInputHandler
|
||||
SendInputHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
@@ -337,6 +372,307 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
))
|
||||
.await
|
||||
.expect("send_input should accept v2 path");
|
||||
|
||||
let child_thread = manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should exist");
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = child_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
if recorded && !saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("v2 send_input should record assistant envelope");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = turn.config.as_ref().clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "boot worker",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn worker");
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("worker thread should exist");
|
||||
let invocation = invocation(
|
||||
session,
|
||||
turn,
|
||||
"send_input",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"items": [
|
||||
{"type": "mention", "name": "drive", "path": "app://google_drive"},
|
||||
{"type": "text", "text": "read the folder"}
|
||||
]
|
||||
})),
|
||||
);
|
||||
|
||||
SendInputHandlerV2
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("structured items should be accepted in v2");
|
||||
|
||||
let expected = Op::UserInput {
|
||||
items: vec![
|
||||
UserInput::Mention {
|
||||
name: "drive".to_string(),
|
||||
path: "app://google_drive".to_string(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "read the folder".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
let captured = manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|(id, op)| *id == agent_id && *op == expected);
|
||||
assert_eq!(captured, Some((agent_id, expected)));
|
||||
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded_assistant_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text }
|
||||
if text == "read the folder"
|
||||
|| text == "[mention:$drive](app://google_drive)\nread the folder"
|
||||
))
|
||||
)
|
||||
});
|
||||
if !recorded_assistant_envelope && saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("structured items should stay on the legacy user-input path");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = turn.config.as_ref().clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "boot worker",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn worker");
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("worker thread should exist");
|
||||
|
||||
let active_turn = thread.codex.session.new_default_turn().await;
|
||||
thread
|
||||
.codex
|
||||
.session
|
||||
.spawn_task(
|
||||
Arc::clone(&active_turn),
|
||||
vec![UserInput::Text {
|
||||
text: "working".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
NeverEndingTask,
|
||||
)
|
||||
.await;
|
||||
|
||||
SendInputHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"send_input",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"message": "continue",
|
||||
"interrupt": true
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("interrupting v2 send_input should succeed");
|
||||
|
||||
let ops = manager.captured_ops();
|
||||
let ops_for_agent: Vec<&Op> = ops
|
||||
.iter()
|
||||
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
|
||||
.collect();
|
||||
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
|
||||
assert!(!ops_for_agent.iter().any(|op| matches!(
|
||||
op,
|
||||
Op::UserInput { items, .. }
|
||||
if items.iter().any(|item| matches!(
|
||||
item,
|
||||
UserInput::Text { text, .. } if text == "continue"
|
||||
))
|
||||
)));
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let saw_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
if saw_envelope && !saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("interrupting v2 send_input should preserve the redirected message");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -356,7 +692,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let output = SpawnAgentHandler
|
||||
let output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
@@ -404,7 +740,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() {
|
||||
"task_name": "BadName"
|
||||
})),
|
||||
);
|
||||
let Err(err) = SpawnAgentHandler.handle(invocation).await else {
|
||||
let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else {
|
||||
panic!("invalid agent name should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -1023,16 +1359,16 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() {
|
||||
"wait_agent",
|
||||
function_payload(json!({"targets": [target.clone()]})),
|
||||
);
|
||||
let output = WaitAgentHandler
|
||||
let output = WaitAgentHandlerV2
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("targets should be accepted in v2 mode");
|
||||
let (content, success) = expect_text_output(output);
|
||||
let result: wait::WaitAgentResult =
|
||||
let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult =
|
||||
serde_json::from_str(&content).expect("wait_agent result should be json");
|
||||
assert_eq!(
|
||||
result,
|
||||
wait::WaitAgentResult {
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
status: HashMap::from([(target, AgentStatus::NotFound)]),
|
||||
timed_out: false,
|
||||
}
|
||||
@@ -1224,7 +1560,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let spawn_output = SpawnAgentHandler
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
@@ -1268,7 +1604,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
.await
|
||||
.expect("shutdown status should arrive");
|
||||
|
||||
let wait_output = WaitAgentHandler
|
||||
let wait_output = WaitAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
@@ -1281,11 +1617,11 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
.await
|
||||
.expect("wait_agent should succeed");
|
||||
let (content, success) = expect_text_output(wait_output);
|
||||
let result: wait::WaitAgentResult =
|
||||
let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult =
|
||||
serde_json::from_str(&content).expect("wait_agent result should be json");
|
||||
assert_eq!(
|
||||
result,
|
||||
wait::WaitAgentResult {
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]),
|
||||
timed_out: false,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Implements the MultiAgentV2 collaboration tool surface.
|
||||
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_resolver::resolve_agent_target;
|
||||
use crate::agent::agent_resolver::resolve_agent_targets;
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::codex::Session;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::multi_agents_common::*;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
|
||||
use codex_protocol::protocol::CollabWaitingBeginEvent;
|
||||
use codex_protocol::protocol::CollabWaitingEndEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) use send_input::Handler as SendInputHandler;
|
||||
pub(crate) use spawn::Handler as SpawnAgentHandler;
|
||||
pub(crate) use wait::Handler as WaitAgentHandler;
|
||||
|
||||
mod send_input;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
@@ -0,0 +1,154 @@
|
||||
use super::*;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool {
|
||||
items
|
||||
.iter()
|
||||
.all(|item| matches!(item, UserInput::Text { .. }))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = SendInputResult;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: SendInputArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
|
||||
let input_items = parse_collab_input(args.message, args.items)?;
|
||||
let prompt = input_preview(&input_items);
|
||||
let receiver_agent = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_metadata(receiver_thread_id)
|
||||
.unwrap_or_default();
|
||||
if args.interrupt {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.interrupt_agent(receiver_thread_id)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabAgentInteractionBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
receiver_thread_id,
|
||||
prompt: prompt.clone(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let result = if can_use_v2_inter_agent_instruction(&input_items) {
|
||||
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"target agent is missing an agent_path".to_string(),
|
||||
)
|
||||
})?;
|
||||
let instruction = InterAgentInstruction::new(
|
||||
turn.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root),
|
||||
receiver_agent_path,
|
||||
Vec::new(),
|
||||
prompt.clone(),
|
||||
);
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.deliver_inter_agent_instruction(
|
||||
receiver_thread_id,
|
||||
instruction,
|
||||
if args.interrupt {
|
||||
InterAgentDelivery::NextTurn
|
||||
} else {
|
||||
InterAgentDelivery::CurrentTurn
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.send_input(receiver_thread_id, input_items)
|
||||
.await
|
||||
}
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err));
|
||||
let status = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_status(receiver_thread_id)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabAgentInteractionEndEvent {
|
||||
call_id,
|
||||
sender_thread_id: session.conversation_id,
|
||||
receiver_thread_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
prompt,
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let submission_id = result?;
|
||||
|
||||
Ok(SendInputResult { submission_id })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SendInputArgs {
|
||||
target: String,
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
#[serde(default)]
|
||||
interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SendInputResult {
|
||||
submission_id: String,
|
||||
}
|
||||
|
||||
impl ToolOutput for SendInputResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "send_input")
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
tool_output_response_item(call_id, payload, self, Some(true), "send_input")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "send_input")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::*;
|
||||
use crate::agent::control::SpawnAgentOptions;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = SpawnAgentResult;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
|
||||
let role_name = args
|
||||
.agent_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|role| !role.is_empty());
|
||||
let input_items = parse_collab_input(args.message, args.items)?;
|
||||
let prompt = input_preview(&input_items);
|
||||
let session_source = turn.session_source.clone();
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
let max_depth = turn.config.agent_max_depth;
|
||||
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Agent depth limit reached. Solve the task yourself.".to_string(),
|
||||
));
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabAgentSpawnBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
prompt: prompt.clone(),
|
||||
model: args.model.clone().unwrap_or_default(),
|
||||
reasoning_effort: args.reasoning_effort.unwrap_or_default(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let mut config =
|
||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort,
|
||||
)
|
||||
.await?;
|
||||
apply_role_to_config(&mut config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
config,
|
||||
input_items,
|
||||
Some(thread_spawn_source(
|
||||
session.conversation_id,
|
||||
&turn.session_source,
|
||||
child_depth,
|
||||
role_name,
|
||||
args.task_name.clone(),
|
||||
)?),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(collab_spawn_error);
|
||||
let (new_thread_id, new_agent_metadata, status) = match &result {
|
||||
Ok(spawned_agent) => (
|
||||
Some(spawned_agent.thread_id),
|
||||
Some(spawned_agent.metadata.clone()),
|
||||
spawned_agent.status.clone(),
|
||||
),
|
||||
Err(_) => (None, None, AgentStatus::NotFound),
|
||||
};
|
||||
let agent_snapshot = match new_thread_id {
|
||||
Some(thread_id) => {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_config_snapshot(thread_id)
|
||||
.await
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let (new_agent_path, new_agent_nickname, new_agent_role) =
|
||||
match (&agent_snapshot, new_agent_metadata) {
|
||||
(Some(snapshot), _) => (
|
||||
snapshot.session_source.get_agent_path().map(String::from),
|
||||
snapshot.session_source.get_nickname(),
|
||||
snapshot.session_source.get_agent_role(),
|
||||
),
|
||||
(None, Some(metadata)) => (
|
||||
metadata.agent_path.map(String::from),
|
||||
metadata.agent_nickname,
|
||||
metadata.agent_role,
|
||||
),
|
||||
(None, None) => (None, None, None),
|
||||
};
|
||||
let effective_model = agent_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.model.clone())
|
||||
.unwrap_or_else(|| args.model.clone().unwrap_or_default());
|
||||
let effective_reasoning_effort = agent_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.reasoning_effort)
|
||||
.unwrap_or(args.reasoning_effort.unwrap_or_default());
|
||||
let nickname = new_agent_nickname.clone();
|
||||
let task_name = new_agent_path.clone();
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabAgentSpawnEndEvent {
|
||||
call_id,
|
||||
sender_thread_id: session.conversation_id,
|
||||
new_thread_id,
|
||||
new_agent_nickname,
|
||||
new_agent_role,
|
||||
prompt,
|
||||
model: effective_model,
|
||||
reasoning_effort: effective_reasoning_effort,
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let new_thread_id = result?.thread_id;
|
||||
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
|
||||
turn.session_telemetry.counter(
|
||||
"codex.multi_agent.spawn",
|
||||
/*inc*/ 1,
|
||||
&[("role", role_tag)],
|
||||
);
|
||||
|
||||
Ok(SpawnAgentResult {
|
||||
agent_id: task_name.is_none().then(|| new_thread_id.to_string()),
|
||||
task_name,
|
||||
nickname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentArgs {
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
task_name: Option<String>,
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
#[serde(default)]
|
||||
fork_context: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SpawnAgentResult {
|
||||
agent_id: Option<String>,
|
||||
task_name: Option<String>,
|
||||
nickname: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolOutput for SpawnAgentResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "spawn_agent")
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "spawn_agent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use super::*;
|
||||
use crate::agent::status::is_final;
|
||||
use futures::FutureExt;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch::Receiver;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::timeout_at;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = WaitAgentResult;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: WaitArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?;
|
||||
let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
|
||||
let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
|
||||
for receiver_thread_id in &receiver_thread_ids {
|
||||
let agent_metadata = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_metadata(*receiver_thread_id)
|
||||
.unwrap_or_default();
|
||||
target_by_thread_id.insert(
|
||||
*receiver_thread_id,
|
||||
agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| receiver_thread_id.to_string()),
|
||||
);
|
||||
receiver_agents.push(CollabAgentRef {
|
||||
thread_id: *receiver_thread_id,
|
||||
agent_nickname: agent_metadata.agent_nickname,
|
||||
agent_role: agent_metadata.agent_role,
|
||||
});
|
||||
}
|
||||
|
||||
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
|
||||
let timeout_ms = match timeout_ms {
|
||||
ms if ms <= 0 => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"timeout_ms must be greater than zero".to_owned(),
|
||||
));
|
||||
}
|
||||
ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabWaitingBeginEvent {
|
||||
sender_thread_id: session.conversation_id,
|
||||
receiver_thread_ids: receiver_thread_ids.clone(),
|
||||
receiver_agents: receiver_agents.clone(),
|
||||
call_id: call_id.clone(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len());
|
||||
let mut initial_final_statuses = Vec::new();
|
||||
for id in &receiver_thread_ids {
|
||||
match session.services.agent_control.subscribe_status(*id).await {
|
||||
Ok(rx) => {
|
||||
let status = rx.borrow().clone();
|
||||
if is_final(&status) {
|
||||
initial_final_statuses.push((*id, status));
|
||||
}
|
||||
status_rxs.push((*id, rx));
|
||||
}
|
||||
Err(crate::error::CodexErr::ThreadNotFound(_)) => {
|
||||
initial_final_statuses.push((*id, AgentStatus::NotFound));
|
||||
}
|
||||
Err(err) => {
|
||||
let mut statuses = HashMap::with_capacity(1);
|
||||
statuses.insert(*id, session.services.agent_control.get_status(*id).await);
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabWaitingEndEvent {
|
||||
sender_thread_id: session.conversation_id,
|
||||
call_id: call_id.clone(),
|
||||
agent_statuses: build_wait_agent_statuses(
|
||||
&statuses,
|
||||
&receiver_agents,
|
||||
),
|
||||
statuses,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
return Err(collab_agent_error(*id, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let statuses = if !initial_final_statuses.is_empty() {
|
||||
initial_final_statuses
|
||||
} else {
|
||||
let mut futures = FuturesUnordered::new();
|
||||
for (id, rx) in status_rxs {
|
||||
let session = session.clone();
|
||||
futures.push(wait_for_final_status(session, id, rx));
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
|
||||
loop {
|
||||
match timeout_at(deadline, futures.next()).await {
|
||||
Ok(Some(Some(result))) => {
|
||||
results.push(result);
|
||||
break;
|
||||
}
|
||||
Ok(Some(None)) => continue,
|
||||
Ok(None) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
if !results.is_empty() {
|
||||
loop {
|
||||
match futures.next().now_or_never() {
|
||||
Some(Some(Some(result))) => results.push(result),
|
||||
Some(Some(None)) => continue,
|
||||
Some(None) | None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
};
|
||||
|
||||
let timed_out = statuses.is_empty();
|
||||
let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
|
||||
let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents);
|
||||
let result = WaitAgentResult {
|
||||
status: statuses
|
||||
.into_iter()
|
||||
.filter_map(|(thread_id, status)| {
|
||||
target_by_thread_id
|
||||
.get(&thread_id)
|
||||
.cloned()
|
||||
.map(|target| (target, status))
|
||||
})
|
||||
.collect(),
|
||||
timed_out,
|
||||
};
|
||||
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabWaitingEndEvent {
|
||||
sender_thread_id: session.conversation_id,
|
||||
call_id,
|
||||
agent_statuses,
|
||||
statuses: statuses_by_id,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WaitArgs {
|
||||
#[serde(default)]
|
||||
targets: Vec<String>,
|
||||
timeout_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct WaitAgentResult {
|
||||
pub(crate) status: HashMap<String, AgentStatus>,
|
||||
pub(crate) timed_out: bool,
|
||||
}
|
||||
|
||||
impl ToolOutput for WaitAgentResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "wait_agent")
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
tool_output_response_item(call_id, payload, self, /*success*/ None, "wait_agent")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "wait_agent")
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_final_status(
|
||||
session: std::sync::Arc<Session>,
|
||||
thread_id: ThreadId,
|
||||
mut status_rx: Receiver<AgentStatus>,
|
||||
) -> Option<(ThreadId, AgentStatus)> {
|
||||
let mut status = status_rx.borrow().clone();
|
||||
if is_final(&status) {
|
||||
return Some((thread_id, status));
|
||||
}
|
||||
|
||||
loop {
|
||||
if status_rx.changed().await.is_err() {
|
||||
let latest = session.services.agent_control.get_status(thread_id).await;
|
||||
return is_final(&latest).then_some((thread_id, latest));
|
||||
}
|
||||
status = status_rx.borrow().clone();
|
||||
if is_final(&status) {
|
||||
return Some((thread_id, status));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,9 @@ use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME;
|
||||
use crate::tools::handlers::agent_jobs::BatchJobHandler;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
|
||||
use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::request_permissions_tool_description;
|
||||
use crate::tools::handlers::request_user_input_tool_description;
|
||||
use crate::tools::registry::ToolRegistryBuilder;
|
||||
@@ -2602,6 +2602,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::multi_agents::SendInputHandler;
|
||||
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
|
||||
use crate::tools::handlers::multi_agents::WaitAgentHandler;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut builder = ToolRegistryBuilder::new();
|
||||
@@ -3013,9 +3016,15 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandler));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandler));
|
||||
if config.multi_agent_v2 {
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandlerV2));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
|
||||
} else {
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandler));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandler));
|
||||
}
|
||||
builder.register_handler("close_agent", Arc::new(CloseAgentHandler));
|
||||
}
|
||||
|
||||
|
||||
@@ -381,6 +381,116 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let ctx = RefreshTokenTestContext::new(&server)?;
|
||||
let stale_refresh = Utc::now() - Duration::days(9);
|
||||
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
||||
let initial_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(initial_tokens),
|
||||
last_refresh: Some(stale_refresh),
|
||||
};
|
||||
ctx.write_auth(&initial_auth)?;
|
||||
|
||||
let fresh_refresh = Utc::now() - Duration::days(1);
|
||||
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
||||
let disk_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
&disk_auth,
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let cached_auth = ctx
|
||||
.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.context("auth should reload from disk")?;
|
||||
let cached = cached_auth
|
||||
.get_token_data()
|
||||
.context("token data should reload from disk")?;
|
||||
assert_eq!(cached, disk_tokens);
|
||||
|
||||
let stored = ctx.load_auth()?;
|
||||
assert_eq!(stored, disk_auth);
|
||||
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
assert!(requests.is_empty(), "expected no refresh token requests");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": {
|
||||
"code": "refresh_token_expired"
|
||||
}
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ctx = RefreshTokenTestContext::new(&server)?;
|
||||
let stale_refresh = Utc::now() - Duration::days(9);
|
||||
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
||||
let initial_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(initial_tokens),
|
||||
last_refresh: Some(stale_refresh),
|
||||
};
|
||||
ctx.write_auth(&initial_auth)?;
|
||||
|
||||
let fresh_refresh = Utc::now() - Duration::days(1);
|
||||
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
||||
let disk_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(disk_tokens.clone()),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
};
|
||||
save_auth(
|
||||
ctx.codex_home.path(),
|
||||
&disk_auth,
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let cached_auth = ctx
|
||||
.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.context("auth should reload from disk")?;
|
||||
let cached = cached_auth
|
||||
.get_token_data()
|
||||
.context("token data should reload from disk")?;
|
||||
assert_eq!(cached, disk_tokens);
|
||||
|
||||
let stored = ctx.load_auth()?;
|
||||
assert_eq!(stored, disk_auth);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> {
|
||||
|
||||
@@ -1090,10 +1090,13 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
/// Current cached auth (clone). May be `None` if not logged in or load failed.
|
||||
/// Refreshes cached ChatGPT tokens if they are stale before returning.
|
||||
/// For stale managed ChatGPT auth, first performs a guarded reload and then
|
||||
/// refreshes only if the on-disk auth is unchanged.
|
||||
pub async fn auth(&self) -> Option<CodexAuth> {
|
||||
let auth = self.auth_cached()?;
|
||||
if let Err(err) = self.refresh_if_stale(&auth).await {
|
||||
if Self::is_stale_for_proactive_refresh(&auth)
|
||||
&& let Err(err) = self.refresh_token().await
|
||||
{
|
||||
tracing::error!("Failed to refresh token: {}", err);
|
||||
return Some(auth);
|
||||
}
|
||||
@@ -1320,30 +1323,21 @@ impl AuthManager {
|
||||
self.auth_cached().as_ref().map(CodexAuth::auth_mode)
|
||||
}
|
||||
|
||||
async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result<bool, RefreshTokenError> {
|
||||
fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool {
|
||||
let chatgpt_auth = match auth {
|
||||
CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth,
|
||||
_ => return Ok(false),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let auth_dot_json = match chatgpt_auth.current_auth_json() {
|
||||
Some(auth_dot_json) => auth_dot_json,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let tokens = match auth_dot_json.tokens {
|
||||
Some(tokens) => tokens,
|
||||
None => return Ok(false),
|
||||
None => return false,
|
||||
};
|
||||
let last_refresh = match auth_dot_json.last_refresh {
|
||||
Some(last_refresh) => last_refresh,
|
||||
None => return Ok(false),
|
||||
None => return false,
|
||||
};
|
||||
if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.refresh_and_persist_chatgpt_token(chatgpt_auth, tokens.refresh_token)
|
||||
.await?;
|
||||
Ok(true)
|
||||
last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
async fn refresh_external_auth(
|
||||
|
||||
+249
-1
@@ -45,10 +45,14 @@ use codex_app_server_client::InProcessClientStartArgs;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_core::AuthManager;
|
||||
@@ -355,6 +359,72 @@ async fn request_plugin_detail(
|
||||
response
|
||||
}
|
||||
|
||||
async fn request_plugin_install(
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
config: Config,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
params: PluginInstallParams,
|
||||
) -> Result<PluginInstallResponse> {
|
||||
let client = start_plugin_request_client(
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_kv_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
)
|
||||
.await?;
|
||||
let request_handle = client.request_handle();
|
||||
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
|
||||
let response = request_handle
|
||||
.request_typed(ClientRequest::PluginInstall { request_id, params })
|
||||
.await
|
||||
.wrap_err("plugin/install failed in legacy TUI");
|
||||
if let Err(err) = client.shutdown().await {
|
||||
tracing::warn!(%err, "failed to shut down embedded app server after plugin/install");
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
async fn request_plugin_uninstall(
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
config: Config,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
plugin_id: String,
|
||||
) -> Result<PluginUninstallResponse> {
|
||||
let client = start_plugin_request_client(
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_kv_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
)
|
||||
.await?;
|
||||
let request_handle = client.request_handle();
|
||||
let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4()));
|
||||
let response = request_handle
|
||||
.request_typed(ClientRequest::PluginUninstall {
|
||||
request_id,
|
||||
params: PluginUninstallParams {
|
||||
plugin_id,
|
||||
force_remote_sync: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("plugin/uninstall failed in legacy TUI");
|
||||
if let Err(err) = client.shutdown().await {
|
||||
tracing::warn!(%err, "failed to shut down embedded app server after plugin/uninstall");
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
|
||||
let mut disabled_folders = Vec::new();
|
||||
|
||||
@@ -1365,6 +1435,85 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_plugin_install(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
) {
|
||||
let config = self.config.clone();
|
||||
let arg0_paths = self.arg0_paths.clone();
|
||||
let cli_kv_overrides = self.cli_kv_overrides.clone();
|
||||
let loader_overrides = self.loader_overrides.clone();
|
||||
let cloud_requirements = self.cloud_requirements.clone();
|
||||
let feedback = self.feedback.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let cwd_for_event = cwd.clone();
|
||||
let marketplace_path_for_event = marketplace_path.clone();
|
||||
let plugin_name_for_event = plugin_name.clone();
|
||||
let result = request_plugin_install(
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_kv_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
force_remote_sync: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to install plugin: {err}"));
|
||||
app_event_tx.send(AppEvent::PluginInstallLoaded {
|
||||
cwd: cwd_for_event,
|
||||
marketplace_path: marketplace_path_for_event,
|
||||
plugin_name: plugin_name_for_event,
|
||||
plugin_display_name,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_plugin_uninstall(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
) {
|
||||
let config = self.config.clone();
|
||||
let arg0_paths = self.arg0_paths.clone();
|
||||
let cli_kv_overrides = self.cli_kv_overrides.clone();
|
||||
let loader_overrides = self.loader_overrides.clone();
|
||||
let cloud_requirements = self.cloud_requirements.clone();
|
||||
let feedback = self.feedback.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let cwd_for_event = cwd.clone();
|
||||
let plugin_id_for_event = plugin_id.clone();
|
||||
let result = request_plugin_uninstall(
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_kv_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
plugin_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to uninstall plugin: {err}"));
|
||||
app_event_tx.send(AppEvent::PluginUninstallLoaded {
|
||||
cwd: cwd_for_event,
|
||||
plugin_id: plugin_id_for_event,
|
||||
plugin_display_name,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_ui_header_lines_with_version(
|
||||
&self,
|
||||
width: u16,
|
||||
@@ -2953,6 +3102,15 @@ impl App {
|
||||
AppEvent::RefreshConnectors { force_refetch } => {
|
||||
self.chat_widget.refresh_connectors(force_refetch);
|
||||
}
|
||||
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
|
||||
if refresh_connectors {
|
||||
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
|
||||
}
|
||||
self.chat_widget.advance_plugin_install_auth_flow();
|
||||
}
|
||||
AppEvent::PluginInstallAuthAbandon => {
|
||||
self.chat_widget.abandon_plugin_install_auth_flow();
|
||||
}
|
||||
AppEvent::FetchPluginsList { cwd } => {
|
||||
self.fetch_plugins_list(cwd);
|
||||
}
|
||||
@@ -2962,6 +3120,18 @@ impl App {
|
||||
self.chat_widget
|
||||
.open_plugin_detail_loading_popup(&plugin_display_name);
|
||||
}
|
||||
AppEvent::OpenPluginInstallLoading {
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_plugin_install_loading_popup(&plugin_display_name);
|
||||
}
|
||||
AppEvent::OpenPluginUninstallLoading {
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_plugin_uninstall_loading_popup(&plugin_display_name);
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
@@ -2983,6 +3153,55 @@ impl App {
|
||||
AppEvent::PluginDetailLoaded { cwd, result } => {
|
||||
self.chat_widget.on_plugin_detail_loaded(cwd, result);
|
||||
}
|
||||
AppEvent::FetchPluginInstall {
|
||||
cwd,
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.fetch_plugin_install(cwd, marketplace_path, plugin_name, plugin_display_name);
|
||||
}
|
||||
AppEvent::FetchPluginUninstall {
|
||||
cwd,
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.fetch_plugin_uninstall(cwd, plugin_id, plugin_display_name);
|
||||
}
|
||||
AppEvent::PluginInstallLoaded {
|
||||
cwd,
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
result,
|
||||
} => {
|
||||
let install_succeeded = result.is_ok();
|
||||
if install_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after plugin install");
|
||||
}
|
||||
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
||||
}
|
||||
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
||||
cwd.clone(),
|
||||
marketplace_path.clone(),
|
||||
plugin_name.clone(),
|
||||
plugin_display_name,
|
||||
result,
|
||||
);
|
||||
if install_succeeded && self.chat_widget.config_ref().cwd == cwd {
|
||||
self.fetch_plugins_list(cwd.clone());
|
||||
if should_refresh_plugin_detail {
|
||||
self.fetch_plugin_detail(
|
||||
cwd,
|
||||
PluginReadParams {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.on_update_reasoning_effort(effort);
|
||||
self.refresh_status_surfaces();
|
||||
@@ -3400,6 +3619,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::PluginUninstallLoaded {
|
||||
cwd,
|
||||
plugin_id: _plugin_id,
|
||||
plugin_display_name,
|
||||
result,
|
||||
} => {
|
||||
let uninstall_succeeded = result.is_ok();
|
||||
if uninstall_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after plugin uninstall"
|
||||
);
|
||||
}
|
||||
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
||||
}
|
||||
self.chat_widget.on_plugin_uninstall_loaded(
|
||||
cwd.clone(),
|
||||
plugin_display_name,
|
||||
result,
|
||||
);
|
||||
if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd {
|
||||
self.fetch_plugins_list(cwd);
|
||||
}
|
||||
}
|
||||
AppEvent::PersistPersonalitySelection { personality } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
@@ -3930,7 +4174,11 @@ impl App {
|
||||
AppEvent::UpdateRecordingMeter { id, text } => {
|
||||
// Update in place to preserve the element id for subsequent frames.
|
||||
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
|
||||
if updated {
|
||||
if updated
|
||||
|| self
|
||||
.chat_widget
|
||||
.stop_realtime_conversation_for_deleted_meter(&id)
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,18 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
@@ -193,6 +196,56 @@ pub(crate) enum AppEvent {
|
||||
result: Result<PluginReadResponse, String>,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with an install loading state.
|
||||
OpenPluginInstallLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with an uninstall loading state.
|
||||
OpenPluginUninstallLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Install a specific plugin from a marketplace.
|
||||
FetchPluginInstall {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of installing a plugin.
|
||||
PluginInstallLoaded {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
},
|
||||
|
||||
/// Uninstall a specific plugin by canonical plugin id.
|
||||
FetchPluginUninstall {
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of uninstalling a plugin.
|
||||
PluginUninstallLoaded {
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginUninstallResponse, String>,
|
||||
},
|
||||
|
||||
/// Advance the post-install plugin app-auth flow.
|
||||
PluginInstallAuthAdvance {
|
||||
refresh_connectors: bool,
|
||||
},
|
||||
|
||||
/// Abandon the post-install plugin app-auth flow.
|
||||
PluginInstallAuthAbandon,
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
/// Apply rollback semantics to local transcript cells.
|
||||
|
||||
@@ -3584,6 +3584,24 @@ impl ChatComposer {
|
||||
|
||||
fn mention_items(&self) -> Vec<MentionItem> {
|
||||
let mut mentions = Vec::new();
|
||||
let plugin_namespaces: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let (plugin_name, _) = plugin
|
||||
.config_name
|
||||
.split_once('@')
|
||||
.unwrap_or((plugin.config_name.as_str(), ""));
|
||||
let plugin_name = plugin_name.trim();
|
||||
if plugin_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plugin_name.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let plugin_display_names: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
@@ -3594,6 +3612,13 @@ impl ChatComposer {
|
||||
|
||||
if let Some(skills) = self.skills.as_ref() {
|
||||
for skill in skills {
|
||||
let is_plugin_namespaced_skill =
|
||||
skill.name.split_once(':').is_some_and(|(namespace, _)| {
|
||||
plugin_namespaces.contains(&namespace.to_ascii_lowercase())
|
||||
});
|
||||
if is_plugin_namespaced_skill {
|
||||
continue;
|
||||
}
|
||||
let display_name = skill_display_name(skill).to_string();
|
||||
let description = skill_description(skill);
|
||||
let skill_name = skill.name.clone();
|
||||
@@ -5403,7 +5428,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
|
||||
fn mention_items_hide_plugin_owned_skill_and_app_duplicates() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -5465,27 +5490,13 @@ mod tests {
|
||||
}],
|
||||
}));
|
||||
|
||||
let mut mention_summaries: Vec<_> = composer
|
||||
.mention_items()
|
||||
.into_iter()
|
||||
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
|
||||
.collect();
|
||||
mention_summaries.sort();
|
||||
|
||||
let mentions = composer.mention_items();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
|
||||
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(
|
||||
mention_summaries,
|
||||
vec![
|
||||
(
|
||||
"Google Calendar".to_string(),
|
||||
Some("[Plugin]".to_string()),
|
||||
Some("plugin://google-calendar@debug".to_string()),
|
||||
),
|
||||
(
|
||||
"Google Calendar".to_string(),
|
||||
Some("[Skill]".to_string()),
|
||||
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
|
||||
),
|
||||
]
|
||||
mentions[0].path,
|
||||
Some("plugin://google-calendar@debug".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ use crate::terminal_title::clear_terminal_title;
|
||||
use crate::terminal_title::set_terminal_title;
|
||||
use crate::text_formatting::proper_join;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_chatgpt::connectors;
|
||||
@@ -537,6 +538,12 @@ struct PluginListFetchState {
|
||||
in_flight_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PluginInstallAuthFlowState {
|
||||
plugin_display_name: String,
|
||||
next_app_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RateLimitErrorKind {
|
||||
ServerOverloaded,
|
||||
@@ -732,6 +739,8 @@ pub(crate) struct ChatWidget {
|
||||
pending_mcp_output_requests: usize,
|
||||
plugins_cache: PluginsCacheState,
|
||||
plugins_fetch_state: PluginListFetchState,
|
||||
plugin_install_apps_needing_auth: Vec<AppSummary>,
|
||||
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
|
||||
// Queue of interruptive UI events deferred during an active write cycle
|
||||
interrupts: InterruptManager,
|
||||
// Accumulates the current reasoning block text to extract a header
|
||||
@@ -3754,6 +3763,8 @@ impl ChatWidget {
|
||||
pending_mcp_output_requests: 0,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
plugin_install_auth_flow: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -3956,6 +3967,8 @@ impl ChatWidget {
|
||||
pending_mcp_output_requests: 0,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
plugin_install_auth_flow: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -4150,6 +4163,8 @@ impl ChatWidget {
|
||||
pending_mcp_output_requests: 0,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
plugin_install_auth_flow: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -4561,7 +4576,7 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
if self.realtime_conversation.is_live() {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
} else {
|
||||
self.start_realtime_conversation();
|
||||
}
|
||||
@@ -8710,7 +8725,7 @@ impl ChatWidget {
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
return;
|
||||
}
|
||||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||||
@@ -9386,13 +9401,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if self.realtime_conversation.is_live()
|
||||
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
|
||||
{
|
||||
self.realtime_conversation.meter_placeholder_id = None;
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
}
|
||||
self.bottom_pane.remove_transcription_placeholder(id);
|
||||
// Ensure the UI redraws to reflect placeholder removal.
|
||||
self.request_redraw();
|
||||
|
||||
@@ -9,12 +9,15 @@ use crate::history_cell;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
@@ -69,19 +72,25 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
let auth_flow_active = self.plugin_install_auth_flow.is_some();
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.plugins_fetch_state.cache_cwd = Some(cwd);
|
||||
self.plugins_cache = PluginsCacheState::Ready(response.clone());
|
||||
self.refresh_plugins_popup_if_open(&response);
|
||||
if !auth_flow_active {
|
||||
self.refresh_plugins_popup_if_open(&response);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.plugins_fetch_state.cache_cwd = None;
|
||||
self.plugins_cache = PluginsCacheState::Failed(err.clone());
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugins_error_popup_params(&err),
|
||||
);
|
||||
if !auth_flow_active {
|
||||
self.plugins_fetch_state.cache_cwd = None;
|
||||
self.plugins_cache = PluginsCacheState::Failed(err.clone());
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugins_error_popup_params(&err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +139,20 @@ impl ChatWidget {
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
||||
}
|
||||
|
||||
pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) {
|
||||
let params = self.plugin_install_loading_popup_params(plugin_display_name);
|
||||
let _ = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
||||
}
|
||||
|
||||
pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) {
|
||||
let params = self.plugin_uninstall_loading_popup_params(plugin_display_name);
|
||||
let _ = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_detail_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
@@ -162,6 +185,291 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_install_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
_marketplace_path: AbsolutePathBuf,
|
||||
_plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
) -> bool {
|
||||
if self.config.cwd != cwd {
|
||||
return true;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.plugin_install_apps_needing_auth = response.apps_needing_auth;
|
||||
self.plugin_install_auth_flow = None;
|
||||
if self.plugin_install_apps_needing_auth.is_empty() {
|
||||
self.add_info_message(
|
||||
format!("Installed {plugin_display_name} plugin."),
|
||||
Some("No additional app authentication is required.".to_string()),
|
||||
);
|
||||
true
|
||||
} else {
|
||||
let app_names = self
|
||||
.plugin_install_apps_needing_auth
|
||||
.iter()
|
||||
.map(|app| app.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
self.add_info_message(
|
||||
format!("Installed {plugin_display_name} plugin."),
|
||||
Some(format!(
|
||||
"{} app(s) still need authentication: {app_names}",
|
||||
self.plugin_install_apps_needing_auth.len()
|
||||
)),
|
||||
);
|
||||
self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState {
|
||||
plugin_display_name,
|
||||
next_app_index: 0,
|
||||
});
|
||||
self.open_plugin_install_auth_popup();
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.plugin_install_apps_needing_auth.clear();
|
||||
self.plugin_install_auth_flow = None;
|
||||
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
||||
PluginsCacheState::Ready(response) => Some(response),
|
||||
_ => None,
|
||||
};
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_uninstall_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginUninstallResponse, String>,
|
||||
) {
|
||||
if self.config.cwd != cwd {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(_response) => {
|
||||
self.plugin_install_apps_needing_auth.clear();
|
||||
self.plugin_install_auth_flow = None;
|
||||
self.add_info_message(
|
||||
format!("Uninstalled {plugin_display_name} plugin."),
|
||||
Some("Bundled apps remain installed.".to_string()),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
||||
PluginsCacheState::Ready(response) => Some(response),
|
||||
_ => None,
|
||||
};
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn advance_plugin_install_auth_flow(&mut self) {
|
||||
let should_finish = {
|
||||
let Some(flow) = self.plugin_install_auth_flow.as_mut() else {
|
||||
return;
|
||||
};
|
||||
flow.next_app_index += 1;
|
||||
flow.next_app_index >= self.plugin_install_apps_needing_auth.len()
|
||||
};
|
||||
|
||||
if should_finish {
|
||||
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_plugin_install_auth_popup();
|
||||
}
|
||||
|
||||
pub(crate) fn abandon_plugin_install_auth_flow(&mut self) {
|
||||
self.finish_plugin_install_auth_flow(/*abandoned*/ true);
|
||||
}
|
||||
|
||||
fn open_plugin_install_auth_popup(&mut self) {
|
||||
let Some(params) = self.plugin_install_auth_popup_params() else {
|
||||
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
|
||||
return;
|
||||
};
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
||||
&& let Some(params) = self.plugin_install_auth_popup_params()
|
||||
{
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_install_auth_popup_params(&self) -> Option<SelectionViewParams> {
|
||||
let flow = self.plugin_install_auth_flow.as_ref()?;
|
||||
let app = self
|
||||
.plugin_install_apps_needing_auth
|
||||
.get(flow.next_app_index)?;
|
||||
let total = self.plugin_install_apps_needing_auth.len();
|
||||
let current = flow.next_app_index + 1;
|
||||
let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str());
|
||||
let status_label = if is_installed {
|
||||
"Already installed in this session."
|
||||
} else {
|
||||
"Not installed yet."
|
||||
};
|
||||
let description = app
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("{} plugin installed.", flow.plugin_display_name).bold(),
|
||||
));
|
||||
header.push(Line::from(
|
||||
format!("App setup {current}/{total}: {}", app.name).dim(),
|
||||
));
|
||||
header.push(Line::from(status_label.dim()));
|
||||
|
||||
let mut items = vec![SelectionItem {
|
||||
name: app.name.clone(),
|
||||
description,
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
if let Some(install_url) = app.install_url.clone() {
|
||||
let install_label = if is_installed {
|
||||
"Manage on ChatGPT"
|
||||
} else {
|
||||
"Install on ChatGPT"
|
||||
};
|
||||
items.push(SelectionItem {
|
||||
name: install_label.to_string(),
|
||||
description: Some(
|
||||
"Open the same ChatGPT app management link used by /apps.".to_string(),
|
||||
),
|
||||
selected_description: Some("Open the app page in your browser.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: install_url.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
items.push(SelectionItem {
|
||||
name: "ChatGPT link unavailable".to_string(),
|
||||
description: Some("This app did not provide an install/manage URL.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if is_installed {
|
||||
items.push(SelectionItem {
|
||||
name: "Continue".to_string(),
|
||||
description: Some("This app is already installed.".to_string()),
|
||||
selected_description: Some("Advance to the next app.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::PluginInstallAuthAdvance {
|
||||
refresh_connectors: false,
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
items.push(SelectionItem {
|
||||
name: "I've installed it".to_string(),
|
||||
description: Some(
|
||||
"Trust your confirmation and continue to the next app.".to_string(),
|
||||
),
|
||||
selected_description: Some(
|
||||
"Continue without waiting for refresh to complete.".to_string(),
|
||||
),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::PluginInstallAuthAdvance {
|
||||
refresh_connectors: true,
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Skip remaining app setup".to_string(),
|
||||
description: Some("Stop this follow-up flow for this plugin.".to_string()),
|
||||
selected_description: Some("Abandon remaining required app setup.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::PluginInstallAuthAbandon);
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Some(SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(plugins_popup_hint_line()),
|
||||
items,
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool {
|
||||
self.connectors_for_mentions().is_some_and(|connectors| {
|
||||
connectors
|
||||
.iter()
|
||||
.any(|connector| connector.id == app_id && connector.is_accessible)
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) {
|
||||
let Some(flow) = self.plugin_install_auth_flow.take() else {
|
||||
return;
|
||||
};
|
||||
self.plugin_install_apps_needing_auth.clear();
|
||||
if abandoned {
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Skipped remaining app setup for {} plugin.",
|
||||
flow.plugin_display_name
|
||||
),
|
||||
Some("The plugin may not be usable until required apps are installed.".to_string()),
|
||||
);
|
||||
} else {
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Completed app setup flow for {} plugin.",
|
||||
flow.plugin_display_name
|
||||
),
|
||||
Some("You can now continue managing plugins from /plugins.".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
||||
PluginsCacheState::Ready(response) => Some(response),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(plugins_response) = plugins_response {
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugins_popup_params(&plugins_response),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) {
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
@@ -212,6 +520,52 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_install_loading_popup_params(
|
||||
&self,
|
||||
plugin_display_name: &str,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Installing {plugin_display_name}...").dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![SelectionItem {
|
||||
name: "Installing plugin...".to_string(),
|
||||
description: Some("This updates when plugin installation completes.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_uninstall_loading_popup_params(
|
||||
&self,
|
||||
plugin_display_name: &str,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Uninstalling {plugin_display_name}...").dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![SelectionItem {
|
||||
name: "Uninstalling plugin...".to_string(),
|
||||
description: Some("This updates when plugin removal completes.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
@@ -397,6 +751,59 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
if plugin.summary.installed {
|
||||
let uninstall_cwd = self.config.cwd.clone();
|
||||
let plugin_id = plugin.summary.id.clone();
|
||||
let plugin_display_name = display_name;
|
||||
items.push(SelectionItem {
|
||||
name: "Uninstall plugin".to_string(),
|
||||
description: Some("Remove this plugin now.".to_string()),
|
||||
selected_description: Some("Remove this plugin now.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenPluginUninstallLoading {
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
tx.send(AppEvent::FetchPluginUninstall {
|
||||
cwd: uninstall_cwd.clone(),
|
||||
plugin_id: plugin_id.clone(),
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
} else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable {
|
||||
items.push(SelectionItem {
|
||||
name: "Install plugin".to_string(),
|
||||
description: Some(
|
||||
"This plugin is not installable from this marketplace.".to_string(),
|
||||
),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
let install_cwd = self.config.cwd.clone();
|
||||
let marketplace_path = plugin.marketplace_path.clone();
|
||||
let plugin_name = plugin.summary.name.clone();
|
||||
let plugin_display_name = display_name;
|
||||
items.push(SelectionItem {
|
||||
name: "Install plugin".to_string(),
|
||||
description: Some("Install this plugin now.".to_string()),
|
||||
selected_description: Some("Install this plugin now.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenPluginInstallLoading {
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
tx.send(AppEvent::FetchPluginInstall {
|
||||
cwd: install_cwd.clone(),
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
plugin_name: plugin_name.clone(),
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Skills".to_string(),
|
||||
description: Some(plugin_skill_summary(plugin)),
|
||||
|
||||
@@ -106,6 +106,23 @@ pub(super) struct PendingSteerCompareKey {
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
|
||||
if self.realtime_conversation.is_live()
|
||||
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
|
||||
{
|
||||
self.realtime_conversation.meter_placeholder_id = None;
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn rendered_user_message_event_from_parts(
|
||||
message: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Figma · ChatGPT Marketplace
|
||||
Can be installed
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Install plugin Install this plugin now.
|
||||
3. Skills design-review, extract-copy
|
||||
4. Apps Figma, Slack
|
||||
5. MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Browse plugins from the ChatGPT marketplace.
|
||||
Installed 1 of 3 available plugins.
|
||||
Using cached marketplace data: remote sync timed out
|
||||
|
||||
Type to search plugins
|
||||
› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details.
|
||||
Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already
|
||||
installed but disabled.
|
||||
Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by
|
||||
default.
|
||||
|
||||
Press esc to close.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Loading available plugins...
|
||||
This first pass shows the ChatGPT marketplace only.
|
||||
|
||||
› 1. Loading plugins... This updates when the marketplace list is ready.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Browse plugins from the ChatGPT marketplace.
|
||||
Installed 0 of 3 available plugins.
|
||||
|
||||
sla
|
||||
› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details.
|
||||
|
||||
Press esc to close.
|
||||
@@ -18,6 +18,18 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::MarketplaceInterface;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::SkillSummary;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::ApprovalsReviewer;
|
||||
use codex_core::config::Config;
|
||||
@@ -35,6 +47,7 @@ use codex_core::config_loader::ConfigRequirementsToml;
|
||||
use codex_core::config_loader::RequirementSource;
|
||||
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
@@ -2021,6 +2034,8 @@ async fn make_chatwidget_manual(
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
connectors_partial_snapshot: None,
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
plugin_install_auth_flow: None,
|
||||
connectors_prefetch_in_flight: false,
|
||||
connectors_force_refetch_pending: false,
|
||||
pending_mcp_output_requests: 0,
|
||||
@@ -5193,13 +5208,13 @@ async fn realtime_error_closes_without_followup_closed_info() {
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn removing_active_realtime_placeholder_closes_realtime_conversation() {
|
||||
async fn deleted_realtime_meter_uses_shared_stop_path() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
|
||||
chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
|
||||
|
||||
chat.remove_transcription_placeholder(&placeholder_id);
|
||||
assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id));
|
||||
|
||||
next_realtime_close_op(&mut op_rx);
|
||||
assert_eq!(chat.realtime_conversation.meter_placeholder_id, None);
|
||||
@@ -7067,6 +7082,513 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
std::env::temp_dir()
|
||||
.join("codex-plugin-menu-tests")
|
||||
.join(path),
|
||||
)
|
||||
.expect("expected absolute test path")
|
||||
}
|
||||
|
||||
fn plugins_test_interface(
|
||||
display_name: Option<&str>,
|
||||
short_description: Option<&str>,
|
||||
long_description: Option<&str>,
|
||||
) -> PluginInterface {
|
||||
PluginInterface {
|
||||
display_name: display_name.map(str::to_string),
|
||||
short_description: short_description.map(str::to_string),
|
||||
long_description: long_description.map(str::to_string),
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: Vec::new(),
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
logo: None,
|
||||
screenshots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_summary(
|
||||
id: &str,
|
||||
name: &str,
|
||||
display_name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
installed: bool,
|
||||
enabled: bool,
|
||||
install_policy: PluginInstallPolicy,
|
||||
) -> PluginSummary {
|
||||
PluginSummary {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
source: PluginSource::Local {
|
||||
path: plugins_test_absolute_path(&format!("plugins/{name}")),
|
||||
},
|
||||
installed,
|
||||
enabled,
|
||||
install_policy,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
interface: Some(plugins_test_interface(display_name, description, None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_curated_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
|
||||
PluginMarketplaceEntry {
|
||||
name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
path: plugins_test_absolute_path("marketplaces/chatgpt"),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("ChatGPT Marketplace".to_string()),
|
||||
}),
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
|
||||
PluginMarketplaceEntry {
|
||||
name: "repo".to_string(),
|
||||
path: plugins_test_absolute_path("marketplaces/repo"),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Repo Marketplace".to_string()),
|
||||
}),
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_response(marketplaces: Vec<PluginMarketplaceEntry>) -> PluginListResponse {
|
||||
PluginListResponse {
|
||||
marketplaces,
|
||||
remote_sync_error: None,
|
||||
featured_plugin_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String {
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd, Ok(response));
|
||||
chat.add_plugins_output();
|
||||
render_bottom_popup(chat, 100)
|
||||
}
|
||||
|
||||
fn plugins_test_detail(
|
||||
summary: PluginSummary,
|
||||
description: Option<&str>,
|
||||
skills: &[&str],
|
||||
apps: &[(&str, bool)],
|
||||
mcp_servers: &[&str],
|
||||
) -> PluginDetail {
|
||||
PluginDetail {
|
||||
marketplace_name: "ChatGPT Marketplace".to_string(),
|
||||
marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"),
|
||||
summary,
|
||||
description: description.map(str::to_string),
|
||||
skills: skills
|
||||
.iter()
|
||||
.map(|name| SkillSummary {
|
||||
name: (*name).to_string(),
|
||||
description: format!("{name} description"),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
|
||||
enabled: true,
|
||||
})
|
||||
.collect(),
|
||||
apps: apps
|
||||
.iter()
|
||||
.map(|(name, needs_auth)| AppSummary {
|
||||
id: format!("{name}-id"),
|
||||
name: (*name).to_string(),
|
||||
description: Some(format!("{name} app")),
|
||||
install_url: Some(format!("https://example.test/{name}")),
|
||||
needs_auth: *needs_auth,
|
||||
})
|
||||
.collect(),
|
||||
mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize {
|
||||
popup
|
||||
.find(needle)
|
||||
.unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}"))
|
||||
}
|
||||
|
||||
fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
|
||||
for ch in query.chars() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_loading_state_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
chat.add_plugins_output();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
popup.contains("Loading available plugins..."),
|
||||
"expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}"
|
||||
);
|
||||
assert_snapshot!("plugins_popup_loading_state", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let mut response = plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-bravo",
|
||||
"bravo",
|
||||
Some("Bravo Search"),
|
||||
Some("Search docs and tickets."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-alpha",
|
||||
"alpha",
|
||||
Some("Alpha Sync"),
|
||||
Some("Already installed but disabled."),
|
||||
true,
|
||||
false,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-starter",
|
||||
"starter",
|
||||
Some("Starter"),
|
||||
Some("Included by default."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::InstalledByDefault,
|
||||
),
|
||||
]),
|
||||
plugins_test_repo_marketplace(vec![plugins_test_summary(
|
||||
"plugin-hidden",
|
||||
"hidden",
|
||||
Some("Hidden Repo Plugin"),
|
||||
Some("Should not be shown in /plugins."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
)]),
|
||||
]);
|
||||
response.remote_sync_error = Some("remote sync timed out".to_string());
|
||||
|
||||
let popup = render_loaded_plugins_popup(&mut chat, response);
|
||||
assert_snapshot!("plugins_popup_curated_marketplace", popup);
|
||||
assert!(
|
||||
!popup.contains("Hidden Repo Plugin"),
|
||||
"expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
plugins_test_popup_row_position(&popup, "Bravo Search")
|
||||
< plugins_test_popup_row_position(&popup, "Alpha Sync")
|
||||
&& plugins_test_popup_row_position(&popup, "Alpha Sync")
|
||||
< plugins_test_popup_row_position(&popup, "Starter"),
|
||||
"expected /plugins rows to keep response order, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let summary = plugins_test_summary(
|
||||
"plugin-figma",
|
||||
"figma",
|
||||
Some("Figma"),
|
||||
Some("Design handoff."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
);
|
||||
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
summary.clone(),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd.clone(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd,
|
||||
Ok(PluginReadResponse {
|
||||
plugin: plugins_test_detail(
|
||||
summary,
|
||||
Some("Turn Figma files into implementation context."),
|
||||
&["design-review", "extract-copy"],
|
||||
&[("Figma", true), ("Slack", false)],
|
||||
&["figma-mcp", "docs-mcp"],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert_snapshot!("plugin_detail_popup_installable", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_refresh_replaces_selection_with_first_row() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-notion",
|
||||
"notion",
|
||||
Some("Notion"),
|
||||
Some("Workspace docs."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
render_loaded_plugins_popup(&mut chat, initial);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
|
||||
let before = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
before.contains("› Slack"),
|
||||
"expected Slack to be selected before refresh, got:\n{before}"
|
||||
);
|
||||
|
||||
let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-airtable",
|
||||
"airtable",
|
||||
Some("Airtable"),
|
||||
Some("Structured records."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-notion",
|
||||
"notion",
|
||||
Some("Notion"),
|
||||
Some("Workspace docs."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd, Ok(refreshed));
|
||||
|
||||
let after = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
after.contains("› Airtable"),
|
||||
"expected refresh to rebuild the popup from the new first row, got:\n{after}"
|
||||
);
|
||||
assert!(
|
||||
after.contains("Slack · ChatGPT Marketplace"),
|
||||
"expected refreshed popup to include the updated plugin list, got:\n{after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_refreshes_installed_counts_after_install() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-drive",
|
||||
"drive",
|
||||
Some("Drive"),
|
||||
Some("Document access."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
let before = render_loaded_plugins_popup(&mut chat, initial);
|
||||
assert!(
|
||||
before.contains("Installed 1 of 2 available plugins."),
|
||||
"expected initial installed count before refresh, got:\n{before}"
|
||||
);
|
||||
assert!(
|
||||
before.contains("Can be installed"),
|
||||
"expected pre-install popup copy before refresh, got:\n{before}"
|
||||
);
|
||||
|
||||
let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-drive",
|
||||
"drive",
|
||||
Some("Drive"),
|
||||
Some("Document access."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd, Ok(refreshed));
|
||||
|
||||
let after = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
after.contains("Installed 2 of 2 available plugins."),
|
||||
"expected /plugins to refresh installed counts after install, got:\n{after}"
|
||||
);
|
||||
assert!(
|
||||
after.contains("Installed. Press Enter to view plugin details."),
|
||||
"expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_search_filters_visible_rows_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-drive",
|
||||
"drive",
|
||||
Some("Drive"),
|
||||
Some("Document access."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]),
|
||||
);
|
||||
|
||||
type_plugins_search_query(&mut chat, "sla");
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert_snapshot!("plugins_popup_search_filtered", popup);
|
||||
assert!(
|
||||
!popup.contains("Calendar · ChatGPT Marketplace")
|
||||
&& !popup.contains("Drive · ChatGPT Marketplace"),
|
||||
"expected search to leave only matching rows visible, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_search_no_matches_and_backspace_restores_results() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]),
|
||||
);
|
||||
|
||||
type_plugins_search_query(&mut chat, "zzz");
|
||||
|
||||
let no_matches = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
no_matches.contains("zzz"),
|
||||
"expected popup to show the typed search query, got:\n{no_matches}"
|
||||
);
|
||||
assert!(
|
||||
no_matches.contains("no matches"),
|
||||
"expected popup to render the no-matches UX, got:\n{no_matches}"
|
||||
);
|
||||
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
}
|
||||
|
||||
let restored = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
restored.contains("Calendar · ChatGPT Marketplace")
|
||||
&& restored.contains("Slack · ChatGPT Marketplace"),
|
||||
"expected clearing the query to restore the plugin rows, got:\n{restored}"
|
||||
);
|
||||
assert!(
|
||||
!restored.contains("no matches"),
|
||||
"did not expect the no-matches state after clearing the query, got:\n{restored}"
|
||||
);
|
||||
}
|
||||
|
||||
fn selected_permissions_popup_line(popup: &str) -> &str {
|
||||
popup
|
||||
.lines()
|
||||
|
||||
@@ -55,10 +55,14 @@ use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
@@ -1831,6 +1835,57 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_plugin_install(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let cwd_for_event = cwd.clone();
|
||||
let marketplace_path_for_event = marketplace_path.clone();
|
||||
let plugin_name_for_event = plugin_name.clone();
|
||||
let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to install plugin: {err}"));
|
||||
app_event_tx.send(AppEvent::PluginInstallLoaded {
|
||||
cwd: cwd_for_event,
|
||||
marketplace_path: marketplace_path_for_event,
|
||||
plugin_name: plugin_name_for_event,
|
||||
plugin_display_name,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_plugin_uninstall(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let cwd_for_event = cwd.clone();
|
||||
let plugin_id_for_event = plugin_id.clone();
|
||||
let result = fetch_plugin_uninstall(request_handle, plugin_id)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to uninstall plugin: {err}"));
|
||||
app_event_tx.send(AppEvent::PluginUninstallLoaded {
|
||||
cwd: cwd_for_event,
|
||||
plugin_id: plugin_id_for_event,
|
||||
plugin_display_name,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Process the completed MCP inventory fetch: clear the loading spinner, then
|
||||
/// render either the full tool/resource listing or an error into chat history.
|
||||
///
|
||||
@@ -3555,6 +3610,15 @@ impl App {
|
||||
AppEvent::RefreshConnectors { force_refetch } => {
|
||||
self.chat_widget.refresh_connectors(force_refetch);
|
||||
}
|
||||
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
|
||||
if refresh_connectors {
|
||||
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
|
||||
}
|
||||
self.chat_widget.advance_plugin_install_auth_flow();
|
||||
}
|
||||
AppEvent::PluginInstallAuthAbandon => {
|
||||
self.chat_widget.abandon_plugin_install_auth_flow();
|
||||
}
|
||||
AppEvent::FetchPluginsList { cwd } => {
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
@@ -3564,6 +3628,18 @@ impl App {
|
||||
self.chat_widget
|
||||
.open_plugin_detail_loading_popup(&plugin_display_name);
|
||||
}
|
||||
AppEvent::OpenPluginInstallLoading {
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_plugin_install_loading_popup(&plugin_display_name);
|
||||
}
|
||||
AppEvent::OpenPluginUninstallLoading {
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_plugin_uninstall_loading_popup(&plugin_display_name);
|
||||
}
|
||||
AppEvent::PluginsLoaded { cwd, result } => {
|
||||
self.chat_widget.on_plugins_loaded(cwd, result);
|
||||
}
|
||||
@@ -3573,6 +3649,62 @@ impl App {
|
||||
AppEvent::PluginDetailLoaded { cwd, result } => {
|
||||
self.chat_widget.on_plugin_detail_loaded(cwd, result);
|
||||
}
|
||||
AppEvent::FetchPluginInstall {
|
||||
cwd,
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.fetch_plugin_install(
|
||||
app_server,
|
||||
cwd,
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
);
|
||||
}
|
||||
AppEvent::FetchPluginUninstall {
|
||||
cwd,
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name);
|
||||
}
|
||||
AppEvent::PluginInstallLoaded {
|
||||
cwd,
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
result,
|
||||
} => {
|
||||
let install_succeeded = result.is_ok();
|
||||
if install_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after plugin install");
|
||||
}
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
}
|
||||
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
||||
cwd.clone(),
|
||||
marketplace_path.clone(),
|
||||
plugin_name.clone(),
|
||||
plugin_display_name,
|
||||
result,
|
||||
);
|
||||
if install_succeeded && self.chat_widget.config_ref().cwd == cwd {
|
||||
self.fetch_plugins_list(app_server, cwd.clone());
|
||||
if should_refresh_plugin_detail {
|
||||
self.fetch_plugin_detail(
|
||||
app_server,
|
||||
cwd,
|
||||
PluginReadParams {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::FetchMcpInventory => {
|
||||
self.fetch_mcp_inventory(app_server);
|
||||
}
|
||||
@@ -4012,6 +4144,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::PluginUninstallLoaded {
|
||||
cwd,
|
||||
plugin_id: _plugin_id,
|
||||
plugin_display_name,
|
||||
result,
|
||||
} => {
|
||||
let uninstall_succeeded = result.is_ok();
|
||||
if uninstall_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after plugin uninstall"
|
||||
);
|
||||
}
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
}
|
||||
self.chat_widget.on_plugin_uninstall_loaded(
|
||||
cwd.clone(),
|
||||
plugin_display_name,
|
||||
result,
|
||||
);
|
||||
if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd {
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
}
|
||||
AppEvent::PersistPersonalitySelection { personality } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
@@ -4542,7 +4699,11 @@ impl App {
|
||||
AppEvent::UpdateRecordingMeter { id, text } => {
|
||||
// Update in place to preserve the element id for subsequent frames.
|
||||
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
|
||||
if updated {
|
||||
if updated
|
||||
|| self
|
||||
.chat_widget
|
||||
.stop_realtime_conversation_for_deleted_meter(&id)
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
@@ -5133,6 +5294,42 @@ async fn fetch_plugin_detail(
|
||||
.wrap_err("plugin/read failed in app-server TUI")
|
||||
}
|
||||
|
||||
async fn fetch_plugin_install(
|
||||
request_handle: AppServerRequestHandle,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
) -> Result<PluginInstallResponse> {
|
||||
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
|
||||
request_handle
|
||||
.request_typed(ClientRequest::PluginInstall {
|
||||
request_id,
|
||||
params: PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
force_remote_sync: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("plugin/install failed in app-server TUI")
|
||||
}
|
||||
|
||||
async fn fetch_plugin_uninstall(
|
||||
request_handle: AppServerRequestHandle,
|
||||
plugin_id: String,
|
||||
) -> Result<PluginUninstallResponse> {
|
||||
let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4()));
|
||||
request_handle
|
||||
.request_typed(ClientRequest::PluginUninstall {
|
||||
request_id,
|
||||
params: PluginUninstallParams {
|
||||
plugin_id,
|
||||
force_remote_sync: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("plugin/uninstall failed in app-server TUI")
|
||||
}
|
||||
|
||||
/// Convert flat `McpServerStatus` responses into the per-server maps used by the
|
||||
/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus
|
||||
/// per-server resource/template/auth maps). Test-only because the app-server TUI
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -21,6 +23,7 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
@@ -195,6 +198,56 @@ pub(crate) enum AppEvent {
|
||||
result: Result<PluginReadResponse, String>,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with an install loading state.
|
||||
OpenPluginInstallLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with an uninstall loading state.
|
||||
OpenPluginUninstallLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Install a specific plugin from a marketplace.
|
||||
FetchPluginInstall {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of installing a plugin.
|
||||
PluginInstallLoaded {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
},
|
||||
|
||||
/// Uninstall a specific plugin by canonical plugin id.
|
||||
FetchPluginUninstall {
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of uninstalling a plugin.
|
||||
PluginUninstallLoaded {
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginUninstallResponse, String>,
|
||||
},
|
||||
|
||||
/// Advance the post-install plugin app-auth flow.
|
||||
PluginInstallAuthAdvance {
|
||||
refresh_connectors: bool,
|
||||
},
|
||||
|
||||
/// Abandon the post-install plugin app-auth flow.
|
||||
PluginInstallAuthAbandon,
|
||||
|
||||
/// Fetch MCP inventory via app-server RPCs and render it into history.
|
||||
FetchMcpInventory,
|
||||
|
||||
|
||||
@@ -3599,6 +3599,24 @@ impl ChatComposer {
|
||||
|
||||
fn mention_items(&self) -> Vec<MentionItem> {
|
||||
let mut mentions = Vec::new();
|
||||
let plugin_namespaces: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let (plugin_name, _) = plugin
|
||||
.config_name
|
||||
.split_once('@')
|
||||
.unwrap_or((plugin.config_name.as_str(), ""));
|
||||
let plugin_name = plugin_name.trim();
|
||||
if plugin_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plugin_name.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let plugin_display_names: HashSet<String> =
|
||||
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
|
||||
plugins
|
||||
@@ -3609,6 +3627,13 @@ impl ChatComposer {
|
||||
|
||||
if let Some(skills) = self.skills.as_ref() {
|
||||
for skill in skills {
|
||||
let is_plugin_namespaced_skill =
|
||||
skill.name.split_once(':').is_some_and(|(namespace, _)| {
|
||||
plugin_namespaces.contains(&namespace.to_ascii_lowercase())
|
||||
});
|
||||
if is_plugin_namespaced_skill {
|
||||
continue;
|
||||
}
|
||||
let display_name = skill_display_name(skill).to_string();
|
||||
let description = skill_description(skill);
|
||||
let skill_name = skill.name.clone();
|
||||
@@ -5418,7 +5443,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
|
||||
fn mention_items_hide_plugin_owned_skill_and_app_duplicates() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -5480,27 +5505,13 @@ mod tests {
|
||||
}],
|
||||
}));
|
||||
|
||||
let mut mention_summaries: Vec<_> = composer
|
||||
.mention_items()
|
||||
.into_iter()
|
||||
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
|
||||
.collect();
|
||||
mention_summaries.sort();
|
||||
|
||||
let mentions = composer.mention_items();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
|
||||
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(
|
||||
mention_summaries,
|
||||
vec![
|
||||
(
|
||||
"Google Calendar".to_string(),
|
||||
Some("[Plugin]".to_string()),
|
||||
Some("plugin://google-calendar@debug".to_string()),
|
||||
),
|
||||
(
|
||||
"Google Calendar".to_string(),
|
||||
Some("[Skill]".to_string()),
|
||||
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
|
||||
),
|
||||
]
|
||||
mentions[0].path,
|
||||
Some("plugin://google-calendar@debug".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ use crate::status::format_tokens_compact;
|
||||
use crate::status::rate_limit_snapshot_display_for_limit;
|
||||
use crate::text_formatting::proper_join;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
|
||||
use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
|
||||
@@ -566,6 +567,12 @@ struct PluginListFetchState {
|
||||
in_flight_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PluginInstallAuthFlowState {
|
||||
plugin_display_name: String,
|
||||
next_app_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RateLimitErrorKind {
|
||||
ServerOverloaded,
|
||||
@@ -772,6 +779,8 @@ pub(crate) struct ChatWidget {
|
||||
connectors_force_refetch_pending: bool,
|
||||
plugins_cache: PluginsCacheState,
|
||||
plugins_fetch_state: PluginListFetchState,
|
||||
plugin_install_apps_needing_auth: Vec<AppSummary>,
|
||||
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
|
||||
// Queue of interruptive UI events deferred during an active write cycle
|
||||
interrupts: InterruptManager,
|
||||
// Accumulates the current reasoning block text to extract a header
|
||||
@@ -4316,6 +4325,8 @@ impl ChatWidget {
|
||||
connectors_force_refetch_pending: false,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
plugin_install_auth_flow: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -4721,7 +4732,7 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
if self.realtime_conversation.is_live() {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
} else {
|
||||
self.start_realtime_conversation();
|
||||
}
|
||||
@@ -9948,7 +9959,7 @@ impl ChatWidget {
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
return;
|
||||
}
|
||||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||||
@@ -10561,13 +10572,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if self.realtime_conversation.is_live()
|
||||
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
|
||||
{
|
||||
self.realtime_conversation.meter_placeholder_id = None;
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
}
|
||||
self.bottom_pane.remove_transcription_placeholder(id);
|
||||
// Ensure the UI redraws to reflect placeholder removal.
|
||||
self.request_redraw();
|
||||
|
||||
@@ -9,12 +9,15 @@ use crate::history_cell;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
@@ -69,19 +72,25 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
let auth_flow_active = self.plugin_install_auth_flow.is_some();
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.plugins_fetch_state.cache_cwd = Some(cwd);
|
||||
self.plugins_cache = PluginsCacheState::Ready(response.clone());
|
||||
self.refresh_plugins_popup_if_open(&response);
|
||||
if !auth_flow_active {
|
||||
self.refresh_plugins_popup_if_open(&response);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.plugins_fetch_state.cache_cwd = None;
|
||||
self.plugins_cache = PluginsCacheState::Failed(err.clone());
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugins_error_popup_params(&err),
|
||||
);
|
||||
if !auth_flow_active {
|
||||
self.plugins_fetch_state.cache_cwd = None;
|
||||
self.plugins_cache = PluginsCacheState::Failed(err.clone());
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugins_error_popup_params(&err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +139,20 @@ impl ChatWidget {
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
||||
}
|
||||
|
||||
pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) {
|
||||
let params = self.plugin_install_loading_popup_params(plugin_display_name);
|
||||
let _ = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
||||
}
|
||||
|
||||
pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) {
|
||||
let params = self.plugin_uninstall_loading_popup_params(plugin_display_name);
|
||||
let _ = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_detail_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
@@ -162,6 +185,291 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_install_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
_marketplace_path: AbsolutePathBuf,
|
||||
_plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
) -> bool {
|
||||
if self.config.cwd != cwd {
|
||||
return true;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.plugin_install_apps_needing_auth = response.apps_needing_auth;
|
||||
self.plugin_install_auth_flow = None;
|
||||
if self.plugin_install_apps_needing_auth.is_empty() {
|
||||
self.add_info_message(
|
||||
format!("Installed {plugin_display_name} plugin."),
|
||||
Some("No additional app authentication is required.".to_string()),
|
||||
);
|
||||
true
|
||||
} else {
|
||||
let app_names = self
|
||||
.plugin_install_apps_needing_auth
|
||||
.iter()
|
||||
.map(|app| app.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
self.add_info_message(
|
||||
format!("Installed {plugin_display_name} plugin."),
|
||||
Some(format!(
|
||||
"{} app(s) still need authentication: {app_names}",
|
||||
self.plugin_install_apps_needing_auth.len()
|
||||
)),
|
||||
);
|
||||
self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState {
|
||||
plugin_display_name,
|
||||
next_app_index: 0,
|
||||
});
|
||||
self.open_plugin_install_auth_popup();
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.plugin_install_apps_needing_auth.clear();
|
||||
self.plugin_install_auth_flow = None;
|
||||
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
||||
PluginsCacheState::Ready(response) => Some(response),
|
||||
_ => None,
|
||||
};
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_uninstall_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginUninstallResponse, String>,
|
||||
) {
|
||||
if self.config.cwd != cwd {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(_response) => {
|
||||
self.plugin_install_apps_needing_auth.clear();
|
||||
self.plugin_install_auth_flow = None;
|
||||
self.add_info_message(
|
||||
format!("Uninstalled {plugin_display_name} plugin."),
|
||||
Some("Bundled apps remain installed.".to_string()),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
||||
PluginsCacheState::Ready(response) => Some(response),
|
||||
_ => None,
|
||||
};
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn advance_plugin_install_auth_flow(&mut self) {
|
||||
let should_finish = {
|
||||
let Some(flow) = self.plugin_install_auth_flow.as_mut() else {
|
||||
return;
|
||||
};
|
||||
flow.next_app_index += 1;
|
||||
flow.next_app_index >= self.plugin_install_apps_needing_auth.len()
|
||||
};
|
||||
|
||||
if should_finish {
|
||||
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_plugin_install_auth_popup();
|
||||
}
|
||||
|
||||
pub(crate) fn abandon_plugin_install_auth_flow(&mut self) {
|
||||
self.finish_plugin_install_auth_flow(/*abandoned*/ true);
|
||||
}
|
||||
|
||||
fn open_plugin_install_auth_popup(&mut self) {
|
||||
let Some(params) = self.plugin_install_auth_popup_params() else {
|
||||
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
|
||||
return;
|
||||
};
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
||||
&& let Some(params) = self.plugin_install_auth_popup_params()
|
||||
{
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_install_auth_popup_params(&self) -> Option<SelectionViewParams> {
|
||||
let flow = self.plugin_install_auth_flow.as_ref()?;
|
||||
let app = self
|
||||
.plugin_install_apps_needing_auth
|
||||
.get(flow.next_app_index)?;
|
||||
let total = self.plugin_install_apps_needing_auth.len();
|
||||
let current = flow.next_app_index + 1;
|
||||
let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str());
|
||||
let status_label = if is_installed {
|
||||
"Already installed in this session."
|
||||
} else {
|
||||
"Not installed yet."
|
||||
};
|
||||
let description = app
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("{} plugin installed.", flow.plugin_display_name).bold(),
|
||||
));
|
||||
header.push(Line::from(
|
||||
format!("App setup {current}/{total}: {}", app.name).dim(),
|
||||
));
|
||||
header.push(Line::from(status_label.dim()));
|
||||
|
||||
let mut items = vec![SelectionItem {
|
||||
name: app.name.clone(),
|
||||
description,
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
if let Some(install_url) = app.install_url.clone() {
|
||||
let install_label = if is_installed {
|
||||
"Manage on ChatGPT"
|
||||
} else {
|
||||
"Install on ChatGPT"
|
||||
};
|
||||
items.push(SelectionItem {
|
||||
name: install_label.to_string(),
|
||||
description: Some(
|
||||
"Open the same ChatGPT app management link used by /apps.".to_string(),
|
||||
),
|
||||
selected_description: Some("Open the app page in your browser.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: install_url.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
items.push(SelectionItem {
|
||||
name: "ChatGPT link unavailable".to_string(),
|
||||
description: Some("This app did not provide an install/manage URL.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if is_installed {
|
||||
items.push(SelectionItem {
|
||||
name: "Continue".to_string(),
|
||||
description: Some("This app is already installed.".to_string()),
|
||||
selected_description: Some("Advance to the next app.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::PluginInstallAuthAdvance {
|
||||
refresh_connectors: false,
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
items.push(SelectionItem {
|
||||
name: "I've installed it".to_string(),
|
||||
description: Some(
|
||||
"Trust your confirmation and continue to the next app.".to_string(),
|
||||
),
|
||||
selected_description: Some(
|
||||
"Continue without waiting for refresh to complete.".to_string(),
|
||||
),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::PluginInstallAuthAdvance {
|
||||
refresh_connectors: true,
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Skip remaining app setup".to_string(),
|
||||
description: Some("Stop this follow-up flow for this plugin.".to_string()),
|
||||
selected_description: Some("Abandon remaining required app setup.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::PluginInstallAuthAbandon);
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Some(SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(plugins_popup_hint_line()),
|
||||
items,
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool {
|
||||
self.connectors_for_mentions().is_some_and(|connectors| {
|
||||
connectors
|
||||
.iter()
|
||||
.any(|connector| connector.id == app_id && connector.is_accessible)
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) {
|
||||
let Some(flow) = self.plugin_install_auth_flow.take() else {
|
||||
return;
|
||||
};
|
||||
self.plugin_install_apps_needing_auth.clear();
|
||||
if abandoned {
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Skipped remaining app setup for {} plugin.",
|
||||
flow.plugin_display_name
|
||||
),
|
||||
Some("The plugin may not be usable until required apps are installed.".to_string()),
|
||||
);
|
||||
} else {
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Completed app setup flow for {} plugin.",
|
||||
flow.plugin_display_name
|
||||
),
|
||||
Some("You can now continue managing plugins from /plugins.".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
||||
PluginsCacheState::Ready(response) => Some(response),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(plugins_response) = plugins_response {
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
self.plugins_popup_params(&plugins_response),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) {
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
PLUGINS_SELECTION_VIEW_ID,
|
||||
@@ -212,6 +520,52 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_install_loading_popup_params(
|
||||
&self,
|
||||
plugin_display_name: &str,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Installing {plugin_display_name}...").dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![SelectionItem {
|
||||
name: "Installing plugin...".to_string(),
|
||||
description: Some("This updates when plugin installation completes.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_uninstall_loading_popup_params(
|
||||
&self,
|
||||
plugin_display_name: &str,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Uninstalling {plugin_display_name}...").dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![SelectionItem {
|
||||
name: "Uninstalling plugin...".to_string(),
|
||||
description: Some("This updates when plugin removal completes.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
@@ -397,6 +751,59 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
if plugin.summary.installed {
|
||||
let uninstall_cwd = self.config.cwd.clone();
|
||||
let plugin_id = plugin.summary.id.clone();
|
||||
let plugin_display_name = display_name;
|
||||
items.push(SelectionItem {
|
||||
name: "Uninstall plugin".to_string(),
|
||||
description: Some("Remove this plugin now.".to_string()),
|
||||
selected_description: Some("Remove this plugin now.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenPluginUninstallLoading {
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
tx.send(AppEvent::FetchPluginUninstall {
|
||||
cwd: uninstall_cwd.clone(),
|
||||
plugin_id: plugin_id.clone(),
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
} else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable {
|
||||
items.push(SelectionItem {
|
||||
name: "Install plugin".to_string(),
|
||||
description: Some(
|
||||
"This plugin is not installable from this marketplace.".to_string(),
|
||||
),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
let install_cwd = self.config.cwd.clone();
|
||||
let marketplace_path = plugin.marketplace_path.clone();
|
||||
let plugin_name = plugin.summary.name.clone();
|
||||
let plugin_display_name = display_name;
|
||||
items.push(SelectionItem {
|
||||
name: "Install plugin".to_string(),
|
||||
description: Some("Install this plugin now.".to_string()),
|
||||
selected_description: Some("Install this plugin now.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenPluginInstallLoading {
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
tx.send(AppEvent::FetchPluginInstall {
|
||||
cwd: install_cwd.clone(),
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
plugin_name: plugin_name.clone(),
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: "Skills".to_string(),
|
||||
description: Some(plugin_skill_summary(plugin)),
|
||||
|
||||
@@ -202,6 +202,23 @@ impl ChatWidget {
|
||||
vec![("/realtime".to_string(), "stop live voice".to_string())]
|
||||
}
|
||||
|
||||
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
|
||||
if self.realtime_conversation.is_live()
|
||||
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
|
||||
{
|
||||
self.realtime_conversation.meter_placeholder_id = None;
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn start_realtime_conversation(&mut self) {
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
|
||||
self.realtime_conversation.requested_close = false;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui_app_server/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Figma · ChatGPT Marketplace
|
||||
Can be installed
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Install plugin Install this plugin now.
|
||||
3. Skills design-review, extract-copy
|
||||
4. Apps Figma, Slack
|
||||
5. MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui_app_server/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Browse plugins from the ChatGPT marketplace.
|
||||
Installed 1 of 3 available plugins.
|
||||
Using cached marketplace data: remote sync timed out
|
||||
|
||||
Type to search plugins
|
||||
› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details.
|
||||
Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already
|
||||
installed but disabled.
|
||||
Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by
|
||||
default.
|
||||
|
||||
Press esc to close.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui_app_server/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Loading available plugins...
|
||||
This first pass shows the ChatGPT marketplace only.
|
||||
|
||||
› 1. Loading plugins... This updates when the marketplace list is ready.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui_app_server/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Browse plugins from the ChatGPT marketplace.
|
||||
Installed 0 of 3 available plugins.
|
||||
|
||||
sla
|
||||
› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details.
|
||||
|
||||
Press esc to close.
|
||||
@@ -19,6 +19,7 @@ use crate::model_catalog::ModelCatalog;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
|
||||
use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
|
||||
use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool;
|
||||
@@ -32,9 +33,20 @@ use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification;
|
||||
use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::MarketplaceInterface;
|
||||
use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
|
||||
use codex_app_server_protocol::PatchChangeKind;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SkillSummary;
|
||||
use codex_app_server_protocol::ThreadClosedNotification;
|
||||
use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
|
||||
use codex_app_server_protocol::Turn as AppServerTurn;
|
||||
@@ -58,6 +70,7 @@ use codex_core::config_loader::ConfigRequirements;
|
||||
use codex_core::config_loader::ConfigRequirementsToml;
|
||||
use codex_core::config_loader::RequirementSource;
|
||||
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
@@ -2042,6 +2055,8 @@ async fn make_chatwidget_manual(
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
connectors_partial_snapshot: None,
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
plugin_install_auth_flow: None,
|
||||
connectors_prefetch_in_flight: false,
|
||||
connectors_force_refetch_pending: false,
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
@@ -5818,13 +5833,13 @@ async fn realtime_error_closes_without_followup_closed_info() {
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn removing_active_realtime_placeholder_closes_realtime_conversation() {
|
||||
async fn deleted_realtime_meter_uses_shared_stop_path() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
|
||||
chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
|
||||
|
||||
chat.remove_transcription_placeholder(&placeholder_id);
|
||||
assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id));
|
||||
|
||||
next_realtime_close_op(&mut op_rx);
|
||||
assert_eq!(chat.realtime_conversation.meter_placeholder_id, None);
|
||||
@@ -7664,6 +7679,513 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
std::env::temp_dir()
|
||||
.join("codex-plugin-menu-tests")
|
||||
.join(path),
|
||||
)
|
||||
.expect("expected absolute test path")
|
||||
}
|
||||
|
||||
fn plugins_test_interface(
|
||||
display_name: Option<&str>,
|
||||
short_description: Option<&str>,
|
||||
long_description: Option<&str>,
|
||||
) -> PluginInterface {
|
||||
PluginInterface {
|
||||
display_name: display_name.map(str::to_string),
|
||||
short_description: short_description.map(str::to_string),
|
||||
long_description: long_description.map(str::to_string),
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: Vec::new(),
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
logo: None,
|
||||
screenshots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_summary(
|
||||
id: &str,
|
||||
name: &str,
|
||||
display_name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
installed: bool,
|
||||
enabled: bool,
|
||||
install_policy: PluginInstallPolicy,
|
||||
) -> PluginSummary {
|
||||
PluginSummary {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
source: PluginSource::Local {
|
||||
path: plugins_test_absolute_path(&format!("plugins/{name}")),
|
||||
},
|
||||
installed,
|
||||
enabled,
|
||||
install_policy,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
interface: Some(plugins_test_interface(display_name, description, None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_curated_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
|
||||
PluginMarketplaceEntry {
|
||||
name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
path: plugins_test_absolute_path("marketplaces/chatgpt"),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("ChatGPT Marketplace".to_string()),
|
||||
}),
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry {
|
||||
PluginMarketplaceEntry {
|
||||
name: "repo".to_string(),
|
||||
path: plugins_test_absolute_path("marketplaces/repo"),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Repo Marketplace".to_string()),
|
||||
}),
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_response(marketplaces: Vec<PluginMarketplaceEntry>) -> PluginListResponse {
|
||||
PluginListResponse {
|
||||
marketplaces,
|
||||
remote_sync_error: None,
|
||||
featured_plugin_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String {
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd, Ok(response));
|
||||
chat.add_plugins_output();
|
||||
render_bottom_popup(chat, 100)
|
||||
}
|
||||
|
||||
fn plugins_test_detail(
|
||||
summary: PluginSummary,
|
||||
description: Option<&str>,
|
||||
skills: &[&str],
|
||||
apps: &[(&str, bool)],
|
||||
mcp_servers: &[&str],
|
||||
) -> PluginDetail {
|
||||
PluginDetail {
|
||||
marketplace_name: "ChatGPT Marketplace".to_string(),
|
||||
marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"),
|
||||
summary,
|
||||
description: description.map(str::to_string),
|
||||
skills: skills
|
||||
.iter()
|
||||
.map(|name| SkillSummary {
|
||||
name: (*name).to_string(),
|
||||
description: format!("{name} description"),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
|
||||
enabled: true,
|
||||
})
|
||||
.collect(),
|
||||
apps: apps
|
||||
.iter()
|
||||
.map(|(name, needs_auth)| AppSummary {
|
||||
id: format!("{name}-id"),
|
||||
name: (*name).to_string(),
|
||||
description: Some(format!("{name} app")),
|
||||
install_url: Some(format!("https://example.test/{name}")),
|
||||
needs_auth: *needs_auth,
|
||||
})
|
||||
.collect(),
|
||||
mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize {
|
||||
popup
|
||||
.find(needle)
|
||||
.unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}"))
|
||||
}
|
||||
|
||||
fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
|
||||
for ch in query.chars() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_loading_state_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
chat.add_plugins_output();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
popup.contains("Loading available plugins..."),
|
||||
"expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}"
|
||||
);
|
||||
assert_snapshot!("plugins_popup_loading_state", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let mut response = plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-bravo",
|
||||
"bravo",
|
||||
Some("Bravo Search"),
|
||||
Some("Search docs and tickets."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-alpha",
|
||||
"alpha",
|
||||
Some("Alpha Sync"),
|
||||
Some("Already installed but disabled."),
|
||||
true,
|
||||
false,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-starter",
|
||||
"starter",
|
||||
Some("Starter"),
|
||||
Some("Included by default."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::InstalledByDefault,
|
||||
),
|
||||
]),
|
||||
plugins_test_repo_marketplace(vec![plugins_test_summary(
|
||||
"plugin-hidden",
|
||||
"hidden",
|
||||
Some("Hidden Repo Plugin"),
|
||||
Some("Should not be shown in /plugins."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
)]),
|
||||
]);
|
||||
response.remote_sync_error = Some("remote sync timed out".to_string());
|
||||
|
||||
let popup = render_loaded_plugins_popup(&mut chat, response);
|
||||
assert_snapshot!("plugins_popup_curated_marketplace", popup);
|
||||
assert!(
|
||||
!popup.contains("Hidden Repo Plugin"),
|
||||
"expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
plugins_test_popup_row_position(&popup, "Bravo Search")
|
||||
< plugins_test_popup_row_position(&popup, "Alpha Sync")
|
||||
&& plugins_test_popup_row_position(&popup, "Alpha Sync")
|
||||
< plugins_test_popup_row_position(&popup, "Starter"),
|
||||
"expected /plugins rows to keep response order, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let summary = plugins_test_summary(
|
||||
"plugin-figma",
|
||||
"figma",
|
||||
Some("Figma"),
|
||||
Some("Design handoff."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
);
|
||||
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
summary.clone(),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd.clone(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd,
|
||||
Ok(PluginReadResponse {
|
||||
plugin: plugins_test_detail(
|
||||
summary,
|
||||
Some("Turn Figma files into implementation context."),
|
||||
&["design-review", "extract-copy"],
|
||||
&[("Figma", true), ("Slack", false)],
|
||||
&["figma-mcp", "docs-mcp"],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert_snapshot!("plugin_detail_popup_installable", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_refresh_replaces_selection_with_first_row() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-notion",
|
||||
"notion",
|
||||
Some("Notion"),
|
||||
Some("Workspace docs."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
render_loaded_plugins_popup(&mut chat, initial);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
|
||||
let before = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
before.contains("› Slack"),
|
||||
"expected Slack to be selected before refresh, got:\n{before}"
|
||||
);
|
||||
|
||||
let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-airtable",
|
||||
"airtable",
|
||||
Some("Airtable"),
|
||||
Some("Structured records."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-notion",
|
||||
"notion",
|
||||
Some("Notion"),
|
||||
Some("Workspace docs."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd, Ok(refreshed));
|
||||
|
||||
let after = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
after.contains("› Airtable"),
|
||||
"expected refresh to rebuild the popup from the new first row, got:\n{after}"
|
||||
);
|
||||
assert!(
|
||||
after.contains("Slack · ChatGPT Marketplace"),
|
||||
"expected refreshed popup to include the updated plugin list, got:\n{after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_refreshes_installed_counts_after_install() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-drive",
|
||||
"drive",
|
||||
Some("Drive"),
|
||||
Some("Document access."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
let before = render_loaded_plugins_popup(&mut chat, initial);
|
||||
assert!(
|
||||
before.contains("Installed 1 of 2 available plugins."),
|
||||
"expected initial installed count before refresh, got:\n{before}"
|
||||
);
|
||||
assert!(
|
||||
before.contains("Can be installed"),
|
||||
"expected pre-install popup copy before refresh, got:\n{before}"
|
||||
);
|
||||
|
||||
let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-drive",
|
||||
"drive",
|
||||
Some("Drive"),
|
||||
Some("Document access."),
|
||||
true,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd, Ok(refreshed));
|
||||
|
||||
let after = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
after.contains("Installed 2 of 2 available plugins."),
|
||||
"expected /plugins to refresh installed counts after install, got:\n{after}"
|
||||
);
|
||||
assert!(
|
||||
after.contains("Installed. Press Enter to view plugin details."),
|
||||
"expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_search_filters_visible_rows_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-drive",
|
||||
"drive",
|
||||
Some("Drive"),
|
||||
Some("Document access."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]),
|
||||
);
|
||||
|
||||
type_plugins_search_query(&mut chat, "sla");
|
||||
|
||||
let popup = render_bottom_popup(&chat, 100);
|
||||
assert_snapshot!("plugins_popup_search_filtered", popup);
|
||||
assert!(
|
||||
!popup.contains("Calendar · ChatGPT Marketplace")
|
||||
&& !popup.contains("Drive · ChatGPT Marketplace"),
|
||||
"expected search to leave only matching rows visible, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_search_no_matches_and_backspace_restores_results() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
plugins_test_summary(
|
||||
"plugin-calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Schedule management."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
plugins_test_summary(
|
||||
"plugin-slack",
|
||||
"slack",
|
||||
Some("Slack"),
|
||||
Some("Team chat."),
|
||||
false,
|
||||
true,
|
||||
PluginInstallPolicy::Available,
|
||||
),
|
||||
])]),
|
||||
);
|
||||
|
||||
type_plugins_search_query(&mut chat, "zzz");
|
||||
|
||||
let no_matches = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
no_matches.contains("zzz"),
|
||||
"expected popup to show the typed search query, got:\n{no_matches}"
|
||||
);
|
||||
assert!(
|
||||
no_matches.contains("no matches"),
|
||||
"expected popup to render the no-matches UX, got:\n{no_matches}"
|
||||
);
|
||||
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
}
|
||||
|
||||
let restored = render_bottom_popup(&chat, 100);
|
||||
assert!(
|
||||
restored.contains("Calendar · ChatGPT Marketplace")
|
||||
&& restored.contains("Slack · ChatGPT Marketplace"),
|
||||
"expected clearing the query to restore the plugin rows, got:\n{restored}"
|
||||
);
|
||||
assert!(
|
||||
!restored.contains("no matches"),
|
||||
"did not expect the no-matches state after clearing the query, got:\n{restored}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_popup_stays_loading_until_final_snapshot_updates() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
Reference in New Issue
Block a user