Wire the PatchUpdated events through app_server (#18289)

Wires patch_updated events through app_server. These events are parsed
and streamed while apply_patch is being written by the model. Also adds 500ms of buffering to the patch_updated events in the diff_consumer.

The eventual goal is to use this to display better progress indicators in
the codex app.
This commit is contained in:
Akshay Nathan
2026-04-20 10:44:03 -07:00
committed by GitHub
Unverified
parent 316cf0e90b
commit 34a3e85fcd
18 changed files with 613 additions and 6 deletions
@@ -993,6 +993,32 @@
],
"type": "object"
},
"FileChangePatchUpdatedNotification": {
"properties": {
"changes": {
"items": {
"$ref": "#/definitions/FileUpdateChange"
},
"type": "array"
},
"itemId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"changes",
"itemId",
"threadId",
"turnId"
],
"type": "object"
},
"FileUpdateChange": {
"properties": {
"diff": {
@@ -4668,6 +4694,26 @@
"title": "Item/fileChange/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"item/fileChange/patchUpdated"
],
"title": "Item/fileChange/patchUpdatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FileChangePatchUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Item/fileChange/patchUpdatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4290,6 +4290,26 @@
"title": "Item/fileChange/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"item/fileChange/patchUpdated"
],
"title": "Item/fileChange/patchUpdatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FileChangePatchUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Item/fileChange/patchUpdatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -7985,6 +8005,34 @@
"title": "FileChangeOutputDeltaNotification",
"type": "object"
},
"FileChangePatchUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"changes": {
"items": {
"$ref": "#/definitions/v2/FileUpdateChange"
},
"type": "array"
},
"itemId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"changes",
"itemId",
"threadId",
"turnId"
],
"title": "FileChangePatchUpdatedNotification",
"type": "object"
},
"FileUpdateChange": {
"properties": {
"diff": {
@@ -4342,6 +4342,34 @@
"title": "FileChangeOutputDeltaNotification",
"type": "object"
},
"FileChangePatchUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"changes": {
"items": {
"$ref": "#/definitions/FileUpdateChange"
},
"type": "array"
},
"itemId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"changes",
"itemId",
"threadId",
"turnId"
],
"title": "FileChangePatchUpdatedNotification",
"type": "object"
},
"FileUpdateChange": {
"properties": {
"diff": {
@@ -9640,6 +9668,26 @@
"title": "Item/fileChange/outputDeltaNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"item/fileChange/patchUpdated"
],
"title": "Item/fileChange/patchUpdatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FileChangePatchUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Item/fileChange/patchUpdatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -0,0 +1,107 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"FileUpdateChange": {
"properties": {
"diff": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/PatchChangeKind"
},
"path": {
"type": "string"
}
},
"required": [
"diff",
"kind",
"path"
],
"type": "object"
},
"PatchChangeKind": {
"oneOf": [
{
"properties": {
"type": {
"enum": [
"add"
],
"title": "AddPatchChangeKindType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AddPatchChangeKind",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"delete"
],
"title": "DeletePatchChangeKindType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DeletePatchChangeKind",
"type": "object"
},
{
"properties": {
"move_path": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"update"
],
"title": "UpdatePatchChangeKindType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UpdatePatchChangeKind",
"type": "object"
}
]
}
},
"properties": {
"changes": {
"items": {
"$ref": "#/definitions/FileUpdateChange"
},
"type": "array"
},
"itemId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"changes",
"itemId",
"threadId",
"turnId"
],
"title": "FileChangePatchUpdatedNotification",
"type": "object"
}
@@ -16,6 +16,7 @@ import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotifi
import type { ErrorNotification } from "./v2/ErrorNotification";
import type { ExternalAgentConfigImportCompletedNotification } from "./v2/ExternalAgentConfigImportCompletedNotification";
import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification";
import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification";
import type { FsChangedNotification } from "./v2/FsChangedNotification";
import type { HookCompletedNotification } from "./v2/HookCompletedNotification";
import type { HookStartedNotification } from "./v2/HookStartedNotification";
@@ -61,4 +62,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -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 { FileUpdateChange } from "./FileUpdateChange";
export type FileChangePatchUpdatedNotification = { threadId: string, turnId: string, itemId: string, changes: Array<FileUpdateChange>, };
@@ -98,6 +98,7 @@ export type { FeedbackUploadParams } from "./FeedbackUploadParams";
export type { FeedbackUploadResponse } from "./FeedbackUploadResponse";
export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision";
export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaNotification";
export type { FileChangePatchUpdatedNotification } from "./FileChangePatchUpdatedNotification";
export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams";
export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse";
export type { FileSystemAccessMode } from "./FileSystemAccessMode";
@@ -1023,6 +1023,7 @@ server_notification_definitions! {
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
FileChangePatchUpdated => "item/fileChange/patchUpdated" (v2::FileChangePatchUpdatedNotification),
ServerRequestResolved => "serverRequest/resolved" (v2::ServerRequestResolvedNotification),
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
@@ -5935,6 +5935,16 @@ pub struct FileChangeOutputDeltaNotification {
pub delta: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FileChangePatchUpdatedNotification {
pub thread_id: String,
pub turn_id: String,
pub item_id: String,
pub changes: Vec<FileUpdateChange>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -1069,6 +1069,7 @@ There are additional item-specific events:
#### fileChange
- `item/fileChange/patchUpdated` - when `features.apply_patch_streaming_events` is enabled, streams structured file-change snapshots parsed from the model-generated patch before it is executed.
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
### Errors
@@ -40,6 +40,7 @@ use codex_app_server_protocol::ExecCommandApprovalResponse;
use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::FileUpdateChange;
@@ -1636,6 +1637,17 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
}
EventMsg::PatchApplyUpdated(event) => {
let notification = FileChangePatchUpdatedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item_id: event.call_id,
changes: convert_patch_changes(&event.changes),
};
outgoing
.send_server_notification(ServerNotification::FileChangePatchUpdated(notification))
.await;
}
EventMsg::PatchApplyEnd(patch_end_event) => {
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
@@ -24,6 +24,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
@@ -2026,6 +2027,248 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let call_id = "patch-call";
let item_id = "fc-patch-call";
let patch = "*** Begin Patch\n*** Add File: live.txt\n+live line\n*** End Patch\n";
let patch_delta_1 = "*** Begin Patch\n*** Add File: live.txt\n+live";
let patch_delta_2 = " line\n*** End Patch\n";
let responses = vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
serde_json::json!({
"type": "response.output_item.added",
"item": {
"type": "custom_tool_call",
"id": item_id,
"call_id": call_id,
"name": "apply_patch",
"input": "",
"status": "in_progress"
}
}),
serde_json::json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"call_id": call_id,
"delta": patch_delta_1,
}),
serde_json::json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"call_id": call_id,
"delta": patch_delta_2,
}),
responses::ev_apply_patch_custom_tool_call(call_id, patch),
responses::ev_completed("resp-1"),
]),
create_final_assistant_message_sse_response("patch applied")?,
];
let server = create_mock_responses_server_sequence(responses).await;
create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
cwd: Some(workspace.to_string_lossy().into_owned()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "apply patch".into(),
text_elements: Vec::new(),
}],
cwd: Some(workspace),
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert!(
!mcp.pending_notification_methods()
.iter()
.any(|method| method == "item/fileChange/patchUpdated")
);
Ok(())
}
#[tokio::test]
async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let call_id = "patch-call";
let item_id = "fc-patch-call";
let patch = "*** Begin Patch\n*** Add File: live.txt\n+live line\n*** End Patch\n";
let patch_delta_1 = "*** Begin Patch\n*** Add File: live.txt\n+live";
let patch_delta_2 = " line\n*** End Patch\n";
let responses = vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
serde_json::json!({
"type": "response.output_item.added",
"item": {
"type": "function_call",
"id": "fc-other-call",
"call_id": "other-call",
"name": "not_apply_patch",
"arguments": "",
"status": "in_progress"
}
}),
serde_json::json!({
"type": "response.function_call_arguments.delta",
"item_id": "fc-other-call",
"delta": r#"{"input":"*** Begin Patch\n*** Add File: ignored.txt\n+ignored"#,
}),
serde_json::json!({
"type": "response.output_item.added",
"item": {
"type": "custom_tool_call",
"id": item_id,
"call_id": call_id,
"name": "apply_patch",
"input": "",
"status": "in_progress"
}
}),
serde_json::json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"call_id": call_id,
"delta": patch_delta_1,
}),
serde_json::json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"call_id": call_id,
"delta": patch_delta_2,
}),
responses::ev_apply_patch_custom_tool_call(call_id, patch),
responses::ev_completed("resp-1"),
]),
create_final_assistant_message_sse_response("patch applied")?,
];
let server = create_mock_responses_server_sequence(responses).await;
create_config_toml(
&codex_home,
&server.uri(),
"never",
&BTreeMap::from([
(Feature::ApplyPatchFreeform, true),
(Feature::ApplyPatchStreamingEvents, true),
(Feature::Plugins, false),
(Feature::RemoteModels, false),
(Feature::ShellSnapshot, false),
]),
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
cwd: Some(workspace.to_string_lossy().into_owned()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "apply patch".into(),
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let mut streamed_content = String::new();
while streamed_content != "live line\n" {
let delta_notif = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/patchUpdated"),
)
.await??;
let delta: FileChangePatchUpdatedNotification = serde_json::from_value(
delta_notif
.params
.clone()
.expect("item/fileChange/patchUpdated params"),
)?;
assert_eq!(delta.thread_id, thread.id);
assert_eq!(delta.turn_id, turn.id);
assert_eq!(delta.item_id, call_id);
let change = delta
.changes
.iter()
.find(|change| change.path == "live.txt")
.expect("live.txt change");
assert!(matches!(change.kind, PatchChangeKind::Add));
streamed_content = change.diff.clone();
}
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
+5 -1
View File
@@ -1925,7 +1925,11 @@ async fn try_run_sampling_request(
match event {
ResponseEvent::Created => {}
ResponseEvent::OutputItemDone(item) => {
active_tool_argument_diff_consumer = None;
if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
&& let Some(event) = consumer.flush_on_complete()
{
sess.send_event(&turn_context, event).await;
}
let previously_active_item = active_item.take();
if let Some(previous) = previously_active_item.as_ref()
&& matches!(previous, TurnItem::AgentMessage(_))
@@ -3,6 +3,8 @@ use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::apply_patch;
use crate::apply_patch::InternalApplyPatchInvocation;
@@ -44,12 +46,16 @@ use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_tools::ApplyPatchToolArgs;
use codex_utils_absolute_path::AbsolutePathBuf;
const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500);
pub struct ApplyPatchHandler;
#[derive(Default)]
struct ApplyPatchArgumentDiffConsumer {
input: String,
last_progress: Option<Vec<Hunk>>,
last_sent_at: Option<Instant>,
pending: Option<PatchApplyUpdatedEvent>,
}
impl ToolArgumentDiffConsumer for ApplyPatchArgumentDiffConsumer {
@@ -66,6 +72,11 @@ impl ToolArgumentDiffConsumer for ApplyPatchArgumentDiffConsumer {
self.push_delta(call_id, diff)
.map(EventMsg::PatchApplyUpdated)
}
fn flush_on_complete(&mut self) -> Option<EventMsg> {
self.flush_update_on_complete()
.map(EventMsg::PatchApplyUpdated)
}
}
impl ApplyPatchArgumentDiffConsumer {
@@ -82,7 +93,29 @@ impl ApplyPatchArgumentDiffConsumer {
let changes = convert_apply_patch_hunks_to_protocol(&hunks);
self.last_progress = Some(hunks);
Some(PatchApplyUpdatedEvent { call_id, changes })
let event = PatchApplyUpdatedEvent { call_id, changes };
let now = Instant::now();
match self.last_sent_at {
Some(last_sent_at)
if now.duration_since(last_sent_at) < APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL =>
{
self.pending = Some(event);
None
}
Some(_) | None => {
self.pending = None;
self.last_sent_at = Some(now);
Some(event)
}
}
}
fn flush_update_on_complete(&mut self) -> Option<PatchApplyUpdatedEvent> {
let event = self.pending.take();
if event.is_some() {
self.last_sent_at = Some(Instant::now());
}
event
}
}
@@ -54,9 +54,13 @@ fn diff_consumer_streams_apply_patch_changes() {
)
);
let event = consumer
.push_delta("call-1".to_string(), "\n+world")
.expect("progress event");
assert!(
consumer
.push_delta("call-1".to_string(), "\n+world")
.is_none()
);
let event = consumer.flush_update_on_complete().expect("progress event");
assert_eq!(
(event.call_id, event.changes),
(
@@ -71,6 +75,39 @@ fn diff_consumer_streams_apply_patch_changes() {
);
}
#[test]
fn diff_consumer_sends_next_update_after_buffer_interval() {
let mut consumer = ApplyPatchArgumentDiffConsumer::default();
consumer.push_delta("call-1".to_string(), "*** Begin Patch\n");
let first = consumer
.push_delta("call-1".to_string(), "*** Add File: hello.txt\n+hello")
.expect("first progress event");
assert_eq!(
first.changes,
HashMap::from([(
PathBuf::from("hello.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
)])
);
consumer.last_sent_at =
Some(std::time::Instant::now() - APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL);
let second = consumer
.push_delta("call-1".to_string(), "\n+world")
.expect("second progress event");
assert_eq!(
second.changes,
HashMap::from([(
PathBuf::from("hello.txt"),
FileChange::Add {
content: "hello\nworld\n".to_string(),
},
)])
);
}
#[tokio::test]
async fn approval_keys_include_move_destination() {
let tmp = TempDir::new().expect("tmp");
+5
View File
@@ -95,6 +95,11 @@ pub(crate) trait ToolArgumentDiffConsumer: Send {
/// Consume the next argument diff for a tool call.
fn consume_diff(&mut self, turn: &TurnContext, call_id: String, diff: &str)
-> Option<EventMsg>;
/// Flush any buffered event before the tool call completes.
fn flush_on_complete(&mut self) -> Option<EventMsg> {
None
}
}
pub(crate) struct AnyToolResult {
@@ -380,6 +380,9 @@ fn server_notification_thread_target(
ServerNotification::FileChangeOutputDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::FileChangePatchUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ServerRequestResolved(notification) => {
Some(notification.thread_id.as_str())
}
+1
View File
@@ -6540,6 +6540,7 @@ impl ChatWidget {
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::FileChangePatchUpdated(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)