Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/en/wework/developer-guide/wework-e2e-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ The model protocol matrix defines 18 combinations across execution location, mod

Matrix submissions use a 10-second timeout. If the composer already displays a submission error, the runner throws that exact error immediately. Otherwise, a stalled protocol stage reports its current stage and captured requests instead of waiting through the general UI timeout.

`e2e:desktop:streaming-text` runs an isolated streaming-message state regression through a scenario module. It uses the real Tauri WebView, Executor, and Codex app-server while a loopback Responses SSE keeps a partial reply active. The scenario first verifies that streaming reasoning appears as “Thinking · summary” and that the reasoning summary and its placeholder are removed after completion. It then starts a long-running command and confirms that the tool-row duration continues increasing after switching tasks while the tool-group header does not show a turn-wide aggregate duration. The scenario next builds a long multi-turn conversation beyond the virtualization threshold and verifies the “Thinking” position, user scroll anchor, streaming growth, and viewport stability after reopening the task. It also verifies that the waiting state disappears after completion. The scenario retains screenshots for reasoning, tool timing, ready, streaming, and completed stages; its scenario-specific Codex configuration disables plugin extensions to isolate direct message streaming.
`e2e:desktop:streaming-text` runs an isolated streaming-message state regression through a scenario module. It uses the real Tauri WebView, Executor, and Codex app-server while a loopback Responses SSE keeps a partial reply active. The scenario first streams one assistant item as `final_answer` and completes the same item as `commentary`. It verifies that Executor sends `response.block.created.replacesItemId`, allowing the frontend to atomically migrate the visible assistant text into a process block so the page contains one process copy and no final-answer copy. The scenario then verifies that streaming reasoning appears as “Thinking · summary” and that the reasoning summary and its placeholder are removed after completion. It starts a long-running command and confirms that the tool-row duration continues increasing after switching tasks while the tool-group header does not show a turn-wide aggregate duration. The scenario next builds a long multi-turn conversation beyond the virtualization threshold and verifies the “Thinking” position, user scroll anchor, streaming growth, and viewport stability after reopening the task. It also verifies that the waiting state disappears after completion. The scenario retains screenshots for phase reclassification, reasoning, tool timing, ready, streaming, and completed stages; its scenario-specific Codex configuration disables plugin extensions to isolate direct message streaming.

`e2e:desktop:embedded-browser` runs the embedded-browser Agent operation regression through a scenario module. It uses the real Tauri WebView, Executor, Codex app-server, and browser MCP server, opens a local fixture page, and verifies the current WKWebView bridge control path. The scenario covers bridge identity lookup, authenticated bridge requests, page open, structured `inspect`, `fill`, `click`, `wait`, `scroll`, `screenshot`, `capabilities`, high-risk action approval, and combined MCP tools such as `open_and_inspect` and `wait_and_inspect`. It also starts a long `waitFor` and then verifies an independent `click` is not blocked, preventing bridge concurrency regressions. Results are written to `embedded-browser-agent-result.json`.

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/wework/developer-guide/wework-e2e-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ node e2e/utils/mock-connector-upstream-server.mjs

矩阵提交等待上限为 10 秒。如果编辑器已经显示提交错误,runner 会立即抛出该错误;否则在协议阶段未按时推进时输出当前阶段和已捕获请求,避免失败后长时间无反馈。

