Skip to content

ApplyPatch流式识别

追踪模型流式 apply_patch 参数如何转成 PatchApplyUpdated 进度事件,以及完整调用结束时如何 flush 和真正应用。

基于rust-v0.150.0
CodexRustExecutionApplyPatch

ApplyPatch流式识别 ​

Apply Patch 的流式识别不是提前写文件。模型 custom tool input 的 delta 进入 ApplyPatchArgumentDiffConsumer,消费者复用 StreamingPatchParser 形成当前已知 hunks,再把它们投影为 PatchApplyUpdatedEvent。preview 会忽略 Environment ID、文件内容验证和行尾模式;只有完整 custom tool call 结束后,handler 才重新解析完整输入、选择 environment、验证 filesystem 并进入 runtime。

本文承接ApplyPatch解析器、ApplyPatchInvocation模型和工具框架测试策略,面向理解 tool argument diff、feature gate 和节流的读者。范围是 Core handler 的流式进度识别,不展开文件更新算法。读完后,你应能解释为什么进度事件可能先显示空 Add 内容、为什么 500ms 内的更新会合并,以及 malformed stream 如何在 finish 阶段失败。

1. Delta入口 ​

1.1 Consumer状态 ​

消费者持有 parser、上次发送时间和 pending event。它不是 ApplyPatchAction,也不拥有 filesystem。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ApplyPatchArgumentDiffConsumer

rust
#[derive(Default)]
struct ApplyPatchArgumentDiffConsumer {
    parser: StreamingPatchParser,
    last_sent_at: Option<Instant>,
    pending: Option<PatchApplyUpdatedEvent>,
}

1.2 feature gate ​

consume_diff 只有在 ApplyPatchStreamingEvents feature 启用时才继续解析;关闭时直接返回 None,既不发进度,也不改变 consumer 状态。启用时 push_delta 的解析错误被转换为 None,因此 partial stream 的错误不会直接终止 Turn;最终完整 tool call 的 handler 解析才是权威结果。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ToolArgumentDiffConsumer::consume_diff

rust
fn consume_diff(
    &mut self,
    turn: &TurnContext,
    call_id: String,
    diff: &str,
) -> Option<EventMsg> {
    if !turn.config.features.enabled(Feature::ApplyPatchStreamingEvents) {
        return None;
    }
    self.push_delta(call_id, diff)
        .map(EventMsg::PatchApplyUpdated)
}

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ApplyPatchArgumentDiffConsumer::push_delta

rust
let hunks = self.parser.push_delta(delta).ok()?;
if hunks.is_empty() {
    return None;
}

2. Hunk投影 ​

2.1 Add与Delete ​

每次 parser 返回 hunks,consumer 将它们转为 protocol FileChange。map key 使用 hunk 的源路径,即使 Update 带 Move to 也不改成目标路径;目标单独放在 move_path。Add 的早期 event 可能 content 为空, 因为 header 已完成但后续 + 行尚未到达;Delete 只提供路径和空 content。Environment ID 只影响最终 environment 选择,不进入 PatchApplyUpdatedEvent。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: convert_apply_patch_hunks_to_protocol、hunk_source_path

rust
let path = hunk_source_path(hunk).to_path_buf();
let change = match hunk {
    Hunk::AddFile { contents, .. } => FileChange::Add {
        content: contents.clone(),
    },
    Hunk::DeleteFile { .. } => FileChange::Delete {
        content: String::new(),
    },
    Hunk::UpdateFile { chunks, move_path, .. } => FileChange::Update {
        unified_diff: format_update_chunks_for_progress(chunks),
        move_path: move_path.clone(),
    },
};
(path, change)

2.2 Update预览 ​

Update progress 用当前 chunks 格式化出临时 unified diff,并保留 *** End of File marker;它不是 unified_diff_from_chunks 的最终 diff,因为尚未读取目标文件、定位 context、保留行尾或生成 new_content。显式 context 行已同时存在 old/new 数组,preview 会先输出所有 - 行再输出所有 + 行, 因此它是结构预览而非标准 unified diff 的精确展示。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: format_update_chunks_for_progress

rust
for chunk in chunks {
    match &chunk.change_context {
        Some(context) => {
            unified_diff.push_str("@@ ");
            unified_diff.push_str(context);
            unified_diff.push('\n');
        }
        None => unified_diff.push_str("@@\n"),
    }
    for line in &chunk.old_lines {
        unified_diff.push('-');
        unified_diff.push_str(line);
        unified_diff.push('\n');
    }
    for line in &chunk.new_lines {
        unified_diff.push('+');
        unified_diff.push_str(line);
        unified_diff.push('\n');
    }
    if chunk.is_end_of_file {
        unified_diff.push_str("*** End of File\n");
    }
}

3. 节流与flush ​

3.1 500ms窗口 ​

第一次有效事件立即发送;如果距上次发送不足 500ms,最新 event 放进 pending 并返回 None。超过间隔后发送当前 event,同时清空旧 pending。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: push_delta

rust
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)
    }
}

