mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Merge 658aa3a984 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -8557,6 +8557,21 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceLoadErrorInfo": {
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"McpAuthStatus": {
|
||||
"enum": [
|
||||
"unsupported",
|
||||
@@ -9515,6 +9530,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaceLoadErrors": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/MarketplaceLoadErrorInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginMarketplaceEntry"
|
||||
|
||||
@@ -5305,6 +5305,21 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceLoadErrorInfo": {
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"McpAuthStatus": {
|
||||
"enum": [
|
||||
"unsupported",
|
||||
@@ -6263,6 +6278,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaceLoadErrors": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MarketplaceLoadErrorInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceLoadErrorInfo": {
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginAuthPolicy": {
|
||||
"enum": [
|
||||
"ON_INSTALL",
|
||||
@@ -246,6 +261,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaceLoadErrors": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MarketplaceLoadErrorInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// 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 MarketplaceLoadErrorInfo = { marketplacePath: AbsolutePathBuf, message: string, };
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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 { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
|
||||
import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, remoteSyncError: string | null, featuredPluginIds: Array<string>, };
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, remoteSyncError: string | null, featuredPluginIds: Array<string>, };
|
||||
|
||||
@@ -141,6 +141,7 @@ export type { LoginAccountParams } from "./LoginAccountParams";
|
||||
export type { LoginAccountResponse } from "./LoginAccountResponse";
|
||||
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
|
||||
export type { MarketplaceInterface } from "./MarketplaceInterface";
|
||||
export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
|
||||
export type { McpAuthStatus } from "./McpAuthStatus";
|
||||
export type { McpElicitationArrayType } from "./McpElicitationArrayType";
|
||||
export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema";
|
||||
|
||||
@@ -3146,11 +3146,21 @@ pub struct PluginListParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginListResponse {
|
||||
pub marketplaces: Vec<PluginMarketplaceEntry>,
|
||||
#[serde(default)]
|
||||
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
|
||||
pub remote_sync_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub featured_plugin_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct MarketplaceLoadErrorInfo {
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -163,7 +163,7 @@ Example with notification opt-out:
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `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/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, 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. 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.
|
||||
|
||||
@@ -409,6 +409,13 @@ enum EnsureConversationListenerResult {
|
||||
ConnectionClosed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RefreshTokenRequestOutcome {
|
||||
NotAttemptedOrSucceeded,
|
||||
FailedTransiently,
|
||||
FailedPermanently,
|
||||
}
|
||||
|
||||
pub(crate) struct CodexMessageProcessorArgs {
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) thread_manager: Arc<ThreadManager>,
|
||||
@@ -1338,13 +1345,19 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_token_if_requested(&self, do_refresh: bool) {
|
||||
async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome {
|
||||
if self.auth_manager.is_external_auth_active() {
|
||||
return;
|
||||
return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded;
|
||||
}
|
||||
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
|
||||
tracing::warn!("failed to refresh token while getting account: {err}");
|
||||
let failed_reason = err.failed_reason();
|
||||
if failed_reason.is_none() {
|
||||
tracing::warn!("failed to refresh token while getting account: {err}");
|
||||
return RefreshTokenRequestOutcome::FailedTransiently;
|
||||
}
|
||||
return RefreshTokenRequestOutcome::FailedPermanently;
|
||||
}
|
||||
RefreshTokenRequestOutcome::NotAttemptedOrSucceeded
|
||||
}
|
||||
|
||||
async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) {
|
||||
@@ -1367,18 +1380,25 @@ impl CodexMessageProcessor {
|
||||
} else {
|
||||
match self.auth_manager.auth().await {
|
||||
Some(auth) => {
|
||||
let permanent_refresh_failure =
|
||||
self.auth_manager.refresh_failure_for_auth(&auth).is_some();
|
||||
let auth_mode = auth.api_auth_mode();
|
||||
let (reported_auth_method, token_opt) = match auth.get_token() {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
let tok = if include_token { Some(token) } else { None };
|
||||
(Some(auth_mode), tok)
|
||||
}
|
||||
Ok(_) => (None, None),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to get token for auth status: {err}");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let (reported_auth_method, token_opt) =
|
||||
if include_token && permanent_refresh_failure {
|
||||
(Some(auth_mode), None)
|
||||
} else {
|
||||
match auth.get_token() {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
let tok = if include_token { Some(token) } else { None };
|
||||
(Some(auth_mode), tok)
|
||||
}
|
||||
Ok(_) => (None, None),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to get token for auth status: {err}");
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
GetAuthStatusResponse {
|
||||
auth_method: reported_auth_method,
|
||||
auth_token: token_opt,
|
||||
@@ -5523,11 +5543,18 @@ impl CodexMessageProcessor {
|
||||
|
||||
let config_for_marketplace_listing = config.clone();
|
||||
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
|
||||
let data = match tokio::task::spawn_blocking(move || {
|
||||
let marketplaces = plugins_manager_for_marketplace_listing
|
||||
let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
|
||||
let outcome = plugins_manager_for_marketplace_listing
|
||||
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
|
||||
Ok::<Vec<PluginMarketplaceEntry>, MarketplaceError>(
|
||||
marketplaces
|
||||
Ok::<
|
||||
(
|
||||
Vec<PluginMarketplaceEntry>,
|
||||
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
|
||||
),
|
||||
MarketplaceError,
|
||||
>((
|
||||
outcome
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.map(|marketplace| PluginMarketplaceEntry {
|
||||
name: marketplace.name,
|
||||
@@ -5551,11 +5578,19 @@ impl CodexMessageProcessor {
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
outcome
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
|
||||
marketplace_path: err.path,
|
||||
message: err.message,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(data)) => data,
|
||||
Ok(Ok(outcome)) => outcome,
|
||||
Ok(Err(err)) => {
|
||||
self.send_marketplace_error(request_id, err, "list marketplace plugins")
|
||||
.await;
|
||||
@@ -5597,6 +5632,7 @@ impl CodexMessageProcessor {
|
||||
request_id,
|
||||
PluginListResponse {
|
||||
marketplaces: data,
|
||||
marketplace_load_errors,
|
||||
remote_sync_error,
|
||||
featured_plugin_ids,
|
||||
},
|
||||
@@ -8871,6 +8907,7 @@ mod tests {
|
||||
request_id: sent_request_id,
|
||||
..
|
||||
}),
|
||||
..
|
||||
} = request_message
|
||||
else {
|
||||
panic!("expected tool request to be sent to the subscribed connection");
|
||||
|
||||
@@ -42,6 +42,7 @@ use crate::outgoing_message::ConnectionRequestId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
|
||||
const EXEC_TIMEOUT_EXIT_CODE: i32 = 124;
|
||||
const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CommandExecManager {
|
||||
@@ -577,13 +578,19 @@ fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHa
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut observed_num_bytes = 0usize;
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
let mut chunk = tokio::select! {
|
||||
chunk = output_rx.recv() => match chunk {
|
||||
Some(chunk) => chunk,
|
||||
None => break,
|
||||
},
|
||||
_ = stdio_timeout_rx.wait_for(|&v| v) => break,
|
||||
};
|
||||
// Individual chunks are at most 8KiB, so overshooting a bit is acceptable.
|
||||
while chunk.len() < OUTPUT_CHUNK_SIZE_HINT
|
||||
&& let Ok(next_chunk) = output_rx.try_recv()
|
||||
{
|
||||
chunk.extend_from_slice(&next_chunk);
|
||||
}
|
||||
let capped_chunk = match output_bytes_cap {
|
||||
Some(output_bytes_cap) => {
|
||||
let capped_chunk_len = output_bytes_cap
|
||||
@@ -597,8 +604,8 @@ fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHa
|
||||
let cap_reached = Some(observed_num_bytes) == output_bytes_cap;
|
||||
if let (true, Some(process_id)) = (stream_output, process_id.as_ref()) {
|
||||
outgoing
|
||||
.send_server_notification_to_connections(
|
||||
&[connection_id],
|
||||
.send_server_notification_to_connection_and_wait(
|
||||
connection_id,
|
||||
ServerNotification::CommandExecOutputDelta(
|
||||
CommandExecOutputDeltaNotification {
|
||||
process_id: process_id.clone(),
|
||||
@@ -809,6 +816,7 @@ mod tests {
|
||||
let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
..
|
||||
} = envelope
|
||||
else {
|
||||
panic!("expected connection-scoped outgoing message");
|
||||
@@ -891,6 +899,7 @@ mod tests {
|
||||
let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
..
|
||||
} = envelope
|
||||
else {
|
||||
panic!("expected connection-scoped outgoing message");
|
||||
|
||||
@@ -60,6 +60,7 @@ use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::QueuedOutgoingMessage;
|
||||
use crate::transport::CHANNEL_CAPACITY;
|
||||
use crate::transport::OutboundConnectionState;
|
||||
use crate::transport::route_outgoing_envelope;
|
||||
@@ -353,7 +354,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(channel_capacity);
|
||||
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx));
|
||||
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(channel_capacity);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(channel_capacity);
|
||||
let outbound_initialized = Arc::new(AtomicBool::new(false));
|
||||
let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false));
|
||||
let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new()));
|
||||
@@ -547,10 +548,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
|
||||
}
|
||||
}
|
||||
}
|
||||
outgoing_message = writer_rx.recv() => {
|
||||
let Some(outgoing_message) = outgoing_message else {
|
||||
queued_message = writer_rx.recv() => {
|
||||
let Some(queued_message) = queued_message else {
|
||||
break;
|
||||
};
|
||||
let outgoing_message = queued_message.message;
|
||||
match outgoing_message {
|
||||
OutgoingMessage::Response(response) => {
|
||||
if let Some(response_tx) = pending_request_responses.remove(&response.id) {
|
||||
@@ -629,6 +631,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(write_complete_tx) = queued_message.write_complete_tx {
|
||||
let _ = write_complete_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::message_processor::MessageProcessorArgs;
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::QueuedOutgoingMessage;
|
||||
use crate::transport::CHANNEL_CAPACITY;
|
||||
use crate::transport::ConnectionState;
|
||||
use crate::transport::OutboundConnectionState;
|
||||
@@ -103,7 +104,7 @@ enum OutboundControlEvent {
|
||||
/// Register a new writer for an opened connection.
|
||||
Opened {
|
||||
connection_id: ConnectionId,
|
||||
writer: mpsc::Sender<crate::outgoing_message::OutgoingMessage>,
|
||||
writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
disconnect_sender: Option<CancellationToken>,
|
||||
initialized: Arc<AtomicBool>,
|
||||
experimental_api_enabled: Arc<AtomicBool>,
|
||||
|
||||
@@ -390,6 +390,7 @@ async fn read_response<T: serde::de::DeserializeOwned>(
|
||||
let crate::outgoing_message::OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
..
|
||||
} = envelope
|
||||
else {
|
||||
continue;
|
||||
@@ -420,6 +421,7 @@ async fn read_thread_started_notification(
|
||||
crate::outgoing_message::OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
if connection_id != TEST_CONNECTION_ID {
|
||||
continue;
|
||||
|
||||
@@ -81,17 +81,33 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum OutgoingEnvelope {
|
||||
ToConnection {
|
||||
connection_id: ConnectionId,
|
||||
message: OutgoingMessage,
|
||||
write_complete_tx: Option<oneshot::Sender<()>>,
|
||||
},
|
||||
Broadcast {
|
||||
message: OutgoingMessage,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct QueuedOutgoingMessage {
|
||||
pub(crate) message: OutgoingMessage,
|
||||
pub(crate) write_complete_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl QueuedOutgoingMessage {
|
||||
pub(crate) fn new(message: OutgoingMessage) -> Self {
|
||||
Self {
|
||||
message,
|
||||
write_complete_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends messages to the client and manages request callbacks.
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
next_server_request_id: AtomicI64,
|
||||
@@ -299,6 +315,7 @@ impl OutgoingMessageSender {
|
||||
.send(OutgoingEnvelope::ToConnection {
|
||||
connection_id: *connection_id,
|
||||
message: outgoing_message.clone(),
|
||||
write_complete_tx: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -333,6 +350,7 @@ impl OutgoingMessageSender {
|
||||
.send(OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::Request(request),
|
||||
write_complete_tx: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -519,6 +537,7 @@ impl OutgoingMessageSender {
|
||||
.send(OutgoingEnvelope::ToConnection {
|
||||
connection_id: *connection_id,
|
||||
message: outgoing_message.clone(),
|
||||
write_complete_tx: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -527,6 +546,28 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn send_server_notification_to_connection_and_wait(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
notification: ServerNotification,
|
||||
) {
|
||||
tracing::trace!("app-server event: {notification}");
|
||||
let outgoing_message = OutgoingMessage::AppServerNotification(notification);
|
||||
let (write_complete_tx, write_complete_rx) = oneshot::channel();
|
||||
if let Err(err) = self
|
||||
.sender
|
||||
.send(OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: outgoing_message,
|
||||
write_complete_tx: Some(write_complete_tx),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!("failed to send server notification to client: {err:?}");
|
||||
}
|
||||
let _ = write_complete_rx.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn send_error(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
@@ -566,6 +607,7 @@ impl OutgoingMessageSender {
|
||||
let send_fut = self.sender.send(OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx: None,
|
||||
});
|
||||
let send_result = if let Some(request_context) = request_context {
|
||||
send_fut.instrument(request_context.span()).await
|
||||
@@ -818,6 +860,7 @@ mod tests {
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(connection_id, ConnectionId(42));
|
||||
let OutgoingMessage::Response(response) = message else {
|
||||
@@ -880,6 +923,7 @@ mod tests {
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(connection_id, ConnectionId(9));
|
||||
let OutgoingMessage::Error(outgoing_error) = message else {
|
||||
@@ -892,6 +936,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_server_notification_to_connection_and_wait_tracks_write_completion() {
|
||||
let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4);
|
||||
let outgoing = OutgoingMessageSender::new(tx);
|
||||
let send_task = tokio::spawn(async move {
|
||||
outgoing
|
||||
.send_server_notification_to_connection_and_wait(
|
||||
ConnectionId(42),
|
||||
ServerNotification::ModelRerouted(ModelReroutedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
from_model: "gpt-5.3-codex".to_string(),
|
||||
to_model: "gpt-5.2".to_string(),
|
||||
reason: ModelRerouteReason::HighRiskCyberActivity,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let envelope = timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("should receive envelope before timeout")
|
||||
.expect("channel should contain one message");
|
||||
let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx,
|
||||
} = envelope
|
||||
else {
|
||||
panic!("expected targeted server notification envelope");
|
||||
};
|
||||
assert_eq!(connection_id, ConnectionId(42));
|
||||
assert!(matches!(message, OutgoingMessage::AppServerNotification(_)));
|
||||
write_complete_tx
|
||||
.expect("write completion sender should be attached")
|
||||
.send(())
|
||||
.expect("receiver should still be waiting");
|
||||
|
||||
timeout(Duration::from_secs(1), send_task)
|
||||
.await
|
||||
.expect("send task should finish after write completion is signaled")
|
||||
.expect("send task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_closed_clears_registered_request_contexts() {
|
||||
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingError;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::outgoing_message::QueuedOutgoingMessage;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
@@ -187,7 +188,7 @@ impl FromStr for AppServerTransport {
|
||||
pub(crate) enum TransportEvent {
|
||||
ConnectionOpened {
|
||||
connection_id: ConnectionId,
|
||||
writer: mpsc::Sender<OutgoingMessage>,
|
||||
writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
disconnect_sender: Option<CancellationToken>,
|
||||
},
|
||||
ConnectionClosed {
|
||||
@@ -225,13 +226,13 @@ pub(crate) struct OutboundConnectionState {
|
||||
pub(crate) initialized: Arc<AtomicBool>,
|
||||
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
|
||||
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
|
||||
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
|
||||
pub(crate) writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
disconnect_sender: Option<CancellationToken>,
|
||||
}
|
||||
|
||||
impl OutboundConnectionState {
|
||||
pub(crate) fn new(
|
||||
writer: mpsc::Sender<OutgoingMessage>,
|
||||
writer: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
initialized: Arc<AtomicBool>,
|
||||
experimental_api_enabled: Arc<AtomicBool>,
|
||||
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
|
||||
@@ -262,7 +263,7 @@ pub(crate) async fn start_stdio_connection(
|
||||
stdio_handles: &mut Vec<JoinHandle<()>>,
|
||||
) -> IoResult<()> {
|
||||
let connection_id = ConnectionId(0);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(CHANNEL_CAPACITY);
|
||||
let writer_tx_for_reader = writer_tx.clone();
|
||||
transport_event_tx
|
||||
.send(TransportEvent::ConnectionOpened {
|
||||
@@ -309,8 +310,8 @@ pub(crate) async fn start_stdio_connection(
|
||||
|
||||
stdio_handles.push(tokio::spawn(async move {
|
||||
let mut stdout = io::stdout();
|
||||
while let Some(outgoing_message) = writer_rx.recv().await {
|
||||
let Some(mut json) = serialize_outgoing_message(outgoing_message) else {
|
||||
while let Some(queued_message) = writer_rx.recv().await {
|
||||
let Some(mut json) = serialize_outgoing_message(queued_message.message) else {
|
||||
continue;
|
||||
};
|
||||
json.push('\n');
|
||||
@@ -318,6 +319,9 @@ pub(crate) async fn start_stdio_connection(
|
||||
error!("Failed to write to stdout: {err}");
|
||||
break;
|
||||
}
|
||||
if let Some(write_complete_tx) = queued_message.write_complete_tx {
|
||||
let _ = write_complete_tx.send(());
|
||||
}
|
||||
}
|
||||
info!("stdout writer exited (channel closed)");
|
||||
}));
|
||||
@@ -364,7 +368,7 @@ async fn run_websocket_connection(
|
||||
websocket_stream: WebSocket,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
) {
|
||||
let (writer_tx, writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
|
||||
let (writer_tx, writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(CHANNEL_CAPACITY);
|
||||
let writer_tx_for_reader = writer_tx.clone();
|
||||
let disconnect_token = CancellationToken::new();
|
||||
if transport_event_tx
|
||||
@@ -415,7 +419,7 @@ async fn run_websocket_connection(
|
||||
|
||||
async fn run_websocket_outbound_loop(
|
||||
mut websocket_writer: futures::stream::SplitSink<WebSocket, WebSocketMessage>,
|
||||
mut writer_rx: mpsc::Receiver<OutgoingMessage>,
|
||||
mut writer_rx: mpsc::Receiver<QueuedOutgoingMessage>,
|
||||
mut writer_control_rx: mpsc::Receiver<WebSocketMessage>,
|
||||
disconnect_token: CancellationToken,
|
||||
) {
|
||||
@@ -432,16 +436,19 @@ async fn run_websocket_outbound_loop(
|
||||
break;
|
||||
}
|
||||
}
|
||||
outgoing_message = writer_rx.recv() => {
|
||||
let Some(outgoing_message) = outgoing_message else {
|
||||
queued_message = writer_rx.recv() => {
|
||||
let Some(queued_message) = queued_message else {
|
||||
break;
|
||||
};
|
||||
let Some(json) = serialize_outgoing_message(outgoing_message) else {
|
||||
let Some(json) = serialize_outgoing_message(queued_message.message) else {
|
||||
continue;
|
||||
};
|
||||
if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if let Some(write_complete_tx) = queued_message.write_complete_tx {
|
||||
let _ = write_complete_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,7 +457,7 @@ async fn run_websocket_outbound_loop(
|
||||
async fn run_websocket_inbound_loop(
|
||||
mut websocket_reader: futures::stream::SplitStream<WebSocket>,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
writer_tx_for_reader: mpsc::Sender<OutgoingMessage>,
|
||||
writer_tx_for_reader: mpsc::Sender<QueuedOutgoingMessage>,
|
||||
writer_control_tx: mpsc::Sender<WebSocketMessage>,
|
||||
connection_id: ConnectionId,
|
||||
disconnect_token: CancellationToken,
|
||||
@@ -501,7 +508,7 @@ async fn run_websocket_inbound_loop(
|
||||
|
||||
async fn forward_incoming_message(
|
||||
transport_event_tx: &mpsc::Sender<TransportEvent>,
|
||||
writer: &mpsc::Sender<OutgoingMessage>,
|
||||
writer: &mpsc::Sender<QueuedOutgoingMessage>,
|
||||
connection_id: ConnectionId,
|
||||
payload: &str,
|
||||
) -> bool {
|
||||
@@ -518,7 +525,7 @@ async fn forward_incoming_message(
|
||||
|
||||
async fn enqueue_incoming_message(
|
||||
transport_event_tx: &mpsc::Sender<TransportEvent>,
|
||||
writer: &mpsc::Sender<OutgoingMessage>,
|
||||
writer: &mpsc::Sender<QueuedOutgoingMessage>,
|
||||
connection_id: ConnectionId,
|
||||
message: JSONRPCMessage,
|
||||
) -> bool {
|
||||
@@ -541,7 +548,7 @@ async fn enqueue_incoming_message(
|
||||
data: None,
|
||||
},
|
||||
});
|
||||
match writer.try_send(overload_error) {
|
||||
match writer.try_send(QueuedOutgoingMessage::new(overload_error)) {
|
||||
Ok(()) => true,
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => false,
|
||||
Err(mpsc::error::TrySendError::Full(_overload_error)) => {
|
||||
@@ -607,6 +614,7 @@ async fn send_message_to_connection(
|
||||
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
|
||||
connection_id: ConnectionId,
|
||||
message: OutgoingMessage,
|
||||
write_complete_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
) -> bool {
|
||||
let Some(connection_state) = connections.get(&connection_id) else {
|
||||
warn!("dropping message for disconnected connection: {connection_id:?}");
|
||||
@@ -618,8 +626,12 @@ async fn send_message_to_connection(
|
||||
}
|
||||
|
||||
let writer = connection_state.writer.clone();
|
||||
let queued_message = QueuedOutgoingMessage {
|
||||
message,
|
||||
write_complete_tx,
|
||||
};
|
||||
if connection_state.can_disconnect() {
|
||||
match writer.try_send(message) {
|
||||
match writer.try_send(queued_message) {
|
||||
Ok(()) => false,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!(
|
||||
@@ -631,7 +643,7 @@ async fn send_message_to_connection(
|
||||
disconnect_connection(connections, connection_id)
|
||||
}
|
||||
}
|
||||
} else if writer.send(message).await.is_err() {
|
||||
} else if writer.send(queued_message).await.is_err() {
|
||||
disconnect_connection(connections, connection_id)
|
||||
} else {
|
||||
false
|
||||
@@ -670,8 +682,11 @@ pub(crate) async fn route_outgoing_envelope(
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx,
|
||||
} => {
|
||||
let _ = send_message_to_connection(connections, connection_id, message).await;
|
||||
let _ =
|
||||
send_message_to_connection(connections, connection_id, message, write_complete_tx)
|
||||
.await;
|
||||
}
|
||||
OutgoingEnvelope::Broadcast { message } => {
|
||||
let target_connections: Vec<ConnectionId> = connections
|
||||
@@ -688,8 +703,13 @@ pub(crate) async fn route_outgoing_envelope(
|
||||
.collect();
|
||||
|
||||
for connection_id in target_connections {
|
||||
let _ =
|
||||
send_message_to_connection(connections, connection_id, message.clone()).await;
|
||||
let _ = send_message_to_connection(
|
||||
connections,
|
||||
connection_id,
|
||||
message.clone(),
|
||||
/*write_complete_tx*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,7 +820,8 @@ mod tests {
|
||||
.recv()
|
||||
.await
|
||||
.expect("request should receive overload error");
|
||||
let overload_json = serde_json::to_value(overload).expect("serialize overload error");
|
||||
let overload_json =
|
||||
serde_json::to_value(overload.message).expect("serialize overload error");
|
||||
assert_eq!(
|
||||
overload_json,
|
||||
json!({
|
||||
@@ -904,13 +925,15 @@ mod tests {
|
||||
.expect("transport queue should accept first message");
|
||||
|
||||
writer_tx
|
||||
.send(OutgoingMessage::AppServerNotification(
|
||||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: "queued".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
}),
|
||||
.send(QueuedOutgoingMessage::new(
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "queued".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
))
|
||||
.await
|
||||
.expect("writer queue should accept first message");
|
||||
@@ -934,7 +957,8 @@ mod tests {
|
||||
.recv()
|
||||
.await
|
||||
.expect("writer queue should still contain original message");
|
||||
let queued_json = serde_json::to_value(queued_outgoing).expect("serialize queued message");
|
||||
let queued_json =
|
||||
serde_json::to_value(queued_outgoing.message).expect("serialize queued message");
|
||||
assert_eq!(
|
||||
queued_json,
|
||||
json!({
|
||||
@@ -979,6 +1003,7 @@ mod tests {
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -989,6 +1014,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_connection_notifications_are_dropped_for_opted_out_clients() {
|
||||
let connection_id = ConnectionId(10);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "task_started".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
writer_rx.try_recv().is_err(),
|
||||
"opted-out notifications should not reach clients"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_connection_notifications_are_preserved_for_non_opted_out_clients() {
|
||||
let connection_id = ConnectionId(11);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
|
||||
let mut connections = HashMap::new();
|
||||
connections.insert(
|
||||
connection_id,
|
||||
OutboundConnectionState::new(
|
||||
writer_tx,
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "task_started".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let message = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("notification should reach non-opted-out clients");
|
||||
assert!(matches!(
|
||||
message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "task_started"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_execution_request_approval_strips_experimental_fields_without_capability() {
|
||||
let connection_id = ConnectionId(8);
|
||||
@@ -1042,6 +1153,7 @@ mod tests {
|
||||
available_decisions: None,
|
||||
},
|
||||
}),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1050,7 +1162,7 @@ mod tests {
|
||||
.recv()
|
||||
.await
|
||||
.expect("request should be delivered to the connection");
|
||||
let json = serde_json::to_value(message).expect("request should serialize");
|
||||
let json = serde_json::to_value(message.message).expect("request should serialize");
|
||||
assert_eq!(json["params"].get("additionalPermissions"), None);
|
||||
assert_eq!(json["params"].get("skillMetadata"), None);
|
||||
}
|
||||
@@ -1108,6 +1220,7 @@ mod tests {
|
||||
available_decisions: None,
|
||||
},
|
||||
}),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1116,7 +1229,7 @@ mod tests {
|
||||
.recv()
|
||||
.await
|
||||
.expect("request should be delivered to the connection");
|
||||
let json = serde_json::to_value(message).expect("request should serialize");
|
||||
let json = serde_json::to_value(message.message).expect("request should serialize");
|
||||
let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
json["params"]["additionalPermissions"],
|
||||
@@ -1178,7 +1291,7 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
slow_writer_tx
|
||||
.try_send(queued_message)
|
||||
.try_send(QueuedOutgoingMessage::new(queued_message))
|
||||
.expect("channel should have room");
|
||||
|
||||
let broadcast_message = OutgoingMessage::AppServerNotification(
|
||||
@@ -1207,7 +1320,7 @@ mod tests {
|
||||
.try_recv()
|
||||
.expect("fast connection should receive the broadcast notification");
|
||||
assert!(matches!(
|
||||
fast_message,
|
||||
fast_message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "test"
|
||||
@@ -1217,7 +1330,7 @@ mod tests {
|
||||
.try_recv()
|
||||
.expect("slow connection should retain its original buffered message");
|
||||
assert!(matches!(
|
||||
slow_message,
|
||||
slow_message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "already-buffered"
|
||||
@@ -1229,13 +1342,15 @@ mod tests {
|
||||
let connection_id = ConnectionId(3);
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(1);
|
||||
writer_tx
|
||||
.send(OutgoingMessage::AppServerNotification(
|
||||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: "queued".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
}),
|
||||
.send(QueuedOutgoingMessage::new(
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification {
|
||||
summary: "queued".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
},
|
||||
)),
|
||||
))
|
||||
.await
|
||||
.expect("channel should accept the first queued message");
|
||||
@@ -1265,6 +1380,7 @@ mod tests {
|
||||
range: None,
|
||||
}),
|
||||
),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1280,7 +1396,7 @@ mod tests {
|
||||
.expect("routing task should succeed");
|
||||
|
||||
assert!(matches!(
|
||||
first,
|
||||
first.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "queued"
|
||||
@@ -1289,7 +1405,7 @@ mod tests {
|
||||
.try_recv()
|
||||
.expect("second notification should be delivered once the queue has room");
|
||||
assert!(matches!(
|
||||
second,
|
||||
second.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
|
||||
ConfigWarningNotification { summary, .. }
|
||||
)) if summary == "second"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
@@ -8,10 +12,17 @@ use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -207,6 +218,288 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(true),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let status: GetAuthStatusResponse = to_response(resp)?;
|
||||
assert_eq!(
|
||||
status,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::ApiKey),
|
||||
auth_token: Some("sk-test-key".to_string()),
|
||||
requires_openai_auth: Some(true),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("stale-access-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.account_id("acct_123")
|
||||
.email("user@example.com")
|
||||
.plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"code": "refresh_token_reused"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let refresh_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(true),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let status: GetAuthStatusResponse = to_response(resp)?;
|
||||
assert_eq!(
|
||||
status,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::Chatgpt),
|
||||
auth_token: None,
|
||||
requires_openai_auth: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
let second_request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(true),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let second_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let second_status: GetAuthStatusResponse = to_response(second_resp)?;
|
||||
assert_eq!(second_status, status);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("stale-access-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.account_id("acct_123")
|
||||
.email("user@example.com")
|
||||
.plan_type("pro")
|
||||
.last_refresh(Some(Utc::now() - Duration::days(9))),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"code": "refresh_token_reused"
|
||||
}
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let refresh_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let status: GetAuthStatusResponse = to_response(resp)?;
|
||||
assert_eq!(
|
||||
status,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::Chatgpt),
|
||||
auth_token: None,
|
||||
requires_openai_auth: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("stale-access-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.account_id("acct_123")
|
||||
.email("user@example.com")
|
||||
.plan_type("pro")
|
||||
.last_refresh(Some(Utc::now() - Duration::days(9))),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"code": "refresh_token_reused"
|
||||
}
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let refresh_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let failed_request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(true),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let failed_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(failed_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let failed_status: GetAuthStatusResponse = to_response(failed_resp)?;
|
||||
assert_eq!(
|
||||
failed_status,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::Chatgpt),
|
||||
auth_token: None,
|
||||
requires_openai_auth: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("recovered-access-token")
|
||||
.refresh_token("recovered-refresh-token")
|
||||
.account_id("acct_123")
|
||||
.email("user@example.com")
|
||||
.plan_type("pro")
|
||||
.last_refresh(Some(Utc::now())),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let recovered_request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let recovered_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(recovered_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let recovered_status: GetAuthStatusResponse = to_response(recovered_resp)?;
|
||||
assert_eq!(
|
||||
recovered_status,
|
||||
GetAuthStatusResponse {
|
||||
auth_method: Some(AuthMode::Chatgpt),
|
||||
auth_token: Some("recovered-access-token".to_string()),
|
||||
requires_openai_auth: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -11,6 +11,9 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
@@ -41,16 +44,15 @@ plugins = true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> {
|
||||
async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
write_plugins_enabled_config(codex_home.path())?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
"{not json",
|
||||
)?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
std::fs::write(marketplace_path.as_path(), "{not json")?;
|
||||
|
||||
let home = codex_home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
@@ -78,15 +80,24 @@ async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> {
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(
|
||||
response.marketplaces.iter().all(|marketplace| {
|
||||
marketplace.path
|
||||
!= AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.expect("absolute marketplace path")
|
||||
}),
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| { marketplace.path != marketplace_path }),
|
||||
"invalid marketplace should be skipped"
|
||||
);
|
||||
assert_eq!(response.marketplace_load_errors.len(), 1);
|
||||
assert_eq!(
|
||||
response.marketplace_load_errors[0].marketplace_path,
|
||||
marketplace_path
|
||||
);
|
||||
assert!(
|
||||
response.marketplace_load_errors[0]
|
||||
.message
|
||||
.contains("invalid marketplace file"),
|
||||
"unexpected error: {:?}",
|
||||
response.marketplace_load_errors
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -116,6 +127,124 @@ async fn plugin_list_rejects_relative_cwds() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
let valid_repo_root = TempDir::new()?;
|
||||
let invalid_repo_root = TempDir::new()?;
|
||||
std::fs::create_dir_all(valid_repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(
|
||||
valid_repo_root
|
||||
.path()
|
||||
.join("plugins/valid-plugin/.codex-plugin"),
|
||||
)?;
|
||||
std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?;
|
||||
write_plugins_enabled_config(codex_home.path())?;
|
||||
|
||||
let valid_marketplace_path = AbsolutePathBuf::try_from(
|
||||
valid_repo_root
|
||||
.path()
|
||||
.join(".agents/plugins/marketplace.json"),
|
||||
)?;
|
||||
let invalid_marketplace_path = AbsolutePathBuf::try_from(
|
||||
invalid_repo_root
|
||||
.path()
|
||||
.join(".agents/plugins/marketplace.json"),
|
||||
)?;
|
||||
let valid_plugin_path =
|
||||
AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?;
|
||||
|
||||
std::fs::write(
|
||||
valid_marketplace_path.as_path(),
|
||||
r#"{
|
||||
"name": "valid-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "valid-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/valid-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
valid_repo_root
|
||||
.path()
|
||||
.join("plugins/valid-plugin/.codex-plugin/plugin.json"),
|
||||
r#"{"name":"valid-plugin"}"#,
|
||||
)?;
|
||||
std::fs::write(invalid_marketplace_path.as_path(), "{not json")?;
|
||||
|
||||
let home = codex_home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home.as_str())),
|
||||
("USERPROFILE", Some(home.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![
|
||||
AbsolutePathBuf::try_from(valid_repo_root.path())?,
|
||||
AbsolutePathBuf::try_from(invalid_repo_root.path())?,
|
||||
]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response.marketplaces,
|
||||
vec![PluginMarketplaceEntry {
|
||||
name: "valid-marketplace".to_string(),
|
||||
path: valid_marketplace_path,
|
||||
interface: None,
|
||||
plugins: vec![PluginSummary {
|
||||
id: "valid-plugin@valid-marketplace".to_string(),
|
||||
name: "valid-plugin".to_string(),
|
||||
source: PluginSource::Local {
|
||||
path: valid_plugin_path,
|
||||
},
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
);
|
||||
assert_eq!(response.marketplace_load_errors.len(), 1);
|
||||
assert_eq!(
|
||||
response.marketplace_load_errors[0].marketplace_path,
|
||||
invalid_marketplace_path
|
||||
);
|
||||
assert!(
|
||||
response.marketplace_load_errors[0]
|
||||
.message
|
||||
.contains("invalid marketplace file"),
|
||||
"unexpected error: {:?}",
|
||||
response.marketplace_load_errors
|
||||
);
|
||||
assert_eq!(response.remote_sync_error, None);
|
||||
assert!(response.featured_plugin_ids.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -812,35 +812,32 @@ impl AgentControl {
|
||||
return;
|
||||
};
|
||||
let child_thread = state.get_thread(child_thread_id).await.ok();
|
||||
if let Some(child_agent_path) = child_agent_path
|
||||
let message = format_subagent_notification_message(child_reference.as_str(), &status);
|
||||
if child_agent_path.is_some()
|
||||
&& child_thread
|
||||
.as_ref()
|
||||
.map(|thread| thread.enabled(Feature::MultiAgentV2))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
let AgentStatus::Completed(Some(content)) = &status else {
|
||||
let Some(child_agent_path) = child_agent_path.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else {
|
||||
return;
|
||||
};
|
||||
let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else {
|
||||
return;
|
||||
};
|
||||
let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path)
|
||||
let Some(parent_agent_path) = child_agent_path
|
||||
.as_str()
|
||||
.rsplit_once('/')
|
||||
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let communication = InterAgentCommunication::new(
|
||||
child_agent_path,
|
||||
parent_agent_path,
|
||||
Vec::new(),
|
||||
message,
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let _ = control
|
||||
.send_inter_agent_communication(
|
||||
parent_thread_id,
|
||||
InterAgentCommunication::new(
|
||||
child_agent_path,
|
||||
parent_agent_path,
|
||||
Vec::new(),
|
||||
content.clone(),
|
||||
),
|
||||
)
|
||||
.send_inter_agent_communication(parent_thread_id, communication)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -848,10 +845,7 @@ impl AgentControl {
|
||||
return;
|
||||
};
|
||||
parent_thread
|
||||
.inject_user_message_without_turn(format_subagent_notification_message(
|
||||
child_reference.as_str(),
|
||||
&status,
|
||||
))
|
||||
.inject_user_message_without_turn(message)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
@@ -1111,7 +1105,7 @@ fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
|
||||
}
|
||||
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
ResponseItem::Message { role, .. } if role == "user" => {
|
||||
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -29,8 +29,6 @@ use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const MULTI_AGENT_EVENTUAL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
async fn test_config_with_cli_overrides(
|
||||
@@ -409,6 +407,67 @@ async fn send_input_submits_user_message() {
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, thread) = harness.start_thread().await;
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"hello from tests".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.send_inter_agent_communication(thread_id, communication.clone())
|
||||
.await
|
||||
.expect("send_inter_agent_communication should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
|
||||
let expected = (
|
||||
thread_id,
|
||||
Op::InterAgentCommunication {
|
||||
communication: communication.clone(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
assert_eq!(captured, Some(expected));
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if thread
|
||||
.codex
|
||||
.session
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
{
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("inter-agent communication should stay pending");
|
||||
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
assert!(!history_contains_assistant_inter_agent_communication(
|
||||
&history_items,
|
||||
&communication
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_message_records_assistant_message() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -964,94 +1023,6 @@ async fn spawn_child_completion_notifies_parent_history() {
|
||||
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (root_thread_id, _) = harness.start_thread().await;
|
||||
let mut config = harness.config.clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
|
||||
let worker_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config.clone(),
|
||||
text_input("hello worker"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("worker spawn should succeed");
|
||||
let tester_path = worker_path.join("tester").expect("tester path");
|
||||
let tester_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config,
|
||||
text_input("hello tester"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: worker_thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(tester_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("tester spawn should succeed");
|
||||
|
||||
let tester_thread = harness
|
||||
.manager
|
||||
.get_thread(tester_thread_id)
|
||||
.await
|
||||
.expect("tester thread should exist");
|
||||
let tester_turn = tester_thread.codex.session.new_default_turn().await;
|
||||
tester_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
tester_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: tester_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected = InterAgentCommunication::new(
|
||||
tester_path.clone(),
|
||||
worker_path.clone(),
|
||||
Vec::new(),
|
||||
"done".to_string(),
|
||||
);
|
||||
|
||||
timeout(MULTI_AGENT_EVENTUAL_TIMEOUT, async {
|
||||
loop {
|
||||
let delivered = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.any(|(thread_id, op)| {
|
||||
thread_id == worker_thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication == expected
|
||||
)
|
||||
});
|
||||
if delivered {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("completion watcher should send inter-agent communication");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -1147,11 +1118,109 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"done".to_string(),
|
||||
true,
|
||||
)
|
||||
));
|
||||
assert!(!has_subagent_notification(&root_history_items));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (_root_thread_id, root_thread) = harness.start_thread().await;
|
||||
let (worker_thread_id, _worker_thread) = harness.start_thread().await;
|
||||
let mut tester_config = harness.config.clone();
|
||||
let _ = tester_config.features.enable(Feature::MultiAgentV2);
|
||||
let tester_thread_id = harness
|
||||
.manager
|
||||
.start_thread(tester_config)
|
||||
.await
|
||||
.expect("tester thread should start")
|
||||
.thread_id;
|
||||
let tester_thread = harness
|
||||
.manager
|
||||
.get_thread(tester_thread_id)
|
||||
.await
|
||||
.expect("tester thread should exist");
|
||||
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
|
||||
let tester_path = worker_path.join("tester").expect("tester path");
|
||||
harness.control.maybe_start_completion_watcher(
|
||||
tester_thread_id,
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: worker_thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(tester_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
tester_path.to_string(),
|
||||
Some(tester_path.clone()),
|
||||
);
|
||||
let tester_turn = tester_thread.codex.session.new_default_turn().await;
|
||||
tester_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
tester_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: tester_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected_message = crate::session_prefix::format_subagent_notification_message(
|
||||
tester_path.as_str(),
|
||||
&AgentStatus::Completed(Some("done".to_string())),
|
||||
);
|
||||
let expected = (
|
||||
worker_thread_id,
|
||||
Op::InterAgentCommunication {
|
||||
communication: InterAgentCommunication::new(
|
||||
tester_path.clone(),
|
||||
worker_path.clone(),
|
||||
Vec::new(),
|
||||
expected_message.clone(),
|
||||
false,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
if captured == Some(expected.clone()) {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("completion watcher should queue a direct-parent message");
|
||||
|
||||
let root_history_items = root_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
assert!(!history_contains_assistant_inter_agent_communication(
|
||||
&root_history_items,
|
||||
&InterAgentCommunication::new(
|
||||
tester_path,
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
expected_message,
|
||||
false,
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
|
||||
+21
-21
@@ -235,8 +235,6 @@ pub(crate) struct PreviousTurnSettings {
|
||||
|
||||
use crate::exec_policy::ExecPolicyUpdateError;
|
||||
use crate::feedback_tags;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
use crate::file_watcher::FileWatcherEvent;
|
||||
use crate::git_info::get_git_repo_root;
|
||||
use crate::guardian::GuardianReviewSessionManager;
|
||||
use crate::hook_runtime::PendingInputHookDisposition;
|
||||
@@ -322,6 +320,8 @@ use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
use crate::skills::resolve_skill_dependencies_for_turn;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
@@ -403,7 +403,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) skills_manager: Arc<SkillsManager>,
|
||||
pub(crate) plugins_manager: Arc<PluginsManager>,
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
pub(crate) file_watcher: Arc<FileWatcher>,
|
||||
pub(crate) skills_watcher: Arc<SkillsWatcher>,
|
||||
pub(crate) conversation_history: InitialHistory,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
@@ -456,7 +456,7 @@ impl Codex {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
conversation_history,
|
||||
session_source,
|
||||
agent_control,
|
||||
@@ -644,7 +644,7 @@ impl Codex {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager.clone(),
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
)
|
||||
.await
|
||||
@@ -1297,13 +1297,13 @@ impl Session {
|
||||
self.out_of_band_elicitation_paused.send_replace(paused);
|
||||
}
|
||||
|
||||
fn start_file_watcher_listener(self: &Arc<Self>) {
|
||||
let mut rx = self.services.file_watcher.subscribe();
|
||||
fn start_skills_watcher_listener(self: &Arc<Self>) {
|
||||
let mut rx = self.services.skills_watcher.subscribe();
|
||||
let weak_sess = Arc::downgrade(self);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(FileWatcherEvent::SkillsChanged { .. }) => {
|
||||
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
|
||||
let Some(sess) = weak_sess.upgrade() else {
|
||||
break;
|
||||
};
|
||||
@@ -1439,7 +1439,7 @@ impl Session {
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
agent_control: AgentControl,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
debug!(
|
||||
@@ -1858,7 +1858,7 @@ impl Session {
|
||||
skills_manager,
|
||||
plugins_manager: Arc::clone(&plugins_manager),
|
||||
mcp_manager: Arc::clone(&mcp_manager),
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
network_proxy,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
@@ -1937,7 +1937,7 @@ impl Session {
|
||||
}
|
||||
|
||||
// Start the watcher after SessionConfigured so it cannot emit earlier events.
|
||||
sess.start_file_watcher_listener();
|
||||
sess.start_skills_watcher_listener();
|
||||
// Construct sandbox_state before MCP startup so it can be sent to each
|
||||
// MCP server immediately after it becomes ready (avoiding blocking).
|
||||
let sandbox_state = SandboxState {
|
||||
@@ -4650,27 +4650,27 @@ mod handlers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an inter-agent assistant envelope and, when requested, wakes the recipient by
|
||||
/// starting a regular turn if the session is currently idle.
|
||||
pub async fn inter_agent_communication(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
let pending_item = communication.to_response_input_item();
|
||||
if sess
|
||||
.inject_response_items(vec![pending_item.clone()])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
|
||||
.await;
|
||||
sess.queue_response_items_for_next_turn(vec![pending_item])
|
||||
.await;
|
||||
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
|
||||
.await;
|
||||
if communication.trigger_turn {
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
|
||||
.await;
|
||||
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
|
||||
|
||||
@@ -41,6 +41,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem {
|
||||
AgentPath::root().join("worker").unwrap(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
true,
|
||||
);
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
|
||||
@@ -80,7 +80,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
skills_manager: Arc::clone(&parent_session.services.skills_manager),
|
||||
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
file_watcher: Arc::clone(&parent_session.services.file_watcher),
|
||||
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
|
||||
conversation_history: initial_history.unwrap_or(InitialHistory::New),
|
||||
session_source: SessionSource::SubAgent(subagent_source),
|
||||
agent_control: parent_session.services.agent_control.clone(),
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::protocol::NetworkApprovalProtocol;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use crate::protocol::RolloutItem;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
@@ -1166,6 +1167,35 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
// The parent rollout writer drains asynchronously after turn completion.
|
||||
// Wait until the persisted JSONL includes the source user turn before forking from it.
|
||||
let mut source_history_persisted = false;
|
||||
for _ in 0..100 {
|
||||
let history = RolloutRecorder::get_rollout_history(&rollout_path).await;
|
||||
source_history_persisted = history.ok().is_some_and(|history| {
|
||||
history.get_rollout_items().into_iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. })
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| {
|
||||
matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "fork seed"
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
});
|
||||
if source_history_persisted {
|
||||
break;
|
||||
}
|
||||
sleep(StdDuration::from_millis(10)).await;
|
||||
}
|
||||
assert!(
|
||||
source_history_persisted,
|
||||
"source rollout should contain the completed pre-fork user turn before forking"
|
||||
);
|
||||
|
||||
let mut fork_config = initial.config.clone();
|
||||
fork_config.permissions.approval_policy =
|
||||
@@ -2494,7 +2524,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
Arc::new(FileWatcher::noop()),
|
||||
Arc::new(SkillsWatcher::noop()),
|
||||
AgentControl::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -2593,7 +2623,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
.expect("create environment"),
|
||||
);
|
||||
|
||||
let file_watcher = Arc::new(FileWatcher::noop());
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
McpConnectionManager::new_mcp_connection_manager_for_tests(
|
||||
@@ -2627,7 +2657,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
network_proxy: None,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
@@ -3428,7 +3458,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
.expect("create environment"),
|
||||
);
|
||||
|
||||
let file_watcher = Arc::new(FileWatcher::noop());
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
McpConnectionManager::new_mcp_connection_manager_for_tests(
|
||||
@@ -3462,7 +3492,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
agent_control,
|
||||
network_proxy: None,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
|
||||
@@ -435,7 +435,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
true,
|
||||
));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let file_watcher = Arc::new(FileWatcher::noop());
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
|
||||
config,
|
||||
@@ -444,7 +444,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
conversation_history: InitialHistory::New,
|
||||
session_source: SessionSource::SubAgent(SubAgentSource::Other(
|
||||
GUARDIAN_REVIEWER_NAME.to_string(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::event_mapping::has_non_contextual_dev_message_content;
|
||||
use crate::event_mapping::is_contextual_dev_message_content;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
@@ -40,7 +42,9 @@ pub(crate) struct ContextManager {
|
||||
/// match the current turn after context updates are persisted.
|
||||
///
|
||||
/// When this is `None`, settings diffing treats the next turn as having no
|
||||
/// baseline and emits a full reinjection of context state.
|
||||
/// baseline and emits a full reinjection of context state. Rollback may
|
||||
/// also clear this when it trims a mixed initial-context developer bundle
|
||||
/// whose non-diff fragments no longer exist in the surviving history.
|
||||
reference_context_item: Option<TurnContextItem>,
|
||||
}
|
||||
|
||||
@@ -215,6 +219,12 @@ impl ContextManager {
|
||||
/// - if there are no user turns, this is a no-op
|
||||
/// - if `num_turns` exceeds the number of user turns, all user turns are dropped while
|
||||
/// preserving any items that occurred before the first user message.
|
||||
///
|
||||
/// If rollback trims a pre-turn developer message that mixes contextual fragments with
|
||||
/// persistent developer text from `build_initial_context`, this also clears
|
||||
/// `reference_context_item`. The surviving history no longer contains the full bundle that
|
||||
/// established the prior baseline, so future turns must fall back to full reinjection instead
|
||||
/// of diffing against stale state.
|
||||
pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) {
|
||||
if num_turns == 0 {
|
||||
return;
|
||||
@@ -222,18 +232,21 @@ impl ContextManager {
|
||||
|
||||
let snapshot = self.items.clone();
|
||||
let user_positions = user_message_positions(&snapshot);
|
||||
let Some(&first_user_idx) = user_positions.first() else {
|
||||
let Some(&first_instruction_turn_idx) = user_positions.first() else {
|
||||
self.replace(snapshot);
|
||||
return;
|
||||
};
|
||||
|
||||
let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
|
||||
let cut_idx = if n_from_end >= user_positions.len() {
|
||||
first_user_idx
|
||||
let mut cut_idx = if n_from_end >= user_positions.len() {
|
||||
first_instruction_turn_idx
|
||||
} else {
|
||||
user_positions[user_positions.len() - n_from_end]
|
||||
};
|
||||
|
||||
cut_idx =
|
||||
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
|
||||
|
||||
self.replace(snapshot[..cut_idx].to_vec());
|
||||
}
|
||||
|
||||
@@ -382,6 +395,53 @@ impl ContextManager {
|
||||
| ResponseItem::Other => item.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk backward from a rollback cut and trim contiguous pre-turn context-update items.
|
||||
///
|
||||
/// Returns the adjusted cut index after removing contextual developer/user items immediately
|
||||
/// above the rolled-back turn boundary.
|
||||
///
|
||||
/// `first_instruction_turn_idx` is the earliest rollback-eligible instruction-turn boundary
|
||||
/// in `snapshot`; the trim walk never crosses it so any session-prefix items that predate the
|
||||
/// first real turn survive rollback.
|
||||
///
|
||||
/// `cut_idx` is the tentative slice boundary after dropping the requested number of
|
||||
/// instruction turns, before stripping contextual pre-turn items that sit immediately above
|
||||
/// that boundary.
|
||||
///
|
||||
/// If any trimmed developer message was a mixed `build_initial_context` bundle containing both
|
||||
/// rollback-trimmable contextual fragments and persistent developer text, this also clears the
|
||||
/// stored `reference_context_item` baseline so the next real turn falls back to full
|
||||
/// reinjection.
|
||||
fn trim_pre_turn_context_updates(
|
||||
&mut self,
|
||||
snapshot: &[ResponseItem],
|
||||
first_instruction_turn_idx: usize,
|
||||
mut cut_idx: usize,
|
||||
) -> usize {
|
||||
while cut_idx > first_instruction_turn_idx {
|
||||
match &snapshot[cut_idx - 1] {
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "developer" && is_contextual_dev_message_content(content) =>
|
||||
{
|
||||
if has_non_contextual_dev_message_content(content) {
|
||||
// Mixed `build_initial_context` bundles are not reconstructible from
|
||||
// steady-state diffs once trimmed, so the next real turn must fully
|
||||
// reinject context instead of diffing against a stale baseline.
|
||||
self.reference_context_item = None;
|
||||
}
|
||||
cut_idx -= 1;
|
||||
}
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user" && is_contextual_user_message_content(content) =>
|
||||
{
|
||||
cut_idx -= 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
cut_idx
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_function_output_payload(
|
||||
|
||||
@@ -5,6 +5,7 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_git::GhostCommit;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -18,12 +19,16 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use image::ImageBuffer;
|
||||
use image::ImageFormat;
|
||||
use image::Rgba;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
|
||||
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
|
||||
@@ -46,6 +51,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
|
||||
AgentPath::root().join("worker").unwrap(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
true,
|
||||
);
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -90,6 +96,56 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn developer_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: texts
|
||||
.iter()
|
||||
.map(|text| ContentItem::InputText {
|
||||
text: (*text).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reference_context_item() -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some("reference-turn".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/tmp/reference-cwd"),
|
||||
current_date: Some("2026-03-23".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
model: "gpt-test".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: Some(false),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)),
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem {
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
@@ -252,6 +308,15 @@ fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
assert!(is_user_turn_boundary(&item));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_prompt_preserves_inter_agent_assistant_messages() {
|
||||
let item = inter_agent_assistant_msg("continue");
|
||||
let history = create_history_with_items(vec![item.clone()]);
|
||||
|
||||
assert_eq!(history.raw_items(), std::slice::from_ref(&item));
|
||||
assert_eq!(history.for_prompt(&default_input_modalities()), vec![item]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
|
||||
let first_turn = user_input_text_msg("first");
|
||||
@@ -851,6 +916,75 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
|
||||
let items = vec![
|
||||
assistant_msg("session prefix item"),
|
||||
user_input_text_msg("turn 1 user"),
|
||||
assistant_msg("turn 1 assistant"),
|
||||
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
|
||||
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
|
||||
user_input_text_msg(
|
||||
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
|
||||
),
|
||||
user_input_text_msg("turn 2 user"),
|
||||
assistant_msg("turn 2 assistant"),
|
||||
];
|
||||
|
||||
let modalities = default_input_modalities();
|
||||
let mut history = create_history_with_items(items);
|
||||
let reference_context_item = reference_context_item();
|
||||
history.set_reference_context_item(Some(reference_context_item.clone()));
|
||||
history.drop_last_n_user_turns(1);
|
||||
|
||||
assert_eq!(
|
||||
history.clone().for_prompt(&modalities),
|
||||
vec![
|
||||
assistant_msg("session prefix item"),
|
||||
user_input_text_msg("turn 1 user"),
|
||||
assistant_msg("turn 1 assistant"),
|
||||
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(history.reference_context_item())
|
||||
.expect("serialize retained reference context item"),
|
||||
serde_json::to_value(Some(reference_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles() {
|
||||
let items = vec![
|
||||
user_input_text_msg("turn 1 user"),
|
||||
assistant_msg("turn 1 assistant"),
|
||||
developer_msg_with_fragments(&[
|
||||
"<permissions instructions>contextual permissions</permissions instructions>",
|
||||
"persistent plugin instructions",
|
||||
]),
|
||||
user_input_text_msg(
|
||||
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
|
||||
),
|
||||
user_input_text_msg("turn 2 user"),
|
||||
assistant_msg("turn 2 assistant"),
|
||||
];
|
||||
|
||||
let modalities = default_input_modalities();
|
||||
let mut history = create_history_with_items(items);
|
||||
history.set_reference_context_item(Some(reference_context_item()));
|
||||
history.drop_last_n_user_turns(1);
|
||||
|
||||
assert_eq!(
|
||||
history.clone().for_prompt(&modalities),
|
||||
vec![
|
||||
user_input_text_msg("turn 1 user"),
|
||||
assistant_msg("turn 1 assistant"),
|
||||
]
|
||||
);
|
||||
assert!(history.reference_context_item().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_first_item_handles_custom_tool_pair() {
|
||||
let items = vec![
|
||||
|
||||
@@ -14,6 +14,8 @@ use codex_protocol::models::is_image_close_tag_text;
|
||||
use codex_protocol::models::is_image_open_tag_text;
|
||||
use codex_protocol::models::is_local_image_close_tag_text;
|
||||
use codex_protocol::models::is_local_image_open_tag_text;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
@@ -22,10 +24,48 @@ use crate::contextual_user_message::is_contextual_user_fragment;
|
||||
use crate::contextual_user_message::parse_visible_hook_prompt_message;
|
||||
use crate::web_search::web_search_action_detail;
|
||||
|
||||
const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
|
||||
"<permissions instructions>",
|
||||
"<model_switch>",
|
||||
COLLABORATION_MODE_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
"<personality_spec>",
|
||||
];
|
||||
|
||||
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
|
||||
message.iter().any(is_contextual_user_fragment)
|
||||
}
|
||||
|
||||
/// Returns true when a developer message contains any rollback-trimmable contextual fragment.
|
||||
///
|
||||
/// `build_initial_context` can bundle these fragments together with persistent developer text in a
|
||||
/// single developer message, so callers that care about invalidating a stored reference baseline
|
||||
/// should pair this with `has_non_contextual_dev_message_content`.
|
||||
pub(crate) fn is_contextual_dev_message_content(message: &[ContentItem]) -> bool {
|
||||
message.iter().any(is_contextual_dev_fragment)
|
||||
}
|
||||
|
||||
/// Returns true when a developer message contains any fragment that is not part of the
|
||||
/// rollback-trimmable contextual prefix set.
|
||||
pub(crate) fn has_non_contextual_dev_message_content(message: &[ContentItem]) -> bool {
|
||||
message
|
||||
.iter()
|
||||
.any(|content_item| !is_contextual_dev_fragment(content_item))
|
||||
}
|
||||
|
||||
fn is_contextual_dev_fragment(content_item: &ContentItem) -> bool {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let trimmed = text.trim_start();
|
||||
CONTEXTUAL_DEVELOPER_PREFIXES.iter().any(|prefix| {
|
||||
trimmed
|
||||
.get(..prefix.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
if is_contextual_user_message_content(message) {
|
||||
return None;
|
||||
|
||||
+446
-222
@@ -1,13 +1,15 @@
|
||||
//! Watches skill roots for changes and broadcasts coarse-grained
|
||||
//! `FileWatcherEvent`s that higher-level components react to on the next turn.
|
||||
//! Watches subscribed files or directories and routes coarse-grained change
|
||||
//! notifications to the subscribers that own matching watched paths.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::Event;
|
||||
@@ -16,22 +18,162 @@ use notify::RecommendedWatcher;
|
||||
use notify::RecursiveMode;
|
||||
use notify::Watcher;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::sleep_until;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::skills::SkillsManager;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FileWatcherEvent {
|
||||
SkillsChanged { paths: Vec<PathBuf> },
|
||||
/// Coalesced file change notification for a subscriber.
|
||||
pub struct FileWatcherEvent {
|
||||
/// Changed paths delivered in sorted order with duplicates removed.
|
||||
pub paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
/// Path subscription registered by a [`FileWatcherSubscriber`].
|
||||
pub struct WatchPath {
|
||||
/// Root path to watch.
|
||||
pub path: PathBuf,
|
||||
/// Whether events below `path` should match recursively.
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
type SubscriberId = u64;
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatchState {
|
||||
skills_root_ref_counts: HashMap<PathBuf, usize>,
|
||||
next_subscriber_id: SubscriberId,
|
||||
path_ref_counts: HashMap<PathBuf, PathWatchCounts>,
|
||||
subscribers: HashMap<SubscriberId, SubscriberState>,
|
||||
}
|
||||
|
||||
struct SubscriberState {
|
||||
watched_paths: HashMap<WatchPath, usize>,
|
||||
tx: WatchSender,
|
||||
}
|
||||
|
||||
/// Receives coalesced change notifications for a single subscriber.
|
||||
pub struct Receiver {
|
||||
inner: Arc<ReceiverInner>,
|
||||
}
|
||||
|
||||
struct WatchSender {
|
||||
inner: Arc<ReceiverInner>,
|
||||
}
|
||||
|
||||
struct ReceiverInner {
|
||||
changed_paths: AsyncMutex<BTreeSet<PathBuf>>,
|
||||
notify: Notify,
|
||||
sender_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Receiver {
|
||||
/// Waits for the next batch of changed paths, or returns `None` once the
|
||||
/// corresponding subscriber has been removed and no more events can arrive.
|
||||
pub async fn recv(&mut self) -> Option<FileWatcherEvent> {
|
||||
loop {
|
||||
let notified = self.inner.notify.notified();
|
||||
{
|
||||
let mut changed_paths = self.inner.changed_paths.lock().await;
|
||||
if !changed_paths.is_empty() {
|
||||
return Some(FileWatcherEvent {
|
||||
paths: std::mem::take(&mut *changed_paths).into_iter().collect(),
|
||||
});
|
||||
}
|
||||
if self.inner.sender_count.load(Ordering::Acquire) == 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WatchSender {
|
||||
async fn add_changed_paths(&self, paths: &[PathBuf]) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut changed_paths = self.inner.changed_paths.lock().await;
|
||||
let previous_len = changed_paths.len();
|
||||
changed_paths.extend(paths.iter().cloned());
|
||||
if changed_paths.len() != previous_len {
|
||||
self.inner.notify.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for WatchSender {
|
||||
fn clone(&self) -> Self {
|
||||
self.inner.sender_count.fetch_add(1, Ordering::Relaxed);
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WatchSender {
|
||||
fn drop(&mut self) {
|
||||
if self.inner.sender_count.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
self.inner.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_channel() -> (WatchSender, Receiver) {
|
||||
let inner = Arc::new(ReceiverInner {
|
||||
changed_paths: AsyncMutex::new(BTreeSet::new()),
|
||||
notify: Notify::new(),
|
||||
sender_count: AtomicUsize::new(1),
|
||||
});
|
||||
(
|
||||
WatchSender {
|
||||
inner: Arc::clone(&inner),
|
||||
},
|
||||
Receiver { inner },
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct PathWatchCounts {
|
||||
non_recursive: usize,
|
||||
recursive: usize,
|
||||
}
|
||||
|
||||
impl PathWatchCounts {
|
||||
fn increment(&mut self, recursive: bool, amount: usize) {
|
||||
if recursive {
|
||||
self.recursive += amount;
|
||||
} else {
|
||||
self.non_recursive += amount;
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement(&mut self, recursive: bool, amount: usize) {
|
||||
if recursive {
|
||||
self.recursive = self.recursive.saturating_sub(amount);
|
||||
} else {
|
||||
self.non_recursive = self.non_recursive.saturating_sub(amount);
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_mode(self) -> Option<RecursiveMode> {
|
||||
if self.recursive > 0 {
|
||||
Some(RecursiveMode::Recursive)
|
||||
} else if self.non_recursive > 0 {
|
||||
Some(RecursiveMode::NonRecursive)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(self) -> bool {
|
||||
self.non_recursive == 0 && self.recursive == 0
|
||||
}
|
||||
}
|
||||
|
||||
struct FileWatcherInner {
|
||||
@@ -39,73 +181,95 @@ struct FileWatcherInner {
|
||||
watched_paths: HashMap<PathBuf, RecursiveMode>,
|
||||
}
|
||||
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Coalesces bursts of paths and emits at most once per interval.
|
||||
struct ThrottledPaths {
|
||||
pending: HashSet<PathBuf>,
|
||||
next_allowed_at: Instant,
|
||||
/// Coalesces bursts of watch notifications and emits at most once per interval.
|
||||
pub struct ThrottledWatchReceiver {
|
||||
rx: Receiver,
|
||||
interval: Duration,
|
||||
next_allowed: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ThrottledPaths {
|
||||
fn new(now: Instant) -> Self {
|
||||
impl ThrottledWatchReceiver {
|
||||
/// Creates a throttling wrapper around a raw watcher [`Receiver`].
|
||||
pub fn new(rx: Receiver, interval: Duration) -> Self {
|
||||
Self {
|
||||
pending: HashSet::new(),
|
||||
next_allowed_at: now,
|
||||
rx,
|
||||
interval,
|
||||
next_allowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, paths: Vec<PathBuf>) {
|
||||
self.pending.extend(paths);
|
||||
}
|
||||
|
||||
fn next_deadline(&self, now: Instant) -> Option<Instant> {
|
||||
(!self.pending.is_empty() && now < self.next_allowed_at).then_some(self.next_allowed_at)
|
||||
}
|
||||
|
||||
fn take_ready(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
|
||||
if self.pending.is_empty() || now < self.next_allowed_at {
|
||||
return None;
|
||||
/// Receives the next event, enforcing the configured minimum delay after
|
||||
/// the previous emission.
|
||||
pub async fn recv(&mut self) -> Option<FileWatcherEvent> {
|
||||
if let Some(next_allowed) = self.next_allowed {
|
||||
sleep_until(next_allowed).await;
|
||||
}
|
||||
Some(self.take_with_next_allowed(now))
|
||||
}
|
||||
|
||||
fn take_pending(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
|
||||
if self.pending.is_empty() {
|
||||
return None;
|
||||
let event = self.rx.recv().await;
|
||||
if event.is_some() {
|
||||
self.next_allowed = Some(Instant::now() + self.interval);
|
||||
}
|
||||
Some(self.take_with_next_allowed(now))
|
||||
}
|
||||
|
||||
fn take_with_next_allowed(&mut self, now: Instant) -> Vec<PathBuf> {
|
||||
let mut paths: Vec<PathBuf> = self.pending.drain().collect();
|
||||
paths.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
|
||||
self.next_allowed_at = now + WATCHER_THROTTLE_INTERVAL;
|
||||
paths
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FileWatcher {
|
||||
inner: Option<Mutex<FileWatcherInner>>,
|
||||
state: Arc<RwLock<WatchState>>,
|
||||
tx: broadcast::Sender<FileWatcherEvent>,
|
||||
/// Handle used to register watched paths for one logical consumer.
|
||||
pub struct FileWatcherSubscriber {
|
||||
id: SubscriberId,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
}
|
||||
|
||||
pub(crate) struct WatchRegistration {
|
||||
impl FileWatcherSubscriber {
|
||||
/// Registers the provided paths for this subscriber and returns an RAII
|
||||
/// guard that unregisters them on drop.
|
||||
pub fn register_paths(&self, watched_paths: Vec<WatchPath>) -> WatchRegistration {
|
||||
let watched_paths = dedupe_watched_paths(watched_paths);
|
||||
self.file_watcher.register_paths(self.id, &watched_paths);
|
||||
|
||||
WatchRegistration {
|
||||
file_watcher: Arc::downgrade(&self.file_watcher),
|
||||
subscriber_id: self.id,
|
||||
watched_paths,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn register_path(&self, path: PathBuf, recursive: bool) -> WatchRegistration {
|
||||
self.register_paths(vec![WatchPath { path, recursive }])
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileWatcherSubscriber {
|
||||
fn drop(&mut self) {
|
||||
self.file_watcher.remove_subscriber(self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard for a set of active path registrations.
|
||||
pub struct WatchRegistration {
|
||||
file_watcher: std::sync::Weak<FileWatcher>,
|
||||
roots: Vec<PathBuf>,
|
||||
subscriber_id: SubscriberId,
|
||||
watched_paths: Vec<WatchPath>,
|
||||
}
|
||||
|
||||
impl Drop for WatchRegistration {
|
||||
fn drop(&mut self) {
|
||||
if let Some(file_watcher) = self.file_watcher.upgrade() {
|
||||
file_watcher.unregister_roots(&self.roots);
|
||||
file_watcher.unregister_paths(self.subscriber_id, &self.watched_paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-subscriber file watcher built on top of `notify`.
|
||||
pub struct FileWatcher {
|
||||
inner: Option<Mutex<FileWatcherInner>>,
|
||||
state: Arc<RwLock<WatchState>>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
pub(crate) fn new(_codex_home: PathBuf) -> notify::Result<Self> {
|
||||
/// Creates a live filesystem watcher and starts its background event loop
|
||||
/// on the current Tokio runtime.
|
||||
pub fn new() -> notify::Result<Self> {
|
||||
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
|
||||
let raw_tx_clone = raw_tx;
|
||||
let watcher = notify::recommended_watcher(move |res| {
|
||||
@@ -115,109 +279,101 @@ impl FileWatcher {
|
||||
watcher,
|
||||
watched_paths: HashMap::new(),
|
||||
};
|
||||
let (tx, _) = broadcast::channel(128);
|
||||
let state = Arc::new(RwLock::new(WatchState {
|
||||
skills_root_ref_counts: HashMap::new(),
|
||||
}));
|
||||
let state = Arc::new(RwLock::new(WatchState::default()));
|
||||
let file_watcher = Self {
|
||||
inner: Some(Mutex::new(inner)),
|
||||
state: Arc::clone(&state),
|
||||
tx: tx.clone(),
|
||||
state,
|
||||
};
|
||||
file_watcher.spawn_event_loop(raw_rx, state, tx);
|
||||
file_watcher.spawn_event_loop(raw_rx);
|
||||
Ok(file_watcher)
|
||||
}
|
||||
|
||||
pub(crate) fn noop() -> Self {
|
||||
let (tx, _) = broadcast::channel(1);
|
||||
/// Creates an inert watcher that only supports test-driven synthetic
|
||||
/// notifications.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: None,
|
||||
state: Arc::new(RwLock::new(WatchState {
|
||||
skills_root_ref_counts: HashMap::new(),
|
||||
})),
|
||||
tx,
|
||||
state: Arc::new(RwLock::new(WatchState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> broadcast::Receiver<FileWatcherEvent> {
|
||||
self.tx.subscribe()
|
||||
/// Adds a new subscriber and returns both its registration handle and its
|
||||
/// dedicated event receiver.
|
||||
pub fn add_subscriber(self: &Arc<Self>) -> (FileWatcherSubscriber, Receiver) {
|
||||
let (tx, rx) = watch_channel();
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let subscriber_id = state.next_subscriber_id;
|
||||
state.next_subscriber_id += 1;
|
||||
state.subscribers.insert(
|
||||
subscriber_id,
|
||||
SubscriberState {
|
||||
watched_paths: HashMap::new(),
|
||||
tx,
|
||||
},
|
||||
);
|
||||
|
||||
let subscriber = FileWatcherSubscriber {
|
||||
id: subscriber_id,
|
||||
file_watcher: self.clone(),
|
||||
};
|
||||
(subscriber, rx)
|
||||
}
|
||||
|
||||
pub(crate) fn register_config(
|
||||
self: &Arc<Self>,
|
||||
config: &Config,
|
||||
skills_manager: &SkillsManager,
|
||||
) -> WatchRegistration {
|
||||
let deduped_roots: HashSet<PathBuf> = skills_manager
|
||||
.skill_roots_for_config(config)
|
||||
.into_iter()
|
||||
.map(|root| root.path)
|
||||
.collect();
|
||||
let mut registered_roots: Vec<PathBuf> = deduped_roots.into_iter().collect();
|
||||
registered_roots.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
|
||||
for root in ®istered_roots {
|
||||
self.register_skills_root(root.clone());
|
||||
}
|
||||
fn register_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) {
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
|
||||
|
||||
WatchRegistration {
|
||||
file_watcher: Arc::downgrade(self),
|
||||
roots: registered_roots,
|
||||
for watched_path in watched_paths {
|
||||
{
|
||||
let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else {
|
||||
return;
|
||||
};
|
||||
*subscriber
|
||||
.watched_paths
|
||||
.entry(watched_path.clone())
|
||||
.or_default() += 1;
|
||||
}
|
||||
|
||||
let counts = state
|
||||
.path_ref_counts
|
||||
.entry(watched_path.path.clone())
|
||||
.or_default();
|
||||
let previous_mode = counts.effective_mode();
|
||||
counts.increment(watched_path.recursive, /*amount*/ 1);
|
||||
let next_mode = counts.effective_mode();
|
||||
if previous_mode != next_mode {
|
||||
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge `notify`'s callback-based events into the Tokio runtime and
|
||||
// broadcast coarse-grained change signals to subscribers.
|
||||
fn spawn_event_loop(
|
||||
&self,
|
||||
mut raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>,
|
||||
state: Arc<RwLock<WatchState>>,
|
||||
tx: broadcast::Sender<FileWatcherEvent>,
|
||||
) {
|
||||
// notify the matching subscribers.
|
||||
fn spawn_event_loop(&self, mut raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>) {
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
let state = Arc::clone(&self.state);
|
||||
handle.spawn(async move {
|
||||
let now = Instant::now();
|
||||
let mut skills = ThrottledPaths::new(now);
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
let next_deadline = skills.next_deadline(now);
|
||||
let timer_deadline = next_deadline
|
||||
.unwrap_or_else(|| now + Duration::from_secs(60 * 60 * 24 * 365));
|
||||
let timer = sleep_until(timer_deadline);
|
||||
tokio::pin!(timer);
|
||||
|
||||
tokio::select! {
|
||||
res = raw_rx.recv() => {
|
||||
match res {
|
||||
Some(Ok(event)) => {
|
||||
let skills_paths = classify_event(&event, &state);
|
||||
let now = Instant::now();
|
||||
skills.add(skills_paths);
|
||||
|
||||
if let Some(paths) = skills.take_ready(now) {
|
||||
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
|
||||
}
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
warn!("file watcher error: {err}");
|
||||
}
|
||||
None => {
|
||||
// Flush any pending changes before shutdown so subscribers
|
||||
// see the latest state.
|
||||
let now = Instant::now();
|
||||
if let Some(paths) = skills.take_pending(now) {
|
||||
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
|
||||
}
|
||||
break;
|
||||
}
|
||||
match raw_rx.recv().await {
|
||||
Some(Ok(event)) => {
|
||||
if !is_mutating_event(&event) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ = &mut timer => {
|
||||
let now = Instant::now();
|
||||
if let Some(paths) = skills.take_ready(now) {
|
||||
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
|
||||
if event.paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
Self::notify_subscribers(&state, &event.paths).await;
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
warn!("file watcher error: {err}");
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -226,127 +382,195 @@ impl FileWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_skills_root(&self, root: PathBuf) {
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let count = state
|
||||
.skills_root_ref_counts
|
||||
.entry(root.clone())
|
||||
.or_insert(0);
|
||||
*count += 1;
|
||||
if *count == 1 {
|
||||
self.watch_path(root, RecursiveMode::Recursive);
|
||||
}
|
||||
}
|
||||
|
||||
fn unregister_roots(&self, roots: &[PathBuf]) {
|
||||
fn unregister_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) {
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
|
||||
|
||||
for root in roots {
|
||||
let mut should_unwatch = false;
|
||||
if let Some(count) = state.skills_root_ref_counts.get_mut(root) {
|
||||
if *count > 1 {
|
||||
*count -= 1;
|
||||
} else {
|
||||
state.skills_root_ref_counts.remove(root);
|
||||
should_unwatch = true;
|
||||
for watched_path in watched_paths {
|
||||
{
|
||||
let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(subscriber_count) = subscriber.watched_paths.get_mut(watched_path) else {
|
||||
continue;
|
||||
};
|
||||
*subscriber_count = subscriber_count.saturating_sub(1);
|
||||
if *subscriber_count == 0 {
|
||||
subscriber.watched_paths.remove(watched_path);
|
||||
}
|
||||
}
|
||||
|
||||
if !should_unwatch {
|
||||
continue;
|
||||
}
|
||||
let Some(inner) = &self.inner else {
|
||||
let Some(counts) = state.path_ref_counts.get_mut(&watched_path.path) else {
|
||||
continue;
|
||||
};
|
||||
if inner_guard.is_none() {
|
||||
let guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
inner_guard = Some(guard);
|
||||
let previous_mode = counts.effective_mode();
|
||||
counts.decrement(watched_path.recursive, /*amount*/ 1);
|
||||
let next_mode = counts.effective_mode();
|
||||
if counts.is_empty() {
|
||||
state.path_ref_counts.remove(&watched_path.path);
|
||||
}
|
||||
|
||||
let Some(guard) = inner_guard.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if guard.watched_paths.remove(root).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = guard.watcher.unwatch(root) {
|
||||
warn!("failed to unwatch {}: {err}", root.display());
|
||||
if previous_mode != next_mode {
|
||||
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_path(&self, path: PathBuf, mode: RecursiveMode) {
|
||||
fn remove_subscriber(&self, subscriber_id: SubscriberId) {
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(subscriber) = state.subscribers.remove(&subscriber_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
|
||||
for (watched_path, count) in subscriber.watched_paths {
|
||||
let Some(path_counts) = state.path_ref_counts.get_mut(&watched_path.path) else {
|
||||
continue;
|
||||
};
|
||||
let previous_mode = path_counts.effective_mode();
|
||||
path_counts.decrement(watched_path.recursive, count);
|
||||
let next_mode = path_counts.effective_mode();
|
||||
if path_counts.is_empty() {
|
||||
state.path_ref_counts.remove(&watched_path.path);
|
||||
}
|
||||
if previous_mode != next_mode {
|
||||
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reconfigure_watch<'a>(
|
||||
&'a self,
|
||||
path: &Path,
|
||||
next_mode: Option<RecursiveMode>,
|
||||
inner_guard: &mut Option<std::sync::MutexGuard<'a, FileWatcherInner>>,
|
||||
) {
|
||||
let Some(inner) = &self.inner else {
|
||||
return;
|
||||
};
|
||||
if inner_guard.is_none() {
|
||||
let guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*inner_guard = Some(guard);
|
||||
}
|
||||
let Some(guard) = inner_guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let existing_mode = guard.watched_paths.get(path).copied();
|
||||
if existing_mode == next_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
if existing_mode.is_some() {
|
||||
if let Err(err) = guard.watcher.unwatch(path) {
|
||||
warn!("failed to unwatch {}: {err}", path.display());
|
||||
}
|
||||
guard.watched_paths.remove(path);
|
||||
}
|
||||
|
||||
let Some(next_mode) = next_mode else {
|
||||
return;
|
||||
};
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let watch_path = path;
|
||||
let mut guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(existing) = guard.watched_paths.get(&watch_path) {
|
||||
if *existing == RecursiveMode::Recursive || *existing == mode {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = guard.watcher.unwatch(&watch_path) {
|
||||
warn!("failed to unwatch {}: {err}", watch_path.display());
|
||||
}
|
||||
}
|
||||
if let Err(err) = guard.watcher.watch(&watch_path, mode) {
|
||||
warn!("failed to watch {}: {err}", watch_path.display());
|
||||
|
||||
if let Err(err) = guard.watcher.watch(path, next_mode) {
|
||||
warn!("failed to watch {}: {err}", path.display());
|
||||
return;
|
||||
}
|
||||
guard.watched_paths.insert(watch_path, mode);
|
||||
guard.watched_paths.insert(path.to_path_buf(), next_mode);
|
||||
}
|
||||
|
||||
async fn notify_subscribers(state: &RwLock<WatchState>, event_paths: &[PathBuf]) {
|
||||
let subscribers_to_notify: Vec<(WatchSender, Vec<PathBuf>)> = {
|
||||
let state = state
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state
|
||||
.subscribers
|
||||
.values()
|
||||
.filter_map(|subscriber| {
|
||||
let changed_paths: Vec<PathBuf> = event_paths
|
||||
.iter()
|
||||
.filter(|event_path| {
|
||||
subscriber.watched_paths.keys().any(|watched_path| {
|
||||
watch_path_matches_event(watched_path, event_path)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
(!changed_paths.is_empty()).then_some((subscriber.tx.clone(), changed_paths))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (subscriber, changed_paths) in subscribers_to_notify {
|
||||
subscriber.add_changed_paths(&changed_paths).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn send_paths_for_test(&self, paths: Vec<PathBuf>) {
|
||||
Self::notify_subscribers(&self.state, &paths).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn spawn_event_loop_for_test(
|
||||
&self,
|
||||
raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>,
|
||||
) {
|
||||
self.spawn_event_loop(raw_rx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn watch_counts_for_test(&self, path: &Path) -> Option<(usize, usize)> {
|
||||
let state = self
|
||||
.state
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state
|
||||
.path_ref_counts
|
||||
.get(path)
|
||||
.map(|counts| (counts.non_recursive, counts.recursive))
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_event(event: &Event, state: &RwLock<WatchState>) -> Vec<PathBuf> {
|
||||
if !matches!(
|
||||
fn is_mutating_event(event: &Event) -> bool {
|
||||
matches!(
|
||||
event.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut skills_paths = Vec::new();
|
||||
let skills_roots = match state.read() {
|
||||
Ok(state) => state
|
||||
.skills_root_ref_counts
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>(),
|
||||
Err(err) => {
|
||||
let state = err.into_inner();
|
||||
state
|
||||
.skills_root_ref_counts
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>()
|
||||
}
|
||||
};
|
||||
|
||||
for path in &event.paths {
|
||||
if is_skills_path(path, &skills_roots) {
|
||||
skills_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
skills_paths
|
||||
)
|
||||
}
|
||||
|
||||
fn is_skills_path(path: &Path, roots: &HashSet<PathBuf>) -> bool {
|
||||
roots.iter().any(|root| path.starts_with(root))
|
||||
fn dedupe_watched_paths(mut watched_paths: Vec<WatchPath>) -> Vec<WatchPath> {
|
||||
watched_paths.sort_unstable_by(|a, b| {
|
||||
a.path
|
||||
.as_os_str()
|
||||
.cmp(b.path.as_os_str())
|
||||
.then(a.recursive.cmp(&b.recursive))
|
||||
});
|
||||
watched_paths.dedup();
|
||||
watched_paths
|
||||
}
|
||||
|
||||
fn watch_path_matches_event(watched_path: &WatchPath, event_path: &Path) -> bool {
|
||||
if event_path == watched_path.path {
|
||||
return true;
|
||||
}
|
||||
if watched_path.path.starts_with(event_path) {
|
||||
return true;
|
||||
}
|
||||
if !event_path.starts_with(&watched_path.path) {
|
||||
return false;
|
||||
}
|
||||
watched_path.recursive || event_path.parent() == Some(watched_path.path.as_path())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::*;
|
||||
use notify::EventKind;
|
||||
use notify::event::AccessKind;
|
||||
use notify::event::AccessMode;
|
||||
use notify::event::CreateKind;
|
||||
use notify::event::ModifyKind;
|
||||
use notify::event::RemoveKind;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const TEST_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
fn path(name: &str) -> PathBuf {
|
||||
PathBuf::from(name)
|
||||
}
|
||||
@@ -20,147 +20,202 @@ fn notify_event(kind: EventKind, paths: Vec<PathBuf>) -> Event {
|
||||
event
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttles_and_coalesces_within_interval() {
|
||||
let start = Instant::now();
|
||||
let mut throttled = ThrottledPaths::new(start);
|
||||
#[tokio::test]
|
||||
async fn throttled_receiver_coalesces_within_interval() {
|
||||
let (tx, rx) = watch_channel();
|
||||
let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
|
||||
|
||||
throttled.add(vec![path("a")]);
|
||||
let first = throttled.take_ready(start).expect("first emit");
|
||||
assert_eq!(first, vec![path("a")]);
|
||||
|
||||
throttled.add(vec![path("b"), path("c")]);
|
||||
assert_eq!(throttled.take_ready(start), None);
|
||||
|
||||
let second = throttled
|
||||
.take_ready(start + WATCHER_THROTTLE_INTERVAL)
|
||||
.expect("coalesced emit");
|
||||
assert_eq!(second, vec![path("b"), path("c")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flushes_pending_on_shutdown() {
|
||||
let start = Instant::now();
|
||||
let mut throttled = ThrottledPaths::new(start);
|
||||
|
||||
throttled.add(vec![path("a")]);
|
||||
let _ = throttled.take_ready(start).expect("first emit");
|
||||
|
||||
throttled.add(vec![path("b")]);
|
||||
assert_eq!(throttled.take_ready(start), None);
|
||||
|
||||
let flushed = throttled
|
||||
.take_pending(start)
|
||||
.expect("shutdown flush emits pending paths");
|
||||
assert_eq!(flushed, vec![path("b")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_event_filters_to_skills_roots() {
|
||||
let root = path("/tmp/skills");
|
||||
let state = RwLock::new(WatchState {
|
||||
skills_root_ref_counts: HashMap::from([(root.clone(), 1)]),
|
||||
});
|
||||
let event = notify_event(
|
||||
EventKind::Create(CreateKind::Any),
|
||||
vec![
|
||||
root.join("demo/SKILL.md"),
|
||||
path("/tmp/other/not-a-skill.txt"),
|
||||
],
|
||||
);
|
||||
|
||||
let classified = classify_event(&event, &state);
|
||||
assert_eq!(classified, vec![root.join("demo/SKILL.md")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_event_supports_multiple_roots_without_prefix_false_positives() {
|
||||
let root_a = path("/tmp/skills");
|
||||
let root_b = path("/tmp/workspace/.codex/skills");
|
||||
let state = RwLock::new(WatchState {
|
||||
skills_root_ref_counts: HashMap::from([(root_a.clone(), 1), (root_b.clone(), 1)]),
|
||||
});
|
||||
let event = notify_event(
|
||||
EventKind::Modify(ModifyKind::Any),
|
||||
vec![
|
||||
root_a.join("alpha/SKILL.md"),
|
||||
path("/tmp/skills-extra/not-under-skills.txt"),
|
||||
root_b.join("beta/SKILL.md"),
|
||||
],
|
||||
);
|
||||
|
||||
let classified = classify_event(&event, &state);
|
||||
tx.add_changed_paths(&[path("a")]).await;
|
||||
let first = timeout(Duration::from_secs(1), throttled.recv())
|
||||
.await
|
||||
.expect("first emit timeout");
|
||||
assert_eq!(
|
||||
classified,
|
||||
vec![root_a.join("alpha/SKILL.md"), root_b.join("beta/SKILL.md")]
|
||||
first,
|
||||
Some(FileWatcherEvent {
|
||||
paths: vec![path("a")],
|
||||
})
|
||||
);
|
||||
|
||||
tx.add_changed_paths(&[path("b"), path("c")]).await;
|
||||
let blocked = timeout(TEST_THROTTLE_INTERVAL / 2, throttled.recv()).await;
|
||||
assert_eq!(blocked.is_err(), true);
|
||||
|
||||
let second = timeout(TEST_THROTTLE_INTERVAL * 2, throttled.recv())
|
||||
.await
|
||||
.expect("second emit timeout");
|
||||
assert_eq!(
|
||||
second,
|
||||
Some(FileWatcherEvent {
|
||||
paths: vec![path("b"), path("c")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throttled_receiver_flushes_pending_on_shutdown() {
|
||||
let (tx, rx) = watch_channel();
|
||||
let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
|
||||
|
||||
tx.add_changed_paths(&[path("a")]).await;
|
||||
let first = timeout(Duration::from_secs(1), throttled.recv())
|
||||
.await
|
||||
.expect("first emit timeout");
|
||||
assert_eq!(
|
||||
first,
|
||||
Some(FileWatcherEvent {
|
||||
paths: vec![path("a")],
|
||||
})
|
||||
);
|
||||
|
||||
tx.add_changed_paths(&[path("b")]).await;
|
||||
drop(tx);
|
||||
|
||||
let second = timeout(Duration::from_secs(1), throttled.recv())
|
||||
.await
|
||||
.expect("shutdown flush timeout");
|
||||
assert_eq!(
|
||||
second,
|
||||
Some(FileWatcherEvent {
|
||||
paths: vec![path("b")],
|
||||
})
|
||||
);
|
||||
|
||||
let closed = timeout(Duration::from_secs(1), throttled.recv())
|
||||
.await
|
||||
.expect("closed recv timeout");
|
||||
assert_eq!(closed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_mutating_event_filters_non_mutating_event_kinds() {
|
||||
assert_eq!(
|
||||
is_mutating_event(¬ify_event(
|
||||
EventKind::Create(CreateKind::Any),
|
||||
vec![path("/tmp/created")]
|
||||
)),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
is_mutating_event(¬ify_event(
|
||||
EventKind::Modify(ModifyKind::Any),
|
||||
vec![path("/tmp/modified")]
|
||||
)),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
is_mutating_event(¬ify_event(
|
||||
EventKind::Access(AccessKind::Open(AccessMode::Any)),
|
||||
vec![path("/tmp/accessed")]
|
||||
)),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_event_ignores_non_mutating_event_kinds() {
|
||||
let root = path("/tmp/skills");
|
||||
let state = RwLock::new(WatchState {
|
||||
skills_root_ref_counts: HashMap::from([(root.clone(), 1)]),
|
||||
});
|
||||
let path = root.join("demo/SKILL.md");
|
||||
|
||||
let access_event = notify_event(
|
||||
EventKind::Access(AccessKind::Open(AccessMode::Any)),
|
||||
vec![path.clone()],
|
||||
);
|
||||
assert_eq!(classify_event(&access_event, &state), Vec::<PathBuf>::new());
|
||||
|
||||
let any_event = notify_event(EventKind::Any, vec![path.clone()]);
|
||||
assert_eq!(classify_event(&any_event, &state), Vec::<PathBuf>::new());
|
||||
|
||||
let other_event = notify_event(EventKind::Other, vec![path]);
|
||||
assert_eq!(classify_event(&other_event, &state), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_skills_root_dedupes_state_entries() {
|
||||
let watcher = FileWatcher::noop();
|
||||
let root = path("/tmp/skills");
|
||||
watcher.register_skills_root(root.clone());
|
||||
watcher.register_skills_root(root);
|
||||
watcher.register_skills_root(path("/tmp/other-skills"));
|
||||
|
||||
let state = watcher.state.read().expect("state lock");
|
||||
assert_eq!(state.skills_root_ref_counts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_registration_drop_unregisters_roots() {
|
||||
fn register_dedupes_by_path_and_scope() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let root = path("/tmp/skills");
|
||||
watcher.register_skills_root(root.clone());
|
||||
let registration = WatchRegistration {
|
||||
file_watcher: Arc::downgrade(&watcher),
|
||||
roots: vec![root],
|
||||
};
|
||||
let (subscriber, _rx) = watcher.add_subscriber();
|
||||
let _first = subscriber.register_path(path("/tmp/skills"), false);
|
||||
let _second = subscriber.register_path(path("/tmp/skills"), false);
|
||||
let _third = subscriber.register_path(path("/tmp/skills"), true);
|
||||
let _fourth = subscriber.register_path(path("/tmp/other-skills"), true);
|
||||
|
||||
assert_eq!(
|
||||
watcher.watch_counts_for_test(&path("/tmp/skills")),
|
||||
Some((2, 1))
|
||||
);
|
||||
assert_eq!(
|
||||
watcher.watch_counts_for_test(&path("/tmp/other-skills")),
|
||||
Some((0, 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_registration_drop_unregisters_paths() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let (subscriber, _rx) = watcher.add_subscriber();
|
||||
let registration = subscriber.register_path(path("/tmp/skills"), true);
|
||||
|
||||
drop(registration);
|
||||
|
||||
let state = watcher.state.read().expect("state lock");
|
||||
assert_eq!(state.skills_root_ref_counts.len(), 0);
|
||||
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriber_drop_unregisters_paths() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let registration = {
|
||||
let (subscriber, _rx) = watcher.add_subscriber();
|
||||
subscriber.register_path(path("/tmp/skills"), true)
|
||||
};
|
||||
|
||||
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
|
||||
drop(registration);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receiver_closes_when_subscriber_drops() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let (subscriber, mut rx) = watcher.add_subscriber();
|
||||
|
||||
drop(subscriber);
|
||||
|
||||
let closed = timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("closed recv timeout");
|
||||
assert_eq!(closed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_registration_downgrades_to_non_recursive_after_drop() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let root = temp_dir.path().join("watched-dir");
|
||||
std::fs::create_dir(&root).expect("create root");
|
||||
|
||||
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
|
||||
let (subscriber, _rx) = watcher.add_subscriber();
|
||||
let non_recursive = subscriber.register_path(root.clone(), false);
|
||||
let recursive = subscriber.register_path(root.clone(), true);
|
||||
|
||||
{
|
||||
let inner = watcher.inner.as_ref().expect("watcher inner");
|
||||
let inner = inner.lock().expect("inner lock");
|
||||
assert_eq!(
|
||||
inner.watched_paths.get(&root),
|
||||
Some(&RecursiveMode::Recursive)
|
||||
);
|
||||
}
|
||||
|
||||
drop(recursive);
|
||||
|
||||
{
|
||||
let inner = watcher.inner.as_ref().expect("watcher inner");
|
||||
let inner = inner.lock().expect("inner lock");
|
||||
assert_eq!(
|
||||
inner.watched_paths.get(&root),
|
||||
Some(&RecursiveMode::NonRecursive)
|
||||
);
|
||||
}
|
||||
|
||||
drop(non_recursive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_holds_state_lock_until_unwatch_finishes() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let root = temp_dir.path().join("skills");
|
||||
let root = temp_dir.path().join("watched-dir");
|
||||
std::fs::create_dir(&root).expect("create root");
|
||||
|
||||
let watcher = Arc::new(FileWatcher::new(temp_dir.path().to_path_buf()).expect("watcher"));
|
||||
watcher.register_skills_root(root.clone());
|
||||
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
|
||||
let (unregister_subscriber, _unregister_rx) = watcher.add_subscriber();
|
||||
let (register_subscriber, _register_rx) = watcher.add_subscriber();
|
||||
let registration = unregister_subscriber.register_path(root.clone(), true);
|
||||
|
||||
let inner = watcher.inner.as_ref().expect("watcher inner");
|
||||
let inner_guard = inner.lock().expect("inner lock");
|
||||
|
||||
let unregister_watcher = Arc::clone(&watcher);
|
||||
let unregister_root = root.clone();
|
||||
let unregister_thread = std::thread::spawn(move || {
|
||||
unregister_watcher.unregister_roots(&[unregister_root]);
|
||||
drop(registration);
|
||||
});
|
||||
|
||||
let state_lock_observed = (0..100).any(|_| {
|
||||
@@ -172,75 +227,128 @@ fn unregister_holds_state_lock_until_unwatch_finishes() {
|
||||
});
|
||||
assert_eq!(state_lock_observed, true);
|
||||
|
||||
let register_watcher = Arc::clone(&watcher);
|
||||
let register_root = root.clone();
|
||||
let register_thread = std::thread::spawn(move || {
|
||||
register_watcher.register_skills_root(register_root);
|
||||
let registration = register_subscriber.register_path(register_root, false);
|
||||
(register_subscriber, registration)
|
||||
});
|
||||
|
||||
drop(inner_guard);
|
||||
|
||||
unregister_thread.join().expect("unregister join");
|
||||
register_thread.join().expect("register join");
|
||||
let (register_subscriber, non_recursive) = register_thread.join().expect("register join");
|
||||
|
||||
let state = watcher.state.read().expect("state lock");
|
||||
assert_eq!(state.skills_root_ref_counts.get(&root), Some(&1));
|
||||
drop(state);
|
||||
assert_eq!(watcher.watch_counts_for_test(&root), Some((1, 0)));
|
||||
|
||||
let inner = watcher.inner.as_ref().expect("watcher inner");
|
||||
let inner = inner.lock().expect("inner lock");
|
||||
assert_eq!(
|
||||
inner.watched_paths.get(&root),
|
||||
Some(&RecursiveMode::Recursive)
|
||||
Some(&RecursiveMode::NonRecursive)
|
||||
);
|
||||
drop(inner);
|
||||
|
||||
drop(non_recursive);
|
||||
drop(register_subscriber);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_subscribers_are_notified() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let (skills_subscriber, skills_rx) = watcher.add_subscriber();
|
||||
let (plugins_subscriber, plugins_rx) = watcher.add_subscriber();
|
||||
let _skills = skills_subscriber.register_path(path("/tmp/skills"), true);
|
||||
let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), true);
|
||||
let mut skills_rx = ThrottledWatchReceiver::new(skills_rx, TEST_THROTTLE_INTERVAL);
|
||||
let mut plugins_rx = ThrottledWatchReceiver::new(plugins_rx, TEST_THROTTLE_INTERVAL);
|
||||
|
||||
watcher
|
||||
.send_paths_for_test(vec![path("/tmp/skills/rust/SKILL.md")])
|
||||
.await;
|
||||
|
||||
let skills_event = timeout(Duration::from_secs(1), skills_rx.recv())
|
||||
.await
|
||||
.expect("skills change timeout")
|
||||
.expect("skills change");
|
||||
assert_eq!(
|
||||
skills_event,
|
||||
FileWatcherEvent {
|
||||
paths: vec![path("/tmp/skills/rust/SKILL.md")],
|
||||
}
|
||||
);
|
||||
|
||||
let plugins_event = timeout(TEST_THROTTLE_INTERVAL, plugins_rx.recv()).await;
|
||||
assert_eq!(plugins_event.is_err(), true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_recursive_watch_ignores_grandchildren() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let (subscriber, rx) = watcher.add_subscriber();
|
||||
let _registration = subscriber.register_path(path("/tmp/skills"), false);
|
||||
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
|
||||
|
||||
watcher
|
||||
.send_paths_for_test(vec![path("/tmp/skills/nested/SKILL.md")])
|
||||
.await;
|
||||
|
||||
let event = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await;
|
||||
assert_eq!(event.is_err(), true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ancestor_events_notify_child_watches() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let (subscriber, rx) = watcher.add_subscriber();
|
||||
let _registration = subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), false);
|
||||
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
|
||||
|
||||
watcher.send_paths_for_test(vec![path("/tmp/skills")]).await;
|
||||
|
||||
let event = timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("ancestor event timeout")
|
||||
.expect("ancestor event");
|
||||
assert_eq!(
|
||||
event,
|
||||
FileWatcherEvent {
|
||||
paths: vec![path("/tmp/skills")],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_event_loop_flushes_pending_changes_on_shutdown() {
|
||||
let watcher = FileWatcher::noop();
|
||||
let root = path("/tmp/skills");
|
||||
{
|
||||
let mut state = watcher.state.write().expect("state lock");
|
||||
state.skills_root_ref_counts.insert(root.clone(), 1);
|
||||
}
|
||||
|
||||
async fn spawn_event_loop_filters_non_mutating_events() {
|
||||
let watcher = Arc::new(FileWatcher::noop());
|
||||
let (subscriber, rx) = watcher.add_subscriber();
|
||||
let _registration = subscriber.register_path(path("/tmp/skills"), true);
|
||||
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
|
||||
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
|
||||
let (tx, mut rx) = broadcast::channel(8);
|
||||
watcher.spawn_event_loop(raw_rx, Arc::clone(&watcher.state), tx);
|
||||
watcher.spawn_event_loop_for_test(raw_rx);
|
||||
|
||||
raw_tx
|
||||
.send(Ok(notify_event(
|
||||
EventKind::Access(AccessKind::Open(AccessMode::Any)),
|
||||
vec![path("/tmp/skills/SKILL.md")],
|
||||
)))
|
||||
.expect("send access event");
|
||||
let blocked = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await;
|
||||
assert_eq!(blocked.is_err(), true);
|
||||
|
||||
raw_tx
|
||||
.send(Ok(notify_event(
|
||||
EventKind::Create(CreateKind::File),
|
||||
vec![root.join("a/SKILL.md")],
|
||||
vec![path("/tmp/skills/SKILL.md")],
|
||||
)))
|
||||
.expect("send first event");
|
||||
let first = timeout(Duration::from_secs(2), rx.recv())
|
||||
.expect("send create event");
|
||||
let event = timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("first watcher event")
|
||||
.expect("broadcast recv first");
|
||||
.expect("create event timeout")
|
||||
.expect("create event");
|
||||
assert_eq!(
|
||||
first,
|
||||
FileWatcherEvent::SkillsChanged {
|
||||
paths: vec![root.join("a/SKILL.md")]
|
||||
}
|
||||
);
|
||||
|
||||
raw_tx
|
||||
.send(Ok(notify_event(
|
||||
EventKind::Remove(RemoveKind::File),
|
||||
vec![root.join("b/SKILL.md")],
|
||||
)))
|
||||
.expect("send second event");
|
||||
drop(raw_tx);
|
||||
|
||||
let second = timeout(Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("second watcher event")
|
||||
.expect("broadcast recv second");
|
||||
assert_eq!(
|
||||
second,
|
||||
FileWatcherEvent::SkillsChanged {
|
||||
paths: vec![root.join("b/SKILL.md")]
|
||||
event,
|
||||
FileWatcherEvent {
|
||||
paths: vec![path("/tmp/skills/SKILL.md")],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub mod exec;
|
||||
pub mod exec_env;
|
||||
mod exec_policy;
|
||||
pub mod external_agent_config;
|
||||
mod file_watcher;
|
||||
pub mod file_watcher;
|
||||
mod flags;
|
||||
pub mod git_info;
|
||||
mod guardian;
|
||||
@@ -72,6 +72,7 @@ pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod session_startup_prewarm;
|
||||
mod shell_detect;
|
||||
mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
@@ -173,6 +174,7 @@ pub use client_common::Prompt;
|
||||
pub use client_common::REVIEW_PROMPT;
|
||||
pub use client_common::ResponseEvent;
|
||||
pub use client_common::ResponseStream;
|
||||
pub use codex_sandboxing::get_platform_sandbox;
|
||||
pub use compact::content_items_to_text;
|
||||
pub use event_mapping::parse_turn_item;
|
||||
pub use exec_policy::ExecPolicyError;
|
||||
|
||||
@@ -39,7 +39,8 @@ pub(crate) fn list_tool_suggest_discoverable_plugins(
|
||||
.collect::<HashSet<_>>();
|
||||
let marketplaces = plugins_manager
|
||||
.list_marketplaces_for_config(config, &[])
|
||||
.context("failed to list plugin marketplaces for tool suggestions")?;
|
||||
.context("failed to list plugin marketplaces for tool suggestions")?
|
||||
.marketplaces;
|
||||
let Some(curated_marketplace) = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::load_plugin_manifest;
|
||||
use super::manifest::PluginManifestInterface;
|
||||
use super::marketplace::MarketplaceError;
|
||||
use super::marketplace::MarketplaceInterface;
|
||||
use super::marketplace::MarketplaceListError;
|
||||
use super::marketplace::MarketplacePluginAuthPolicy;
|
||||
use super::marketplace::MarketplacePluginPolicy;
|
||||
use super::marketplace::MarketplacePluginSource;
|
||||
@@ -178,6 +179,12 @@ pub struct ConfiguredMarketplacePlugin {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplaceListOutcome {
|
||||
pub marketplaces: Vec<ConfiguredMarketplace>,
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin {
|
||||
pub config_name: String,
|
||||
@@ -996,16 +1003,16 @@ impl PluginsManager {
|
||||
&self,
|
||||
config: &Config,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<ConfiguredMarketplace>, MarketplaceError> {
|
||||
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
return Ok(Vec::new());
|
||||
return Ok(ConfiguredMarketplaceListOutcome::default());
|
||||
}
|
||||
|
||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||
let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?;
|
||||
let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
|
||||
Ok(marketplaces
|
||||
let marketplaces = marketplace_outcome
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.filter_map(|marketplace| {
|
||||
let marketplace_name = marketplace.name.clone();
|
||||
@@ -1043,7 +1050,12 @@ impl PluginsManager {
|
||||
plugins,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
Ok(ConfiguredMarketplaceListOutcome {
|
||||
marketplaces,
|
||||
errors: marketplace_outcome.errors,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_plugin_for_config(
|
||||
@@ -1550,7 +1562,14 @@ fn load_plugin(
|
||||
};
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
loaded_plugin.manifest_name = Some(manifest.name.clone());
|
||||
loaded_plugin.manifest_name = manifest
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|display_name| !display_name.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| 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(
|
||||
|
||||
@@ -1035,7 +1035,8 @@ enabled = false
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
@@ -1130,7 +1131,8 @@ enabled = true
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(marketplaces, Vec::new());
|
||||
}
|
||||
@@ -1177,7 +1179,8 @@ plugins = true
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
@@ -1382,7 +1385,8 @@ plugins = true
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let curated_marketplace = marketplaces
|
||||
.into_iter()
|
||||
@@ -1487,7 +1491,8 @@ enabled = false
|
||||
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let repo_a_marketplace = marketplaces
|
||||
.iter()
|
||||
@@ -1590,7 +1595,8 @@ enabled = true
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
@@ -1733,6 +1739,7 @@ enabled = true
|
||||
let curated_marketplace = manager
|
||||
.list_marketplaces_for_config(&synced_config, &[])
|
||||
.unwrap()
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.unwrap();
|
||||
|
||||
@@ -33,6 +33,18 @@ pub struct Marketplace {
|
||||
pub plugins: Vec<MarketplacePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceListError {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MarketplaceListOutcome {
|
||||
pub marketplaces: Vec<Marketplace>,
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceInterface {
|
||||
pub display_name: Option<String>,
|
||||
@@ -195,7 +207,7 @@ pub fn resolve_marketplace_plugin(
|
||||
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<Marketplace>, MarketplaceError> {
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
@@ -246,23 +258,27 @@ pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Ma
|
||||
fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Result<Vec<Marketplace>, MarketplaceError> {
|
||||
let mut marketplaces = Vec::new();
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
let mut outcome = MarketplaceListOutcome::default();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
match load_marketplace(&marketplace_path) {
|
||||
Ok(marketplace) => marketplaces.push(marketplace),
|
||||
Ok(marketplace) => outcome.marketplaces.push(marketplace),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
error = %err,
|
||||
"skipping marketplace that failed to load"
|
||||
);
|
||||
outcome.errors.push(MarketplaceListError {
|
||||
path: marketplace_path,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(marketplaces)
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths_from_roots(
|
||||
|
||||
@@ -130,7 +130,8 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
@@ -254,7 +255,8 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
@@ -342,7 +344,8 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
@@ -396,7 +399,8 @@ fn list_marketplaces_reads_marketplace_display_name() {
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].interface,
|
||||
@@ -456,12 +460,66 @@ fn list_marketplaces_skips_marketplaces_that_fail_to_load() {
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert_eq!(marketplaces[0].name, "valid-marketplace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_reports_marketplace_load_errors() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let valid_repo_root = tmp.path().join("valid-repo");
|
||||
let invalid_repo_root = tmp.path().join("invalid-repo");
|
||||
|
||||
fs::create_dir_all(valid_repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(invalid_repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
valid_repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "valid-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "valid-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let invalid_marketplace_path =
|
||||
AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap();
|
||||
fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap();
|
||||
|
||||
let outcome = list_marketplaces_with_home(
|
||||
&[
|
||||
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
|
||||
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.marketplaces.len(), 1);
|
||||
assert_eq!(outcome.marketplaces[0].name, "valid-marketplace");
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert_eq!(outcome.errors[0].path, invalid_marketplace_path);
|
||||
assert!(
|
||||
outcome.errors[0]
|
||||
.message
|
||||
.contains("invalid marketplace file"),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -510,7 +568,8 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
@@ -577,7 +636,8 @@ fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
@@ -632,7 +692,8 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplaceListOutcome;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
@@ -40,6 +41,7 @@ pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub use marketplace::MarketplaceError;
|
||||
pub use marketplace::MarketplaceListError;
|
||||
pub use marketplace::MarketplacePluginAuthPolicy;
|
||||
pub use marketplace::MarketplacePluginInstallPolicy;
|
||||
pub use marketplace::MarketplacePluginPolicy;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Skills-specific watcher built on top of the generic [`FileWatcher`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
use crate::file_watcher::FileWatcherSubscriber;
|
||||
use crate::file_watcher::Receiver;
|
||||
use crate::file_watcher::ThrottledWatchReceiver;
|
||||
use crate::file_watcher::WatchPath;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
use crate::skills::SkillsManager;
|
||||
|
||||
#[cfg(not(test))]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
#[cfg(test)]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SkillsWatcherEvent {
|
||||
SkillsChanged { paths: Vec<PathBuf> },
|
||||
}
|
||||
|
||||
pub(crate) struct SkillsWatcher {
|
||||
subscriber: FileWatcherSubscriber,
|
||||
tx: broadcast::Sender<SkillsWatcherEvent>,
|
||||
}
|
||||
|
||||
impl SkillsWatcher {
|
||||
pub(crate) fn new(file_watcher: &Arc<FileWatcher>) -> Self {
|
||||
let (subscriber, rx) = file_watcher.add_subscriber();
|
||||
let (tx, _) = broadcast::channel(128);
|
||||
let skills_watcher = Self {
|
||||
subscriber,
|
||||
tx: tx.clone(),
|
||||
};
|
||||
Self::spawn_event_loop(rx, tx);
|
||||
skills_watcher
|
||||
}
|
||||
|
||||
pub(crate) fn noop() -> Self {
|
||||
Self::new(&Arc::new(FileWatcher::noop()))
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> broadcast::Receiver<SkillsWatcherEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn register_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
skills_manager: &SkillsManager,
|
||||
) -> WatchRegistration {
|
||||
let roots = skills_manager
|
||||
.skill_roots_for_config(config)
|
||||
.into_iter()
|
||||
.map(|root| WatchPath {
|
||||
path: root.path,
|
||||
recursive: true,
|
||||
})
|
||||
.collect();
|
||||
self.subscriber.register_paths(roots)
|
||||
}
|
||||
|
||||
fn spawn_event_loop(rx: Receiver, tx: broadcast::Sender<SkillsWatcherEvent>) {
|
||||
let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL);
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
let _ = tx.send(SkillsWatcherEvent::SkillsChanged { paths: event.paths });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
warn!("skills watcher listener skipped: no Tokio runtime available");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwards_file_watcher_events() {
|
||||
let file_watcher = Arc::new(FileWatcher::noop());
|
||||
let skills_watcher = SkillsWatcher::new(&file_watcher);
|
||||
let mut rx = skills_watcher.subscribe();
|
||||
let _registration = skills_watcher
|
||||
.subscriber
|
||||
.register_path(PathBuf::from("/tmp/skill"), true);
|
||||
|
||||
file_watcher
|
||||
.send_paths_for_test(vec![PathBuf::from("/tmp/skill/SKILL.md")])
|
||||
.await;
|
||||
|
||||
let event = timeout(Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("skills watcher event")
|
||||
.expect("broadcast recv");
|
||||
assert_eq!(
|
||||
event,
|
||||
SkillsWatcherEvent::SkillsChanged {
|
||||
paths: vec![PathBuf::from("/tmp/skill/SKILL.md")],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,12 @@ use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
@@ -54,7 +54,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) skills_manager: Arc<SkillsManager>,
|
||||
pub(crate) plugins_manager: Arc<PluginsManager>,
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
pub(crate) file_watcher: Arc<FileWatcher>,
|
||||
pub(crate) skills_watcher: Arc<SkillsWatcher>,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
pub(crate) network_proxy: Option<StartedNetworkProxy>,
|
||||
pub(crate) network_approval: Arc<NetworkApprovalService>,
|
||||
|
||||
@@ -30,7 +30,6 @@ use crate::hook_runtime::record_additional_contexts;
|
||||
use crate::hook_runtime::record_pending_input;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TurnAbortReason;
|
||||
use crate::protocol::TurnAbortedEvent;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
@@ -192,11 +191,6 @@ impl Session {
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let done = Arc::new(Notify::new());
|
||||
|
||||
let timer = turn_context
|
||||
.session_telemetry
|
||||
.start_timer(TURN_E2E_DURATION_METRIC, &[])
|
||||
.ok();
|
||||
|
||||
let done_clone = Arc::clone(&done);
|
||||
let handle = {
|
||||
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
|
||||
@@ -236,6 +230,21 @@ impl Session {
|
||||
)
|
||||
};
|
||||
|
||||
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
|
||||
let mut active = self.active_turn.lock().await;
|
||||
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 queued_response_items {
|
||||
turn_state.push_pending_input(item);
|
||||
}
|
||||
drop(turn_state);
|
||||
|
||||
let timer = turn_context
|
||||
.session_telemetry
|
||||
.start_timer(TURN_E2E_DURATION_METRIC, &[])
|
||||
.ok();
|
||||
|
||||
let running_task = RunningTask {
|
||||
done,
|
||||
handle: Arc::new(AbortOnDropHandle::new(handle)),
|
||||
@@ -245,8 +254,8 @@ impl Session {
|
||||
turn_context: Arc::clone(&turn_context),
|
||||
_timer: timer,
|
||||
};
|
||||
self.register_new_active_task(running_task, token_usage_at_turn_start)
|
||||
.await;
|
||||
turn.add_task(running_task);
|
||||
*active = Some(turn);
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
|
||||
@@ -406,23 +415,6 @@ impl Session {
|
||||
self.send_event(turn_context.as_ref(), event).await;
|
||||
}
|
||||
|
||||
async fn register_new_active_task(
|
||||
&self,
|
||||
task: RunningTask,
|
||||
token_usage_at_turn_start: TokenUsage,
|
||||
) {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
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);
|
||||
}
|
||||
|
||||
async fn take_active_turn(&self) -> Option<ActiveTurn> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
active.take()
|
||||
|
||||
@@ -63,14 +63,22 @@ impl SessionTask for RegularTask {
|
||||
Some(*prewarmed_client_session)
|
||||
}
|
||||
};
|
||||
run_turn(
|
||||
sess,
|
||||
ctx,
|
||||
input,
|
||||
prewarmed_client_session,
|
||||
cancellation_token,
|
||||
)
|
||||
.instrument(run_turn_span)
|
||||
.await
|
||||
let mut next_input = input;
|
||||
let mut prewarmed_client_session = prewarmed_client_session;
|
||||
loop {
|
||||
let last_agent_message = run_turn(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&ctx),
|
||||
next_input,
|
||||
prewarmed_client_session.take(),
|
||||
cancellation_token.child_token(),
|
||||
)
|
||||
.instrument(run_turn_span.clone())
|
||||
.await;
|
||||
if !sess.has_pending_input().await {
|
||||
return last_agent_message;
|
||||
}
|
||||
next_input = Vec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
use crate::file_watcher::FileWatcherEvent;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
@@ -24,6 +23,8 @@ use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::truncation;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
use codex_app_server_protocol::ThreadHistoryBuilder;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
@@ -83,32 +84,33 @@ impl Drop for TempCodexHomeGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc<SkillsManager>) -> Arc<FileWatcher> {
|
||||
fn build_skills_watcher(skills_manager: Arc<SkillsManager>) -> Arc<SkillsWatcher> {
|
||||
if should_use_test_thread_manager_behavior()
|
||||
&& let Ok(handle) = Handle::try_current()
|
||||
&& handle.runtime_flavor() == RuntimeFlavor::CurrentThread
|
||||
{
|
||||
// The real watcher spins background tasks that can starve the
|
||||
// current-thread test runtime and cause event waits to time out.
|
||||
warn!("using noop file watcher under current-thread test runtime");
|
||||
return Arc::new(FileWatcher::noop());
|
||||
warn!("using noop skills watcher under current-thread test runtime");
|
||||
return Arc::new(SkillsWatcher::noop());
|
||||
}
|
||||
|
||||
let file_watcher = match FileWatcher::new(codex_home) {
|
||||
let file_watcher = match FileWatcher::new() {
|
||||
Ok(file_watcher) => Arc::new(file_watcher),
|
||||
Err(err) => {
|
||||
warn!("failed to initialize file watcher: {err}");
|
||||
Arc::new(FileWatcher::noop())
|
||||
}
|
||||
};
|
||||
let skills_watcher = Arc::new(SkillsWatcher::new(&file_watcher));
|
||||
|
||||
let mut rx = file_watcher.subscribe();
|
||||
let mut rx = skills_watcher.subscribe();
|
||||
let skills_manager = Arc::clone(&skills_manager);
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(FileWatcherEvent::SkillsChanged { .. }) => {
|
||||
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
|
||||
skills_manager.clear_cache();
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
@@ -117,10 +119,10 @@ fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc<SkillsManager>) -
|
||||
}
|
||||
});
|
||||
} else {
|
||||
warn!("file watcher listener skipped: no Tokio runtime available");
|
||||
warn!("skills watcher listener skipped: no Tokio runtime available");
|
||||
}
|
||||
|
||||
file_watcher
|
||||
skills_watcher
|
||||
}
|
||||
|
||||
/// Represents a newly created Codex thread (formerly called a conversation), including the first event
|
||||
@@ -201,7 +203,7 @@ pub(crate) struct ThreadManagerState {
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
session_source: SessionSource,
|
||||
// Captures submitted ops for testing purpose when test mode is enabled.
|
||||
ops_log: Option<SharedCapturedOps>,
|
||||
@@ -233,7 +235,7 @@ impl ThreadManager {
|
||||
config.bundled_skills_enabled(),
|
||||
restriction_product,
|
||||
));
|
||||
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
|
||||
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
|
||||
Self {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -248,7 +250,7 @@ impl ThreadManager {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
auth_manager,
|
||||
session_source,
|
||||
ops_log: should_use_test_thread_manager_behavior()
|
||||
@@ -299,7 +301,7 @@ impl ThreadManager {
|
||||
/*bundled_skills_enabled*/ true,
|
||||
restriction_product,
|
||||
));
|
||||
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
|
||||
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
|
||||
Self {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -312,7 +314,7 @@ impl ThreadManager {
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skills_watcher,
|
||||
auth_manager,
|
||||
session_source: SessionSource::Exec,
|
||||
ops_log: should_use_test_thread_manager_behavior()
|
||||
@@ -342,10 +344,6 @@ impl ThreadManager {
|
||||
self.state.mcp_manager.clone()
|
||||
}
|
||||
|
||||
pub fn subscribe_file_watcher(&self) -> broadcast::Receiver<FileWatcherEvent> {
|
||||
self.state.file_watcher.subscribe()
|
||||
}
|
||||
|
||||
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
|
||||
self.state.models_manager.clone()
|
||||
}
|
||||
@@ -838,7 +836,7 @@ impl ThreadManagerState {
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let watch_registration = self
|
||||
.file_watcher
|
||||
.skills_watcher
|
||||
.register_config(&config, self.skills_manager.as_ref());
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
@@ -849,7 +847,7 @@ impl ThreadManagerState {
|
||||
skills_manager: Arc::clone(&self.skills_manager),
|
||||
plugins_manager: Arc::clone(&self.plugins_manager),
|
||||
mcp_manager: Arc::clone(&self.mcp_manager),
|
||||
file_watcher: Arc::clone(&self.file_watcher),
|
||||
skills_watcher: Arc::clone(&self.skills_watcher),
|
||||
conversation_history: initial_history,
|
||||
session_source,
|
||||
agent_control,
|
||||
|
||||
@@ -21,8 +21,9 @@ use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
|
||||
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;
|
||||
@@ -337,7 +338,7 @@ async fn spawn_agent_errors_when_manager_dropped() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() {
|
||||
async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_path() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
task_name: String,
|
||||
@@ -400,18 +401,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
Some("/root/test_process")
|
||||
);
|
||||
|
||||
SendInputHandlerV2
|
||||
SendMessageHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"send_input",
|
||||
"send_message",
|
||||
function_payload(json!({
|
||||
"target": "test_process",
|
||||
"message": "continue"
|
||||
"items": [{"type": "text", "text": "continue"}]
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("send_input should accept v2 path");
|
||||
.expect("send_message should accept v2 path");
|
||||
|
||||
assert!(manager.captured_ops().iter().any(|(id, op)| {
|
||||
*id == child_thread_id
|
||||
@@ -422,6 +423,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
&& communication.recipient.as_str() == "/root/test_process"
|
||||
&& communication.other_recipients.is_empty()
|
||||
&& communication.content == "continue"
|
||||
&& !communication.trigger_turn
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -647,7 +649,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
async fn multi_agent_v2_send_message_rejects_structured_items() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
@@ -683,7 +685,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
let invocation = invocation(
|
||||
session,
|
||||
turn,
|
||||
"send_input",
|
||||
"send_message",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"items": [
|
||||
@@ -693,33 +695,19 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
})),
|
||||
);
|
||||
|
||||
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 Err(err) = SendMessageHandlerV2.handle(invocation).await else {
|
||||
panic!("structured items should be rejected in v2");
|
||||
};
|
||||
let captured = manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|(id, op)| *id == agent_id && *op == expected);
|
||||
assert_eq!(captured, Some((agent_id, expected)));
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"send_message only supports text content in MultiAgentV2 for now".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() {
|
||||
async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_turn() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
@@ -771,19 +759,161 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
)
|
||||
.await;
|
||||
|
||||
SendInputHandlerV2
|
||||
SendMessageHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"send_input",
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"send_message",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"message": "continue",
|
||||
"items": [{"type": "text", "text": "continue"}],
|
||||
"interrupt": true
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("interrupting v2 send_input should succeed");
|
||||
.expect("interrupting v2 send_message 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::InterAgentCommunication { communication }
|
||||
if communication.author == AgentPath::root()
|
||||
&& communication.recipient.as_str() == "/root/worker"
|
||||
&& communication.other_recipients.is_empty()
|
||||
&& communication.content == "continue"
|
||||
&& !communication.trigger_turn
|
||||
)
|
||||
}));
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if !thread
|
||||
.codex
|
||||
.session
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
continue;
|
||||
}
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let saw_envelope = history_contains_inter_agent_communication(
|
||||
&history_items,
|
||||
&InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
false,
|
||||
),
|
||||
);
|
||||
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 {
|
||||
panic!("send_message should not materialize the envelope into history");
|
||||
}
|
||||
if !saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("interrupting v2 send_message should queue the redirected message without a turn");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_assign_task_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;
|
||||
|
||||
AssignTaskHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"assign_task",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"items": [{"type": "text", "text": "continue"}],
|
||||
"interrupt": true
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("interrupting v2 assign_task should succeed");
|
||||
|
||||
let ops = manager.captured_ops();
|
||||
let ops_for_agent: Vec<&Op> = ops
|
||||
@@ -801,14 +931,6 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
&& communication.content == "continue"
|
||||
)
|
||||
}));
|
||||
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 {
|
||||
@@ -826,6 +948,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
@@ -846,7 +969,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("interrupting v2 send_input should preserve the redirected message");
|
||||
.expect("interrupting v2 assign_task should preserve the redirected message");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
|
||||
@@ -30,12 +30,15 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) use assign_task::Handler as AssignTaskHandler;
|
||||
pub(crate) use list_agents::Handler as ListAgentsHandler;
|
||||
pub(crate) use send_input::Handler as SendInputHandler;
|
||||
pub(crate) use send_message::Handler as SendMessageHandler;
|
||||
pub(crate) use spawn::Handler as SpawnAgentHandler;
|
||||
pub(crate) use wait::Handler as WaitAgentHandler;
|
||||
|
||||
mod assign_task;
|
||||
mod list_agents;
|
||||
mod send_input;
|
||||
mod message_tool;
|
||||
mod send_message;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
use super::message_tool::MessageDeliveryMode;
|
||||
use super::message_tool::MessageToolResult;
|
||||
use super::message_tool::handle_message_tool;
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = MessageToolResult;
|
||||
|
||||
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> {
|
||||
handle_message_tool(invocation, MessageDeliveryMode::TriggerTurn).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
|
||||
//!
|
||||
//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in
|
||||
//! whether the resulting `InterAgentCommunication` should wake the target immediately.
|
||||
|
||||
use super::*;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum MessageDeliveryMode {
|
||||
QueueOnly,
|
||||
TriggerTurn,
|
||||
}
|
||||
|
||||
impl MessageDeliveryMode {
|
||||
/// Returns the model-visible error message for non-text inputs.
|
||||
fn unsupported_items_error(self) -> &'static str {
|
||||
match self {
|
||||
Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now",
|
||||
Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the produced communication should start a turn immediately.
|
||||
fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication {
|
||||
match self {
|
||||
Self::QueueOnly => InterAgentCommunication {
|
||||
trigger_turn: false,
|
||||
..communication
|
||||
},
|
||||
Self::TriggerTurn => InterAgentCommunication {
|
||||
trigger_turn: true,
|
||||
..communication
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
/// Input shared by the MultiAgentV2 `send_message` and `assign_task` tools.
|
||||
pub(crate) struct MessageToolArgs {
|
||||
pub(crate) target: String,
|
||||
pub(crate) items: Vec<UserInput>,
|
||||
#[serde(default)]
|
||||
pub(crate) interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Tool result shared by the MultiAgentV2 message-delivery tools.
|
||||
pub(crate) struct MessageToolResult {
|
||||
submission_id: String,
|
||||
}
|
||||
|
||||
impl ToolOutput for MessageToolResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "multi_agent_message")
|
||||
}
|
||||
|
||||
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), "multi_agent_message")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "multi_agent_message")
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the tool input is non-empty text-only content and returns its preview string.
|
||||
fn text_content(
|
||||
items: &[UserInput],
|
||||
mode: MessageDeliveryMode,
|
||||
) -> Result<String, FunctionCallError> {
|
||||
if items.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Items can't be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if items
|
||||
.iter()
|
||||
.all(|item| matches!(item, UserInput::Text { .. }))
|
||||
{
|
||||
return Ok(input_preview(items));
|
||||
}
|
||||
Err(FunctionCallError::RespondToModel(
|
||||
mode.unsupported_items_error().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`.
|
||||
pub(crate) async fn handle_message_tool(
|
||||
invocation: ToolInvocation,
|
||||
mode: MessageDeliveryMode,
|
||||
) -> Result<MessageToolResult, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: MessageToolArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
|
||||
let prompt = text_content(&args.items, mode)?;
|
||||
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 receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
|
||||
})?;
|
||||
let communication = InterAgentCommunication::new(
|
||||
turn.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root),
|
||||
receiver_agent_path,
|
||||
Vec::new(),
|
||||
prompt.clone(),
|
||||
/*trigger_turn*/ true,
|
||||
);
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication))
|
||||
.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(MessageToolResult { submission_id })
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
|
||||
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 communication = InterAgentCommunication::new(
|
||||
turn.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root),
|
||||
receiver_agent_path,
|
||||
Vec::new(),
|
||||
prompt.clone(),
|
||||
);
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(receiver_thread_id, communication)
|
||||
.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,23 @@
|
||||
use super::message_tool::MessageDeliveryMode;
|
||||
use super::message_tool::MessageToolResult;
|
||||
use super::message_tool::handle_message_tool;
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = MessageToolResult;
|
||||
|
||||
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> {
|
||||
handle_message_tool(invocation, MessageDeliveryMode::QueueOnly).await
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,7 @@ fn verified_plugin_suggestion_completed(
|
||||
.list_marketplaces_for_config(config, &[])
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|outcome| outcome.marketplaces)
|
||||
.flat_map(|marketplace| marketplace.plugins.into_iter())
|
||||
.any(|plugin| plugin.id == tool_id && plugin.installed)
|
||||
}
|
||||
|
||||
@@ -1448,6 +1448,80 @@ fn create_send_input_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_send_message_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"target".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
("items".to_string(), create_collab_input_items_schema()),
|
||||
(
|
||||
"interrupt".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "send_message".to_string(),
|
||||
description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["target".to_string(), "items".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(send_input_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_assign_task_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"target".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
("items".to_string(), create_collab_input_items_schema()),
|
||||
(
|
||||
"interrupt".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "assign_task".to_string(),
|
||||
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["target".to_string(), "items".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(send_input_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_resume_agent_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
@@ -2695,8 +2769,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::AssignTaskHandler as AssignTaskHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
use std::sync::Arc;
|
||||
@@ -3085,10 +3160,22 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_send_input_tool(),
|
||||
if config.multi_agent_v2 {
|
||||
create_send_message_tool()
|
||||
} else {
|
||||
create_send_input_tool()
|
||||
},
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
if config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_assign_task_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
}
|
||||
if !config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
@@ -3122,7 +3209,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandlerV2));
|
||||
builder.register_handler("send_message", Arc::new(SendMessageHandlerV2));
|
||||
builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
|
||||
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
|
||||
} else {
|
||||
|
||||
@@ -550,7 +550,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
&tools,
|
||||
&[
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
"send_message",
|
||||
"assign_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
@@ -584,9 +585,9 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
json!(["agent_id", "task_name", "nickname"])
|
||||
);
|
||||
|
||||
let send_input = find_tool(&tools, "send_input");
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_input.spec else {
|
||||
panic!("send_input should be a function tool");
|
||||
let send_message = find_tool(&tools, "send_message");
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else {
|
||||
panic!("send_message should be a function tool");
|
||||
};
|
||||
let JsonSchema::Object {
|
||||
properties,
|
||||
@@ -594,10 +595,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
..
|
||||
} = parameters
|
||||
else {
|
||||
panic!("send_input should use object params");
|
||||
panic!("send_message should use object params");
|
||||
};
|
||||
assert!(properties.contains_key("target"));
|
||||
assert_eq!(required.as_ref(), Some(&vec!["target".to_string()]));
|
||||
assert!(!properties.contains_key("message"));
|
||||
assert_eq!(
|
||||
required.as_ref(),
|
||||
Some(&vec!["target".to_string(), "items".to_string()])
|
||||
);
|
||||
|
||||
let assign_task = find_tool(&tools, "assign_task");
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec else {
|
||||
panic!("assign_task should be a function tool");
|
||||
};
|
||||
let JsonSchema::Object {
|
||||
properties,
|
||||
required,
|
||||
..
|
||||
} = parameters
|
||||
else {
|
||||
panic!("assign_task should use object params");
|
||||
};
|
||||
assert!(properties.contains_key("target"));
|
||||
assert!(!properties.contains_key("message"));
|
||||
assert_eq!(
|
||||
required.as_ref(),
|
||||
Some(&vec!["target".to_string(), "items".to_string()])
|
||||
);
|
||||
|
||||
let wait_agent = find_tool(&tools, "wait_agent");
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
@@ -652,6 +676,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
output_schema["properties"]["agents"]["items"]["required"],
|
||||
json!(["agent_name", "agent_status", "last_task_message"])
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "send_input");
|
||||
assert_lacks_tool_name(&tools, "resume_agent");
|
||||
}
|
||||
|
||||
|
||||
@@ -294,13 +294,14 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let ctx = RefreshTokenTestContext::new(&server)?;
|
||||
let initial_last_refresh = Utc::now() - Duration::days(1);
|
||||
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
||||
let stale_refresh = Utc::now() - Duration::days(9);
|
||||
let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::hours(1));
|
||||
let initial_tokens = build_tokens(&fresh_access_token, INITIAL_REFRESH_TOKEN);
|
||||
let initial_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
last_refresh: Some(stale_refresh),
|
||||
};
|
||||
ctx.write_auth(&initial_auth)?;
|
||||
|
||||
@@ -325,7 +326,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
|
||||
async fn refreshes_token_when_access_token_is_expired() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
@@ -340,13 +341,14 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
|
||||
.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 fresh_refresh = Utc::now() - Duration::days(1);
|
||||
let expired_access_token = access_token_with_expiration(Utc::now() - Duration::hours(1));
|
||||
let initial_tokens = build_tokens(&expired_access_token, INITIAL_REFRESH_TOKEN);
|
||||
let initial_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(initial_tokens.clone()),
|
||||
last_refresh: Some(stale_refresh),
|
||||
last_refresh: Some(fresh_refresh),
|
||||
};
|
||||
ctx.write_auth(&initial_auth)?;
|
||||
|
||||
@@ -373,7 +375,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
|
||||
.as_ref()
|
||||
.context("last_refresh should be recorded")?;
|
||||
assert!(
|
||||
*refreshed_at >= stale_refresh,
|
||||
*refreshed_at >= fresh_refresh,
|
||||
"last_refresh should advance"
|
||||
);
|
||||
|
||||
@@ -543,6 +545,153 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn refresh_token_does_not_retry_after_permanent_failure() -> 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_reused"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ctx = RefreshTokenTestContext::new(&server)?;
|
||||
let initial_last_refresh = Utc::now() - Duration::days(1);
|
||||
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.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
};
|
||||
ctx.write_auth(&initial_auth)?;
|
||||
|
||||
let first_err = ctx
|
||||
.auth_manager
|
||||
.refresh_token()
|
||||
.await
|
||||
.err()
|
||||
.context("first refresh should fail")?;
|
||||
assert_eq!(
|
||||
first_err.failed_reason(),
|
||||
Some(RefreshTokenFailedReason::Exhausted)
|
||||
);
|
||||
|
||||
let second_err = ctx
|
||||
.auth_manager
|
||||
.refresh_token()
|
||||
.await
|
||||
.err()
|
||||
.context("second refresh should fail without retrying")?;
|
||||
assert_eq!(
|
||||
second_err.failed_reason(),
|
||||
Some(RefreshTokenFailedReason::Exhausted)
|
||||
);
|
||||
|
||||
let stored = ctx.load_auth()?;
|
||||
assert_eq!(stored, initial_auth);
|
||||
let cached_auth = ctx
|
||||
.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.context("auth should remain cached")?;
|
||||
let cached = cached_auth
|
||||
.get_token_data()
|
||||
.context("token data should remain cached")?;
|
||||
assert_eq!(cached, initial_tokens);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> 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_reused"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ctx = RefreshTokenTestContext::new(&server)?;
|
||||
let initial_last_refresh = Utc::now() - Duration::days(1);
|
||||
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.clone()),
|
||||
last_refresh: Some(initial_last_refresh),
|
||||
};
|
||||
ctx.write_auth(&initial_auth)?;
|
||||
|
||||
let first_err = ctx
|
||||
.auth_manager
|
||||
.refresh_token()
|
||||
.await
|
||||
.err()
|
||||
.context("first refresh should fail")?;
|
||||
assert_eq!(
|
||||
first_err.failed_reason(),
|
||||
Some(RefreshTokenFailedReason::Exhausted)
|
||||
);
|
||||
|
||||
let fresh_refresh = Utc::now() - Duration::hours(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,
|
||||
)?;
|
||||
|
||||
ctx.auth_manager
|
||||
.refresh_token()
|
||||
.await
|
||||
.context("refresh should reload changed auth without retrying")?;
|
||||
|
||||
let stored = ctx.load_auth()?;
|
||||
assert_eq!(stored, disk_auth);
|
||||
|
||||
let cached_auth = ctx
|
||||
.auth_manager
|
||||
.auth_cached()
|
||||
.context("auth should be cached")?;
|
||||
let cached = cached_auth
|
||||
.get_token_data()
|
||||
.context("token data should reload from disk")?;
|
||||
assert_eq!(cached, disk_tokens);
|
||||
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"expected only the initial refresh request"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(auth_refresh)]
|
||||
#[tokio::test]
|
||||
async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> {
|
||||
@@ -867,7 +1016,7 @@ impl Drop for EnvGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_jwt() -> String {
|
||||
fn jwt_with_payload(payload: serde_json::Value) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
@@ -878,7 +1027,6 @@ fn minimal_jwt() -> String {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = json!({ "sub": "user-123" });
|
||||
|
||||
fn b64(data: &[u8]) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
|
||||
@@ -898,6 +1046,14 @@ fn minimal_jwt() -> String {
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
fn minimal_jwt() -> String {
|
||||
jwt_with_payload(json!({ "sub": "user-123" }))
|
||||
}
|
||||
|
||||
fn access_token_with_expiration(expires_at: chrono::DateTime<Utc>) -> String {
|
||||
jwt_with_payload(json!({ "sub": "user-123", "exp": expires_at.timestamp() }))
|
||||
}
|
||||
|
||||
fn build_tokens(access_token: &str, refresh_token: &str) -> TokenData {
|
||||
let id_token = IdTokenInfo {
|
||||
raw_jwt: minimal_jwt(),
|
||||
|
||||
@@ -310,10 +310,10 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 1. Arrange mocked SSE responses for the initial flow plus the second compact.
|
||||
// 1. Arrange mocked SSE responses as a single ordered stream so assertions
|
||||
// observe the real request sequence instead of per-mock duplicate captures.
|
||||
let server = MockServer::start().await;
|
||||
let mut request_log = mount_initial_flow(&server).await;
|
||||
request_log.extend(mount_second_compact_flow(&server).await);
|
||||
let request_log = mount_second_compact_sequence(&server).await;
|
||||
|
||||
// 2. Drive the conversation through compact -> resume -> fork -> compact -> resume.
|
||||
let (_home, config, manager, base) = start_test_conversation(&server, None).await;
|
||||
@@ -349,7 +349,12 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
let resumed_again = resume_conversation(&manager, &config, forked_path).await;
|
||||
user_turn(&resumed_again, AFTER_SECOND_RESUME).await;
|
||||
|
||||
let mut requests = gather_request_bodies(&request_log);
|
||||
let mut requests = request_log
|
||||
.requests()
|
||||
.into_iter()
|
||||
.map(|request| request.body_json())
|
||||
.collect::<Vec<_>>();
|
||||
requests.iter_mut().for_each(normalize_line_endings);
|
||||
normalize_compact_prompts(&mut requests);
|
||||
let input_after_compact = json!(requests[requests.len() - 2]["input"]);
|
||||
let input_after_resume = json!(requests[requests.len() - 1]["input"]);
|
||||
@@ -382,20 +387,18 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
);
|
||||
let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index];
|
||||
let summary_after_second_compact =
|
||||
extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT);
|
||||
extract_summary_user_text(&requests[requests.len() - 2], SUMMARY_TEXT);
|
||||
let mut expected_after_second_compact_user_texts = vec![
|
||||
"hello world".to_string(),
|
||||
"AFTER_COMPACT".to_string(),
|
||||
"AFTER_RESUME".to_string(),
|
||||
"AFTER_FORK".to_string(),
|
||||
summary_after_second_compact,
|
||||
summary_after_second_compact.clone(),
|
||||
];
|
||||
expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix);
|
||||
expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string());
|
||||
let mut expected_fork_local_user_texts = vec![
|
||||
"AFTER_FORK".to_string(),
|
||||
expected_after_second_compact_user_texts[4].clone(),
|
||||
];
|
||||
let mut expected_fork_local_user_texts =
|
||||
vec!["AFTER_FORK".to_string(), summary_after_second_compact];
|
||||
expected_fork_local_user_texts.extend_from_slice(seeded_user_prefix);
|
||||
expected_fork_local_user_texts.push("AFTER_COMPACT_2".to_string());
|
||||
let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user");
|
||||
@@ -403,11 +406,16 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
|
||||
.split_last()
|
||||
.unwrap_or_else(|| panic!("after-second-resume request missing user messages"));
|
||||
assert_eq!(final_last, AFTER_SECOND_RESUME);
|
||||
let matched_prefix_len = if final_prefix.starts_with(&expected_after_second_compact_user_texts)
|
||||
let matched_prefix_len = if let Some(start) = final_prefix
|
||||
.windows(expected_after_second_compact_user_texts.len())
|
||||
.position(|window| window == expected_after_second_compact_user_texts)
|
||||
{
|
||||
expected_after_second_compact_user_texts.len()
|
||||
} else if final_prefix.starts_with(&expected_fork_local_user_texts) {
|
||||
expected_fork_local_user_texts.len()
|
||||
start + expected_after_second_compact_user_texts.len()
|
||||
} else if let Some(start) = final_prefix
|
||||
.windows(expected_fork_local_user_texts.len())
|
||||
.position(|window| window == expected_fork_local_user_texts)
|
||||
{
|
||||
start + expected_fork_local_user_texts.len()
|
||||
} else {
|
||||
panic!("after-second-resume user texts should preserve post-compact user history prefix");
|
||||
};
|
||||
@@ -522,8 +530,9 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
/// Scenario: rolling back a turn that introduced persistent pre-turn context
|
||||
/// diffs currently duplicates those context updates on the next request.
|
||||
async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> {
|
||||
/// diffs should trim those context updates so the next request includes them
|
||||
/// only once.
|
||||
async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> {
|
||||
if network_disabled() {
|
||||
println!("Skipping test because network is disabled in this sandbox");
|
||||
return Ok(());
|
||||
@@ -602,14 +611,12 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let before_rollback_developer_count = requests[1]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count();
|
||||
assert_eq!(before_rollback_developer_count, 1);
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.message_input_texts("user")
|
||||
@@ -618,14 +625,13 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests[2]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
let after_rollback_developer_count = requests[2]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count();
|
||||
assert_eq!(after_rollback_developer_count, 1);
|
||||
|
||||
let after_rollback_user_texts = requests[2].message_input_texts("user");
|
||||
assert_eq!(
|
||||
@@ -633,7 +639,7 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
|
||||
.iter()
|
||||
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
|
||||
.count(),
|
||||
2
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
after_rollback_user_texts.last().map(String::as_str),
|
||||
@@ -641,9 +647,9 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(
|
||||
"rollback_followup_turn_duplicates_context_updates",
|
||||
"rollback_followup_turn_trims_context_updates",
|
||||
context_snapshot::format_labeled_requests_snapshot(
|
||||
"rollback currently duplicates pre-turn override context updates on the follow-up request",
|
||||
"rollback trims pre-turn override context updates before the follow-up request",
|
||||
&[
|
||||
("rolled-back turn request", &requests[1]),
|
||||
("follow-up request after rollback", &requests[2]),
|
||||
@@ -749,33 +755,29 @@ async fn mount_initial_flow(server: &MockServer) -> Vec<ResponseMock> {
|
||||
vec![first, compact, after_compact, after_resume, after_fork]
|
||||
}
|
||||
|
||||
async fn mount_second_compact_flow(server: &MockServer) -> Vec<ResponseMock> {
|
||||
async fn mount_second_compact_sequence(server: &MockServer) -> ResponseMock {
|
||||
let sse1 = sse(vec![
|
||||
ev_assistant_message("m1", FIRST_REPLY),
|
||||
ev_completed("r1"),
|
||||
]);
|
||||
let sse2 = sse(vec![
|
||||
ev_assistant_message("m2", SUMMARY_TEXT),
|
||||
ev_completed("r2"),
|
||||
]);
|
||||
let sse3 = sse(vec![
|
||||
ev_assistant_message("m3", "AFTER_COMPACT_REPLY"),
|
||||
ev_completed("r3"),
|
||||
]);
|
||||
let sse4 = sse(vec![ev_completed("r4")]);
|
||||
let sse5 = sse(vec![ev_completed("r5")]);
|
||||
let sse6 = sse(vec![
|
||||
ev_assistant_message("m4", SUMMARY_TEXT),
|
||||
ev_completed("r6"),
|
||||
]);
|
||||
let sse7 = sse(vec![ev_completed("r7")]);
|
||||
let sse8 = sse(vec![ev_completed("r8")]);
|
||||
|
||||
// Keep this matcher broad enough to survive prompt-shape differences across
|
||||
// platforms/config (history may include either marker text or compact prompt
|
||||
// fragments), but explicitly exclude the final resume turn so these two
|
||||
// one-shot mocks cannot race for the same request.
|
||||
let match_second_compact = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
(body.contains("AFTER_FORK")
|
||||
|| body_contains_text(body, SUMMARIZATION_PROMPT)
|
||||
|| body.contains(&json_fragment(FIRST_REPLY)))
|
||||
&& !body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\""))
|
||||
};
|
||||
let second_compact = mount_sse_once_match(server, match_second_compact, sse6).await;
|
||||
|
||||
let match_after_second_resume = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\""))
|
||||
};
|
||||
let after_second_resume = mount_sse_once_match(server, match_after_second_resume, sse7).await;
|
||||
|
||||
vec![second_compact, after_second_resume]
|
||||
mount_sse_sequence(server, vec![sse1, sse2, sse3, sse4, sse5, sse6, sse7, sse8]).await
|
||||
}
|
||||
|
||||
async fn start_test_conversation(
|
||||
|
||||
+3
-5
@@ -1,8 +1,8 @@
|
||||
---
|
||||
source: core/tests/suite/compact_resume_fork.rs
|
||||
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
|
||||
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback trims pre-turn override context updates before the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
|
||||
---
|
||||
Scenario: rollback currently duplicates pre-turn override context updates on the follow-up request
|
||||
Scenario: rollback trims pre-turn override context updates before the follow-up request
|
||||
|
||||
## rolled-back turn request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
@@ -20,6 +20,4 @@ Scenario: rollback currently duplicates pre-turn override context updates on the
|
||||
03:message/assistant:turn 1 assistant
|
||||
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
06:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
07:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
08:message/user:follow-up user
|
||||
06:message/user:follow-up user
|
||||
+1
-3
@@ -23,6 +23,4 @@ Scenario: rollback past compaction replay after rollback
|
||||
01:message/user:<COMPACTION_SUMMARY>\nSUMMARY_ONLY_CONTEXT
|
||||
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
04:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
05:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
06:message/user:AFTER_ROLLBACK
|
||||
04:message/user:AFTER_ROLLBACK
|
||||
|
||||
@@ -197,6 +197,49 @@ fn unauthorized_recovery_reports_mode_and_step_names() {
|
||||
assert_eq!(external.step_name(), "external_refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: Some("pro".to_string()),
|
||||
chatgpt_account_id: Some("org_mine".to_string()),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth available");
|
||||
let mut updated_auth_dot_json = auth
|
||||
.get_current_auth_json()
|
||||
.expect("AuthDotJson should exist");
|
||||
let updated_tokens = updated_auth_dot_json
|
||||
.tokens
|
||||
.as_mut()
|
||||
.expect("tokens should exist");
|
||||
updated_tokens.access_token = "new-access-token".to_string();
|
||||
updated_tokens.refresh_token = "new-refresh-token".to_string();
|
||||
let updated_auth = CodexAuth::from_auth_dot_json(
|
||||
codex_home.path(),
|
||||
updated_auth_dot_json,
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("updated auth should parse");
|
||||
|
||||
let manager = AuthManager::from_auth_for_testing(auth.clone());
|
||||
let error = RefreshTokenFailedError::new(
|
||||
RefreshTokenFailedReason::Exhausted,
|
||||
"refresh token already used",
|
||||
);
|
||||
manager.record_permanent_refresh_failure_if_unchanged(&auth, &error);
|
||||
|
||||
assert_eq!(manager.refresh_failure_for_auth(&auth), Some(error));
|
||||
assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None);
|
||||
}
|
||||
|
||||
struct AuthFileParams {
|
||||
openai_api_key: Option<String>,
|
||||
chatgpt_plan_type: Option<String>,
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::token_data::KnownPlan as InternalKnownPlan;
|
||||
use crate::token_data::PlanType as InternalPlanType;
|
||||
use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_chatgpt_jwt_claims;
|
||||
use crate::token_data::parse_jwt_expiration;
|
||||
use codex_client::CodexHttpClient;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use serde_json::Value;
|
||||
@@ -69,7 +70,6 @@ impl PartialEq for CodexAuth {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(pakrym): use token exp field to check for expiration instead
|
||||
const TOKEN_REFRESH_INTERVAL: i64 = 8;
|
||||
|
||||
const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.";
|
||||
@@ -796,6 +796,15 @@ struct CachedAuth {
|
||||
auth: Option<CodexAuth>,
|
||||
/// Callback used to refresh external auth by asking the parent app for new tokens.
|
||||
external_refresher: Option<Arc<dyn ExternalAuthRefresher>>,
|
||||
/// Permanent refresh failure cached for the current auth snapshot so
|
||||
/// later refresh attempts for the same credentials fail fast without network.
|
||||
permanent_refresh_failure: Option<AuthScopedRefreshFailure>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AuthScopedRefreshFailure {
|
||||
auth: CodexAuth,
|
||||
error: RefreshTokenFailedError,
|
||||
}
|
||||
|
||||
impl Debug for CachedAuth {
|
||||
@@ -809,6 +818,13 @@ impl Debug for CachedAuth {
|
||||
"external_refresher",
|
||||
&self.external_refresher.as_ref().map(|_| "present"),
|
||||
)
|
||||
.field(
|
||||
"permanent_refresh_failure",
|
||||
&self
|
||||
.permanent_refresh_failure
|
||||
.as_ref()
|
||||
.map(|failure| failure.error.reason),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1046,6 +1062,7 @@ impl AuthManager {
|
||||
inner: RwLock::new(CachedAuth {
|
||||
auth: managed_auth,
|
||||
external_refresher: None,
|
||||
permanent_refresh_failure: None,
|
||||
}),
|
||||
enable_codex_api_key_env,
|
||||
auth_credentials_store_mode,
|
||||
@@ -1058,6 +1075,7 @@ impl AuthManager {
|
||||
let cached = CachedAuth {
|
||||
auth: Some(auth),
|
||||
external_refresher: None,
|
||||
permanent_refresh_failure: None,
|
||||
};
|
||||
|
||||
Arc::new(Self {
|
||||
@@ -1074,6 +1092,7 @@ impl AuthManager {
|
||||
let cached = CachedAuth {
|
||||
auth: Some(auth),
|
||||
external_refresher: None,
|
||||
permanent_refresh_failure: None,
|
||||
};
|
||||
Arc::new(Self {
|
||||
codex_home,
|
||||
@@ -1089,6 +1108,16 @@ impl AuthManager {
|
||||
self.inner.read().ok().and_then(|c| c.auth.clone())
|
||||
}
|
||||
|
||||
pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option<RefreshTokenFailedError> {
|
||||
self.inner.read().ok().and_then(|cached| {
|
||||
cached
|
||||
.permanent_refresh_failure
|
||||
.as_ref()
|
||||
.filter(|failure| Self::auths_equal_for_refresh(Some(auth), Some(&failure.auth)))
|
||||
.map(|failure| failure.error.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Current cached auth (clone). May be `None` if not logged in or load failed.
|
||||
/// For stale managed ChatGPT auth, first performs a guarded reload and then
|
||||
/// refreshes only if the on-disk auth is unchanged.
|
||||
@@ -1166,6 +1195,25 @@ impl AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a permanent refresh failure only if the failed refresh was
|
||||
/// attempted against the auth snapshot that is still cached.
|
||||
fn record_permanent_refresh_failure_if_unchanged(
|
||||
&self,
|
||||
attempted_auth: &CodexAuth,
|
||||
error: &RefreshTokenFailedError,
|
||||
) {
|
||||
if let Ok(mut guard) = self.inner.write() {
|
||||
let current_auth_matches =
|
||||
Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref());
|
||||
if current_auth_matches {
|
||||
guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure {
|
||||
auth: attempted_auth.clone(),
|
||||
error: error.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_auth_from_storage(&self) -> Option<CodexAuth> {
|
||||
load_auth(
|
||||
&self.codex_home,
|
||||
@@ -1180,6 +1228,11 @@ impl AuthManager {
|
||||
if let Ok(mut guard) = self.inner.write() {
|
||||
let previous = guard.auth.as_ref();
|
||||
let changed = !AuthManager::auths_equal(previous, new_auth.as_ref());
|
||||
let auth_changed_for_refresh =
|
||||
!Self::auths_equal_for_refresh(previous, new_auth.as_ref());
|
||||
if auth_changed_for_refresh {
|
||||
guard.permanent_refresh_failure = None;
|
||||
}
|
||||
tracing::info!("Reloaded auth, changed: {changed}");
|
||||
guard.auth = new_auth;
|
||||
changed
|
||||
@@ -1255,6 +1308,12 @@ impl AuthManager {
|
||||
/// token is the same as the cached, then ask the token authority to refresh.
|
||||
pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> {
|
||||
let auth_before_reload = self.auth_cached();
|
||||
if auth_before_reload
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_api_key_auth)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let expected_account_id = auth_before_reload
|
||||
.as_ref()
|
||||
.and_then(CodexAuth::get_account_id);
|
||||
@@ -1285,7 +1344,12 @@ impl AuthManager {
|
||||
Some(auth) => auth,
|
||||
None => return Ok(()),
|
||||
};
|
||||
match auth {
|
||||
if let Some(error) = self.refresh_failure_for_auth(&auth) {
|
||||
return Err(RefreshTokenError::Permanent(error));
|
||||
}
|
||||
|
||||
let attempted_auth = auth.clone();
|
||||
let result = match auth {
|
||||
CodexAuth::ChatgptAuthTokens(_) => {
|
||||
self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
|
||||
.await
|
||||
@@ -1297,11 +1361,14 @@ impl AuthManager {
|
||||
))
|
||||
})?;
|
||||
self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
|
||||
.await?;
|
||||
Ok(())
|
||||
.await
|
||||
}
|
||||
CodexAuth::ApiKey(_) => Ok(()),
|
||||
};
|
||||
if let Err(RefreshTokenError::Permanent(error)) = &result {
|
||||
self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true)
|
||||
@@ -1333,6 +1400,11 @@ impl AuthManager {
|
||||
Some(auth_dot_json) => auth_dot_json,
|
||||
None => return false,
|
||||
};
|
||||
if let Some(tokens) = auth_dot_json.tokens.as_ref()
|
||||
&& let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token)
|
||||
{
|
||||
return expires_at <= Utc::now();
|
||||
}
|
||||
let last_refresh = match auth_dot_json.last_refresh {
|
||||
Some(last_refresh) => last_refresh,
|
||||
None => return false,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use base64::Engine;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
|
||||
@@ -117,6 +120,12 @@ struct AuthClaims {
|
||||
chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StandardJwtClaims {
|
||||
#[serde(default)]
|
||||
exp: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IdTokenInfoError {
|
||||
#[error("invalid ID token format")]
|
||||
@@ -127,7 +136,7 @@ pub enum IdTokenInfoError {
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
|
||||
fn decode_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T, IdTokenInfoError> {
|
||||
// JWT format: header.payload.signature
|
||||
let mut parts = jwt.split('.');
|
||||
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
|
||||
@@ -136,7 +145,19 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoErr
|
||||
};
|
||||
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
|
||||
let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
|
||||
let claims = serde_json::from_slice(&payload_bytes)?;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
pub fn parse_jwt_expiration(jwt: &str) -> Result<Option<DateTime<Utc>>, IdTokenInfoError> {
|
||||
let claims: StandardJwtClaims = decode_jwt_payload(jwt)?;
|
||||
Ok(claims
|
||||
.exp
|
||||
.and_then(|exp| DateTime::<Utc>::from_timestamp(exp, 0)))
|
||||
}
|
||||
|
||||
pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
|
||||
let claims: IdClaims = decode_jwt_payload(jwt)?;
|
||||
let email = claims
|
||||
.email
|
||||
.or_else(|| claims.profile.and_then(|profile| profile.email));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Serialize;
|
||||
|
||||
#[test]
|
||||
fn id_token_info_parses_email_and_plan() {
|
||||
fn fake_jwt(payload: serde_json::Value) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
@@ -13,12 +14,6 @@ fn id_token_info_parses_email_and_plan() {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "pro"
|
||||
}
|
||||
});
|
||||
|
||||
fn b64url_no_pad(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
@@ -27,7 +22,17 @@ fn id_token_info_parses_email_and_plan() {
|
||||
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = b64url_no_pad(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_parses_email_and_plan() {
|
||||
let fake_jwt = fake_jwt(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "pro"
|
||||
}
|
||||
}));
|
||||
|
||||
let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse");
|
||||
assert_eq!(info.email.as_deref(), Some("user@example.com"));
|
||||
@@ -36,30 +41,12 @@ fn id_token_info_parses_email_and_plan() {
|
||||
|
||||
#[test]
|
||||
fn id_token_info_parses_go_plan() {
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
typ: &'static str,
|
||||
}
|
||||
let header = Header {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
let fake_jwt = fake_jwt(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "go"
|
||||
}
|
||||
});
|
||||
|
||||
fn b64url_no_pad(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = b64url_no_pad(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
}));
|
||||
|
||||
let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse");
|
||||
assert_eq!(info.email.as_deref(), Some("user@example.com"));
|
||||
@@ -68,31 +55,37 @@ fn id_token_info_parses_go_plan() {
|
||||
|
||||
#[test]
|
||||
fn id_token_info_handles_missing_fields() {
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
typ: &'static str,
|
||||
}
|
||||
let header = Header {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = serde_json::json!({ "sub": "123" });
|
||||
|
||||
fn b64url_no_pad(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap());
|
||||
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap());
|
||||
let signature_b64 = b64url_no_pad(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
|
||||
|
||||
let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse");
|
||||
assert!(info.email.is_none());
|
||||
assert!(info.get_chatgpt_plan_type().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_expiration_parses_exp_claim() {
|
||||
let fake_jwt = fake_jwt(serde_json::json!({
|
||||
"exp": 1_700_000_000_i64,
|
||||
}));
|
||||
|
||||
let expires_at = parse_jwt_expiration(&fake_jwt).expect("should parse");
|
||||
assert_eq!(expires_at, Utc.timestamp_opt(1_700_000_000, 0).single());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_expiration_handles_missing_exp() {
|
||||
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
|
||||
|
||||
let expires_at = parse_jwt_expiration(&fake_jwt).expect("should parse");
|
||||
assert_eq!(expires_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_expiration_rejects_malformed_jwt() {
|
||||
let err = parse_jwt_expiration("not-a-jwt").expect_err("should fail");
|
||||
assert_eq!(err.to_string(), "invalid ID token format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_account_detection_matches_workspace_plans() {
|
||||
let workspace = IdTokenInfo {
|
||||
|
||||
@@ -518,6 +518,7 @@ pub struct InterAgentCommunication {
|
||||
#[serde(default)]
|
||||
pub other_recipients: Vec<AgentPath>,
|
||||
pub content: String,
|
||||
pub trigger_turn: bool,
|
||||
}
|
||||
|
||||
impl InterAgentCommunication {
|
||||
@@ -526,12 +527,14 @@ impl InterAgentCommunication {
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
trigger_turn: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
author,
|
||||
recipient,
|
||||
other_recipients,
|
||||
content,
|
||||
trigger_turn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +551,7 @@ impl InterAgentCommunication {
|
||||
Self::from_message_content(content).is_some()
|
||||
}
|
||||
|
||||
fn from_message_content(content: &[ContentItem]) -> Option<Self> {
|
||||
pub fn from_message_content(content: &[ContentItem]) -> Option<Self> {
|
||||
match content {
|
||||
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
|
||||
serde_json::from_str(text).ok()
|
||||
|
||||
@@ -203,9 +203,13 @@ mod tests {
|
||||
|
||||
/// Prepends the given directory to the system's PATH variable.
|
||||
fn build_path(dir: &Path) -> String {
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
format!("{}{sep}{current}", dir.to_string_lossy())
|
||||
let mut path = OsString::from(dir.as_os_str());
|
||||
if let Some(current) = std::env::var_os("PATH") {
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
path.push(sep);
|
||||
path.push(current);
|
||||
}
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery.
|
||||
|
||||
@@ -14,7 +14,11 @@ pub(crate) fn create_env_for_mcp_server(
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(env_vars.iter().map(String::as_str))
|
||||
.filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value)))
|
||||
.filter_map(|var| match var {
|
||||
"PATH" => env::var_os(var)
|
||||
.map(|value| (var.to_string(), value.to_string_lossy().into_owned())),
|
||||
_ => env::var(var).ok().map(|value| (var.to_string(), value)),
|
||||
})
|
||||
.chain(extra_env.unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
@@ -158,6 +162,18 @@ mod tests {
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_os(key: &str, value: &std::ffi::OsStr) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
@@ -191,4 +207,19 @@ mod tests {
|
||||
let env = create_env_for_mcp_server(None, &[custom_var.to_string()]);
|
||||
assert_eq!(env.get(custom_var), Some(&value.to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(extra_rmcp_env)]
|
||||
fn create_env_preserves_path_when_it_is_not_utf8() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let raw_path = std::ffi::OsStr::from_bytes(b"/tmp/codex-\xFF/bin");
|
||||
let expected = raw_path.to_string_lossy().into_owned();
|
||||
let _guard = EnvVarGuard::set_os("PATH", raw_path);
|
||||
|
||||
let env = create_env_for_mcp_server(None, &[]);
|
||||
|
||||
assert_eq!(env.get("PATH"), Some(&expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,6 +1033,7 @@ impl App {
|
||||
.await?;
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
self.config = config;
|
||||
self.chat_widget.sync_plugin_mentions_config(&self.config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3181,6 +3182,7 @@ impl App {
|
||||
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.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
||||
}
|
||||
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
||||
@@ -3634,6 +3636,7 @@ impl App {
|
||||
"failed to refresh config after plugin uninstall"
|
||||
);
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
||||
}
|
||||
self.chat_widget.on_plugin_uninstall_loaded(
|
||||
|
||||
@@ -3787,7 +3787,7 @@ impl ChatComposer {
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
const HOLD_DELAY_MILLIS: u64 = 500;
|
||||
const HOLD_DELAY_MILLIS: u64 = 1_000;
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
let flag_clone = flag;
|
||||
let frame_clone = frame;
|
||||
@@ -3875,7 +3875,7 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the 500ms space hold timeout elapses.
|
||||
/// Called when the 1s space hold timeout elapses.
|
||||
///
|
||||
/// On terminals without key-release reporting, this only transitions into voice capture if we
|
||||
/// observed repeated Space events while pending; otherwise the keypress is treated as a typed
|
||||
@@ -5500,6 +5500,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_items_hide_plugin_owned_app_duplicates_when_provenance_matches() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_connectors_enabled(true);
|
||||
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||||
config_name: "google-calendar@debug".to_string(),
|
||||
display_name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["google-calendar".to_string()],
|
||||
app_connector_ids: vec![codex_core::plugins::AppConnectorId(
|
||||
"google_calendar".to_string(),
|
||||
)],
|
||||
}]));
|
||||
composer.set_connector_mentions(Some(ConnectorsSnapshot {
|
||||
connectors: vec![AppInfo {
|
||||
id: "google_calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Look up events and availability".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/google-calendar".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: vec!["Google Calendar".to_string()],
|
||||
}],
|
||||
}));
|
||||
|
||||
let mentions = composer.mention_items();
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string()));
|
||||
assert_eq!(
|
||||
mentions[0].path,
|
||||
Some("plugin://google-calendar@debug".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mention_popup_snapshot() {
|
||||
snapshot_composer_state("plugin_mention_popup", false, |composer| {
|
||||
|
||||
@@ -371,11 +371,14 @@ impl ListSelectionView {
|
||||
""
|
||||
};
|
||||
let name_with_marker = format!("{name}{marker}");
|
||||
let is_disabled = item.is_disabled || item.disabled_reason.is_some();
|
||||
let n = visible_idx + 1;
|
||||
let wrap_prefix = if self.is_searchable {
|
||||
// The number keys don't work when search is enabled (since we let the
|
||||
// numbers be used for the search query).
|
||||
format!("{prefix} ")
|
||||
} else if is_disabled {
|
||||
format!("{prefix} {}", " ".repeat(n.to_string().len() + 2))
|
||||
} else {
|
||||
format!("{prefix} {n}. ")
|
||||
};
|
||||
@@ -388,7 +391,6 @@ impl ListSelectionView {
|
||||
.flatten()
|
||||
.or_else(|| item.description.clone());
|
||||
let wrap_indent = description.is_none().then_some(wrap_prefix_width);
|
||||
let is_disabled = item.is_disabled || item.disabled_reason.is_some();
|
||||
GenericDisplayRow {
|
||||
name: name_with_marker,
|
||||
name_prefix_spans,
|
||||
|
||||
@@ -8946,6 +8946,10 @@ impl ChatWidget {
|
||||
|
||||
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
|
||||
if self.connectors_enabled() {
|
||||
let plugin_provenance = McpManager::new(Arc::new(PluginsManager::new(
|
||||
self.config.codex_home.clone(),
|
||||
)))
|
||||
.tool_plugin_provenance(&self.config);
|
||||
let mut connectors_by_id: HashMap<String, connectors::AppInfo> = HashMap::new();
|
||||
for tool in ev.tools.values() {
|
||||
let Some(meta) = tool.meta.as_ref().and_then(serde_json::Value::as_object) else {
|
||||
@@ -8988,7 +8992,9 @@ impl ChatWidget {
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
plugin_display_names: plugin_provenance
|
||||
.plugin_display_names_for_connector_id(connector_id)
|
||||
.to_vec(),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9124,7 +9130,7 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
fn refresh_plugin_mentions(&mut self) {
|
||||
pub(crate) fn refresh_plugin_mentions(&mut self) {
|
||||
if !self.config.features.enabled(Feature::Plugins) {
|
||||
self.bottom_pane.set_plugin_mentions(/*plugins*/ None);
|
||||
return;
|
||||
@@ -9137,6 +9143,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_plugin_mentions(Some(plugins));
|
||||
}
|
||||
|
||||
pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) {
|
||||
self.config.features = config.features.clone();
|
||||
self.config.config_layer_stack = config.config_layer_stack.clone();
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@ expression: before
|
||||
Apps
|
||||
Loading installed and available apps...
|
||||
|
||||
› 1. Loading apps... This updates when the full list is ready.
|
||||
› Loading apps... This updates when the full list is ready.
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ expression: popup
|
||||
|
||||
› 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
|
||||
Skills design-review, extract-copy
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: popup
|
||||
Loading available plugins...
|
||||
This first pass shows the ChatGPT marketplace only.
|
||||
|
||||
› 1. Loading plugins... This updates when the marketplace list is ready.
|
||||
› Loading plugins... This updates when the marketplace list is ready.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ expression: popup
|
||||
|
||||
1. System default Use your operating system
|
||||
default device.
|
||||
› 2. Unavailable: Studio Mic (current) (disabled) Configured device is not
|
||||
› Unavailable: Studio Mic (current) (disabled) Configured device is not
|
||||
currently available.
|
||||
(disabled: Reconnect the
|
||||
device or choose another
|
||||
|
||||
@@ -7162,6 +7162,7 @@ fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketpla
|
||||
fn plugins_test_response(marketplaces: Vec<PluginMarketplaceEntry>) -> PluginListResponse {
|
||||
PluginListResponse {
|
||||
marketplaces,
|
||||
marketplace_load_errors: Vec::new(),
|
||||
remote_sync_error: None,
|
||||
featured_plugin_ids: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -1032,6 +1032,7 @@ impl App {
|
||||
.await?;
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
self.config = config;
|
||||
self.chat_widget.sync_plugin_mentions_config(&self.config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3684,6 +3685,7 @@ impl App {
|
||||
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.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
}
|
||||
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
||||
@@ -4160,6 +4162,7 @@ impl App {
|
||||
"failed to refresh config after plugin uninstall"
|
||||
);
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
}
|
||||
self.chat_widget.on_plugin_uninstall_loaded(
|
||||
|
||||
@@ -3802,7 +3802,7 @@ impl ChatComposer {
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
const HOLD_DELAY_MILLIS: u64 = 500;
|
||||
const HOLD_DELAY_MILLIS: u64 = 1_000;
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
let flag_clone = flag;
|
||||
let frame_clone = frame;
|
||||
@@ -3890,7 +3890,7 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the 500ms space hold timeout elapses.
|
||||
/// Called when the 1s space hold timeout elapses.
|
||||
///
|
||||
/// On terminals without key-release reporting, this only transitions into voice capture if we
|
||||
/// observed repeated Space events while pending; otherwise the keypress is treated as a typed
|
||||
|
||||
@@ -371,11 +371,14 @@ impl ListSelectionView {
|
||||
""
|
||||
};
|
||||
let name_with_marker = format!("{name}{marker}");
|
||||
let is_disabled = item.is_disabled || item.disabled_reason.is_some();
|
||||
let n = visible_idx + 1;
|
||||
let wrap_prefix = if self.is_searchable {
|
||||
// The number keys don't work when search is enabled (since we let the
|
||||
// numbers be used for the search query).
|
||||
format!("{prefix} ")
|
||||
} else if is_disabled {
|
||||
format!("{prefix} {}", " ".repeat(n.to_string().len() + 2))
|
||||
} else {
|
||||
format!("{prefix} {n}. ")
|
||||
};
|
||||
@@ -388,7 +391,6 @@ impl ListSelectionView {
|
||||
.flatten()
|
||||
.or_else(|| item.description.clone());
|
||||
let wrap_indent = description.is_none().then_some(wrap_prefix_width);
|
||||
let is_disabled = item.is_disabled || item.disabled_reason.is_some();
|
||||
GenericDisplayRow {
|
||||
name: name_with_marker,
|
||||
name_prefix_spans,
|
||||
|
||||
@@ -10302,7 +10302,7 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
fn refresh_plugin_mentions(&mut self) {
|
||||
pub(crate) fn refresh_plugin_mentions(&mut self) {
|
||||
if !self.config.features.enabled(Feature::Plugins) {
|
||||
self.bottom_pane.set_plugin_mentions(/*plugins*/ None);
|
||||
return;
|
||||
@@ -10315,6 +10315,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_plugin_mentions(Some(plugins));
|
||||
}
|
||||
|
||||
pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) {
|
||||
self.config.features = config.features.clone();
|
||||
self.config.config_layer_stack = config.config_layer_stack.clone();
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@ expression: before
|
||||
Apps
|
||||
Loading installed and available apps...
|
||||
|
||||
› 1. Loading apps... This updates when the full list is ready.
|
||||
› Loading apps... This updates when the full list is ready.
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ expression: popup
|
||||
|
||||
› 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
|
||||
Skills design-review, extract-copy
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: popup
|
||||
Loading available plugins...
|
||||
This first pass shows the ChatGPT marketplace only.
|
||||
|
||||
› 1. Loading plugins... This updates when the marketplace list is ready.
|
||||
› Loading plugins... This updates when the marketplace list is ready.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ expression: popup
|
||||
|
||||
1. System default Use your operating system
|
||||
default device.
|
||||
› 2. Unavailable: Studio Mic (current) (disabled) Configured device is not
|
||||
› Unavailable: Studio Mic (current) (disabled) Configured device is not
|
||||
currently available.
|
||||
(disabled: Reconnect the
|
||||
device or choose another
|
||||
|
||||
@@ -7759,6 +7759,7 @@ fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketpla
|
||||
fn plugins_test_response(marketplaces: Vec<PluginMarketplaceEntry>) -> PluginListResponse {
|
||||
PluginListResponse {
|
||||
marketplaces,
|
||||
marketplace_load_errors: Vec::new(),
|
||||
remote_sync_error: None,
|
||||
featured_plugin_ids: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::c_void;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -61,10 +61,10 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap<String, String>
|
||||
// 4) PATH entries (best-effort)
|
||||
if let Some(path) = env
|
||||
.get("PATH")
|
||||
.cloned()
|
||||
.or_else(|| std::env::var("PATH").ok())
|
||||
.map(OsString::from)
|
||||
.or_else(|| std::env::var_os("PATH"))
|
||||
{
|
||||
for part in std::env::split_paths(OsStr::new(&path)) {
|
||||
for part in std::env::split_paths(&path) {
|
||||
if !part.as_os_str().is_empty() {
|
||||
unique_push(&mut set, &mut out, part);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::{anyhow, Result};
|
||||
use dirs_next::home_dir;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -31,8 +32,8 @@ pub fn ensure_non_interactive_pager(env_map: &mut HashMap<String, String>) {
|
||||
// Keep PATH and PATHEXT stable for callers that rely on inheriting the parent process env.
|
||||
pub fn inherit_path_env(env_map: &mut HashMap<String, String>) {
|
||||
if !env_map.contains_key("PATH") {
|
||||
if let Ok(path) = env::var("PATH") {
|
||||
env_map.insert("PATH".into(), path);
|
||||
if let Some(path) = env::var_os("PATH") {
|
||||
env_map.insert("PATH".into(), path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
if !env_map.contains_key("PATHEXT") {
|
||||
@@ -42,27 +43,30 @@ pub fn inherit_path_env(env_map: &mut HashMap<String, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_path(env_map: &mut HashMap<String, String>, prefix: &str) {
|
||||
fn prepend_path(env_map: &mut HashMap<String, String>, prefix: &Path) -> Result<()> {
|
||||
let existing = env_map
|
||||
.get("PATH")
|
||||
.cloned()
|
||||
.or_else(|| env::var("PATH").ok())
|
||||
.map(OsString::from)
|
||||
.or_else(|| env::var_os("PATH"))
|
||||
.unwrap_or_default();
|
||||
let parts: Vec<String> = existing.split(';').map(|s| s.to_string()).collect();
|
||||
let parts: Vec<PathBuf> = env::split_paths(&existing).collect();
|
||||
if parts
|
||||
.first()
|
||||
.map(|p| p.eq_ignore_ascii_case(prefix))
|
||||
.map(|path| {
|
||||
path.as_os_str()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&prefix.as_os_str().to_string_lossy())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let mut new_path = String::new();
|
||||
new_path.push_str(prefix);
|
||||
if !existing.is_empty() {
|
||||
new_path.push(';');
|
||||
new_path.push_str(&existing);
|
||||
}
|
||||
env_map.insert("PATH".into(), new_path);
|
||||
let new_path = env::join_paths(
|
||||
std::iter::once(prefix.as_os_str()).chain(parts.iter().map(|path| path.as_os_str())),
|
||||
)
|
||||
.map_err(|err| anyhow!("failed to construct PATH: {err}"))?;
|
||||
env_map.insert("PATH".into(), new_path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reorder_pathext_for_stubs(env_map: &mut HashMap<String, String>) {
|
||||
@@ -168,7 +172,7 @@ pub fn apply_no_network_to_env(env_map: &mut HashMap<String, String>) -> Result<
|
||||
}
|
||||
}
|
||||
}
|
||||
prepend_path(env_map, &base.to_string_lossy());
|
||||
prepend_path(env_map, &base)?;
|
||||
reorder_pathext_for_stubs(env_map);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user