`e2e:desktop:streaming-text` 通过场景模块运行独立的流式消息状态回归。它使用真实 Tauri WebView、Executor 和 Codex app-server,通过 loopback Responses SSE 保持部分回复处于运行状态。场景先验证流式推理显示“正在思考 · 摘要”,并在响应完成后移除推理摘要及其占位,再启动长命令,确认工具行耗时在切换任务后连续递增,同时工具分组标题不显示整轮累计耗时。随后场景构造超过虚拟化阈值的多轮长对话,验证“正在思考”位置、用户滚动锚点、流式增长和任务重开后的视口稳定性,并在响应完成后确认等待状态消失。该场景会保存推理、工具计时、就绪、流式和完成阶段的截图;场景专用 Codex 配置会关闭插件扩展,以隔离验证消息直出链路。
`e2e:desktop:streaming-text` 通过场景模块运行独立的流式消息状态回归。它使用真实 Tauri WebView、Executor 和 Codex app-server,通过 loopback Responses SSE 保持部分回复处于运行状态。场景先让同一个 assistant item 以 `final_answer` 开始流式输出、再以 `commentary` 完成,验证 Executor 通过 `response.block.created.replacesItemId` 通知前端把已显示正文原子迁移到过程块,页面只保留一份过程文本且最终回答区为空。随后场景验证流式推理显示“正在思考 · 摘要”,并在响应完成后移除推理摘要及其占位,再启动长命令,确认工具行耗时在切换任务后连续递增,同时工具分组标题不显示整轮累计耗时。场景还会构造超过虚拟化阈值的多轮长对话,验证“正在思考”位置、用户滚动锚点、流式增长和任务重开后的视口稳定性,并在响应完成后确认等待状态消失。该场景会保存阶段改判、推理、工具计时、就绪、流式和完成阶段的截图;场景专用 Codex 配置会关闭插件扩展,以隔离验证消息直出链路。

`e2e:desktop:embedded-browser` 通过场景模块运行内置浏览器 Agent 操作回归。它使用真实 Tauri WebView、Executor、Codex app-server 和 browser MCP server,打开本地 fixture 页面并通过当前 WKWebView bridge 验证浏览器控制链路。场景覆盖 bridge identity 读取、认证 bridge 请求、打开页面、结构化 `inspect`、`fill`、`click`、`wait`、`scroll`、`screenshot`、`capabilities`、高风险动作确认,以及 MCP 组合工具 `open_and_inspect` 和 `wait_and_inspect`。它还会启动一个长时间 `waitFor`,再验证独立 `click` 不会被阻塞,用于防止 bridge 并发退化。测试结果会写入 `embedded-browser-agent-result.json`。

Expand Down
13 changes: 13 additions & 0 deletions executor/src/agents/codex/run_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ impl CodexRunState {
}
if codex_phase_is_process(phase.as_deref()) {
let text = extract_text(item).unwrap_or_default();
self.clear_reclassified_final_message(codex_item_id(params));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
log_codex_run_state_text(
"completed",
"skip_process",
Expand Down Expand Up @@ -306,6 +307,18 @@ impl CodexRunState {
}
}

fn clear_reclassified_final_message(&mut self, item_id: Option<&str>) {
let Some(item_id) = item_id else {
return;
};
if self.final_message_id.as_deref() != Some(item_id) {
return;
}
self.final_text.clear();
self.final_message_id = None;
self.final_message_saw_delta = false;
}

fn begin_final_message(&mut self, item_id: Option<&str>, clear_on_change: bool) {
let Some(item_id) = item_id else {
return;
Expand Down
102 changes: 102 additions & 0 deletions executor/src/agents/codex/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,108 @@ fn codex_run_state_keeps_commentary_agent_delta_out_of_final_content() {
);
}

#[test]
fn codex_run_state_removes_streamed_final_text_reclassified_as_commentary() {
let mut state = CodexRunState::default();

for message in [
json!({
"method": "item/started",
"params": {
"item": {
"id": "msg-progress",
"type": "agentMessage",
"phase": "final_answer",
"text": ""
}
}
}),
json!({
"method": "item/agentMessage/delta",
"params": {
"itemId": "msg-progress",
"delta": "I will inspect."
}
}),
json!({
"method": "item/completed",
"params": {
"item": {
"id": "msg-progress",
"type": "agentMessage",
"phase": "commentary",
"text": "I will inspect."
}
}
}),
] {
assert!(state.handle_message(&message).is_none());
}

let outcome = state
.handle_message(&json!({
"method": "turn/completed",
"params": {
"turn": {
"status": "completed"
}
}
}))
.expect("turn completion should produce an outcome");

assert_eq!(
outcome,
ExecutionOutcome::Completed {
content: String::new()
}
);
}

#[test]
fn codex_run_state_does_not_reclassify_messages_without_item_ids() {
let mut state = CodexRunState::default();

for message in [
json!({
"method": "item/agentMessage/delta",
"params": {
"phase": "final_answer",
"delta": "Final answer."
}
}),
json!({
"method": "item/completed",
"params": {
"item": {
"type": "agentMessage",
"phase": "commentary",
"text": "I will inspect."
}
}
}),
] {
assert!(state.handle_message(&message).is_none());
}

let outcome = state
.handle_message(&json!({
"method": "turn/completed",
"params": {
"turn": {
"status": "completed"
}
}
}))
.expect("turn completion should produce an outcome");

assert_eq!(
outcome,
ExecutionOutcome::Completed {
content: "Final answer.".to_owned()
}
);
}

#[test]
fn goal_created_during_turn_keeps_notification_reader_alive() {
let mut state = CodexRunState::default();
Expand Down
106 changes: 95 additions & 11 deletions executor/src/runtime_work/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -754,24 +754,36 @@ impl CodexNotificationEventMapper {
self.process_text_count
)
});
let replaces_item_id = item_id
.as_ref()
.filter(|item_id| self.final_message_id.as_ref() == Some(item_id))
.cloned();
let reclassified = replaces_item_id.is_some();
let mut data = json!({
"block": {
"id": id,
"type": block_type,
"process_kind": process_kind,
"process_item_id": item_id,
"content": text,
"status": "done",
"timestamp": now_ms(),
}
});
if let Some(replaces_item_id) = replaces_item_id {
data["replacesItemId"] = Value::String(replaces_item_id);
}
emit_response_event(
emit_context.event_tx,
emit_context.device_id,
"response.block.created",
emit_context.local_task_id,
emit_context.request,
json!({
"block": {
"id": id,
"type": block_type,
"process_kind": process_kind,
"process_item_id": item_id,
"content": text,
"status": "done",
"timestamp": now_ms(),
}
}),
data,
);
if reclassified {
self.reset_final_text();
}
}