pending 只保留最新 event,不累积整个 delta 队列;进度是当前状态快照,不是每个字符的日志。

3.2 完成时flush ​

完整 streamed output item 结束时,Turn 循环调用 consumer finish。finish_update_on_complete 先用 parser.finish 检查 End Patch 和尾行,再取出 pending event。Turn 事件层只在 Ok(Some(event)) 时发送 preview,consumer finish 错误不会作为 preview 事件发出;随后完整 custom tool handler 会重新 parse_patch,因此 malformed input 仍会在正式执行前失败。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: finish_update_on_complete

rust
self.parser.finish().map_err(|err| {
    FunctionCallError::RespondToModel(format!("failed to parse apply_patch: {err}"))
})?;
let event = self.pending.take();
if event.is_some() {
    self.last_sent_at = Some(Instant::now());
}
Ok(event)

源码位置:codex-rs/core/src/session/turn.rs :: ResponseEvent::OutputItemDone 的 diff consumer flush

rust
if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
    && let Ok(Some(event)) = consumer.finish()
{
    sess.send_event(&turn_context, event).await;
}

4. 进度与应用分离 ​

进度 event 只来自参数 diff consumer;完整 freeform custom tool call 后 handler 直接调用 parse_patch, 根据 environment_id 选择 TurnEnvironment,再调用 verify_apply_patch_args_with_mode、权限准备和 ApplyPatchRuntime。shell 命令中的 apply_patch 拦截才使用 maybe_parse_apply_patch_verified_with_mode。 因此用户看到文件路径预览,不等于文件已经写入。

源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ApplyPatchHandler::handle_call

rust
let args = match codex_apply_patch::parse_patch(&patch_input) {
    Ok(args) => args,
    Err(parse_error) => {
        return Err(FunctionCallError::RespondToModel(format!(
            "apply_patch verification failed: {parse_error}"
        )));
    }
};
let selected_environment_id =
    require_environment_id(args.environment_id.as_deref(), self.multi_environment)?;
let Some(turn_environment) = resolve_tool_environment(
    &step_context.environments,
    selected_environment_id.as_deref(),
)? else {
    return Err(FunctionCallError::RespondToModel(
        "apply_patch is unavailable in this session".to_string(),
    ));
};
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment.sandbox_context(/*additional_permissions*/ None);
match codex_apply_patch::verify_apply_patch_args_with_mode(
    args,
    turn_environment.cwd(),
    apply_patch_file_update_mode(&turn),
    fs.as_ref(),
    Some(&sandbox),
).await {
    codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
        // Permission preparation and ApplyPatchRuntime execution follow.
    }
    // Error variants are returned to the model before mutation.
}

5. 两个验证 ​

5.1 consumer单测 ​

diff_consumer_streams_apply_patch_changes 先发送 Begin,再发送 Add header 和分段内容,断言第一次 event content 为空,finish 后 event content 包含完整两行。相邻测试还验证 Environment ID header 不进入 changes,以及 500ms 到期后可立即发送下一次快照。

源码位置:codex-rs/core/src/tools/handlers/apply_patch_tests.rs :: diff_consumer_streams_apply_patch_changes

text
cd codex-rs
cargo test -p codex-core --lib tools::handlers::apply_patch::tests::diff_consumer_streams_apply_patch_changes -- --test-threads=1
cargo test -p codex-core --lib tools::handlers::apply_patch::tests -- --test-threads=1

5.2 端到端事件 ​

apply_patch_custom_tool_streaming_emits_updated_changes 用 mock Responses SSE 发送 input delta 和最终 custom tool call,断言收到两次 PatchApplyUpdated:第一次 Add content 为空,第二次为完整两行;测试还在完整 调用结束后读取 streamed.txt,确认实际写入发生在正式 tool call 路径,而不是第一次 preview 时。

源码位置:codex-rs/core/tests/suite/apply_patch_cli.rs :: apply_patch_custom_tool_streaming_emits_updated_changes

text
cd codex-rs
RUST_MIN_STACK=8388608 cargo test -p codex-core --test all apply_patch_custom_tool_streaming_emits_updated_changes -- --test-threads=1

这些测试不证明每个 preview 都会被 UI 展示:feature 关闭、partial parse error、500ms 节流和 pending 替换都会减少事件数量。它们也不把 preview FileChange::Update.unified_diff 当作最终 filesystem diff。

6. 源码排查 ​

text
rg -n "ApplyPatchArgumentDiffConsumer|consume_diff|finish_update_on_complete" codex-rs/core/src/tools/handlers/apply_patch.rs
rg -n "PatchApplyUpdated|streaming_emits_updated_changes|APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL" codex-rs/core
rg -n "StreamingPatchParser|convert_apply_patch_hunks_to_protocol" codex-rs/core/src/tools/handlers

流式 Apply Patch 主线是:delta 进入 parser,当前 hunk 投影为节流后的 preview event;output item 完成时 flush pending,完整 custom input 再独立 parse/verify,之后才进入 approval、sandbox 和 filesystem apply。 下一篇ApplyPatch安全与原子性将分析权限、symlink 与部分提交边界。