fn emit_text_chunk(
Expand Down Expand Up @@ -2287,6 +2299,78 @@ mod tests {
assert!(event_rx.try_recv().is_err());
}

#[test]
fn reclassifies_streamed_final_text_as_one_commentary_block() {
let (event_tx, mut event_rx) = broadcast::channel(4);
let request = ExecutionRequest {
task_id: "7".to_owned(),
subtask_id: "8".to_owned(),
..ExecutionRequest::default()
};
let mut mapper = CodexNotificationEventMapper::default();

for message in [
json!({
"method": "item/started",
"params": {
"item": {
"id": "msg-progress",
"type": "agentMessage",
"phase": "final_answer",
"text": ""
}
}
}),
json!({
"method": "item/agentMessage/delta",
"params": {
"itemId": "msg-progress",
"delta": "I will inspect."
}
}),
json!({
"method": "item/completed",
"params": {
"item": {
"id": "msg-progress",
"type": "agentMessage",
"phase": "commentary",
"text": "I will inspect."
}
}
}),
] {
mapper.map(
&Some(event_tx.clone()),
"device-1",
"local-1",
&request,
message,
);
}

let streamed = event_rx
.try_recv()
.expect("streamed final text should be emitted");
let reclassified = event_rx
.try_recv()
.expect("reclassified commentary block should be emitted");

assert_eq!(streamed["event"], "response.output_text.delta");
assert_eq!(streamed["payload"]["data"]["itemId"], "msg-progress");
assert_eq!(reclassified["event"], "response.block.created");
assert_eq!(
reclassified["payload"]["data"]["replacesItemId"],
"msg-progress"
);
assert_eq!(
reclassified["payload"]["data"]["block"]["content"],
"I will inspect."
);
assert_eq!(reclassified["payload"]["data"]["block"]["type"], "text");
assert!(event_rx.try_recv().is_err());
}

#[test]
fn maps_codex_context_compacted_event_to_completed_tool_block() {
let (event_tx, mut event_rx) = broadcast::channel(4);
Expand Down
1 change: 1 addition & 0 deletions packages/chat-core/src/workbench-message-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export type WorkbenchMessageAction<
messageId?: string
subtaskId?: string
block: WorkbenchProcessingBlock<TFileChanges>
replaceAssistantTextItemId?: string
}
| {
type: 'block_updated'
Expand Down
Loading
Loading