From 9bac14bf276fbc644b849e1458d91214efd65a27 Mon Sep 17 00:00:00 2001 From: sifang Date: Fri, 26 Jun 2026 18:44:20 +0800 Subject: [PATCH] feat(wework): mirror codex session view --- executor/src/runtime_work/handler.rs | 11 +- executor/src/runtime_work/transcript.rs | 894 +++++++++++++++++- executor/src/runtime_work/util.rs | 12 +- .../app_runtime_work_file_changes_contract.rs | 340 +++++++ pnpm-lock.yaml | 6 + wework/package.json | 2 + wework/src/api/local/localServices.ts | 143 ++- .../src/components/chat/AssistantMarkdown.tsx | 255 +++++ .../components/chat/CodexTurnArtifacts.tsx | 209 ++++ .../components/chat/FileChangesCard.test.tsx | 237 ++++- .../src/components/chat/FileChangesCard.tsx | 659 +++++++++++-- .../src/components/chat/MarkdownCodeBlock.tsx | 270 ++++++ .../src/components/chat/MessageList.test.tsx | 314 +++++- wework/src/components/chat/MessageList.tsx | 744 +++++---------- .../components/chat/assistantMarkdownLinks.ts | 99 ++ .../chat/blocks/ToolBlockItem.test.tsx | 138 +++ .../components/chat/blocks/ToolBlockItem.tsx | 21 +- .../chat/blocks/ToolBlocksDisplay.test.tsx | 26 + .../chat/blocks/ToolBlocksDisplay.tsx | 138 ++- .../chat/blocks/toolBlockActivity.test.ts | 21 +- .../chat/blocks/toolBlockActivity.ts | 14 +- wework/src/components/chat/codexReferences.ts | 107 +++ .../features/workbench/WorkbenchProvider.tsx | 46 +- wework/src/i18n/locales/en/chat.json | 24 +- wework/src/i18n/locales/zh-CN/chat.json | 24 +- wework/src/types/api.ts | 39 + wework/src/types/workbench.ts | 6 + 27 files changed, 4031 insertions(+), 768 deletions(-) create mode 100644 wework/src/components/chat/AssistantMarkdown.tsx create mode 100644 wework/src/components/chat/CodexTurnArtifacts.tsx create mode 100644 wework/src/components/chat/MarkdownCodeBlock.tsx create mode 100644 wework/src/components/chat/assistantMarkdownLinks.ts create mode 100644 wework/src/components/chat/codexReferences.ts diff --git a/executor/src/runtime_work/handler.rs b/executor/src/runtime_work/handler.rs index 0f840ef7666..6c54c19dd93 100644 --- a/executor/src/runtime_work/handler.rs +++ b/executor/src/runtime_work/handler.rs @@ -217,8 +217,13 @@ impl RuntimeWorkRpcHandler { .unwrap_or_default(); let messages = local_link .as_ref() - .map(|link| merge_cached_messages(transcript_messages(thread), cached_messages(link))) - .unwrap_or_else(|| transcript_messages(thread)); + .map(|link| { + merge_cached_messages( + transcript_messages(thread, &self.device_id), + cached_messages(link), + ) + }) + .unwrap_or_else(|| transcript_messages(thread, &self.device_id)); Ok(json!({ "success": true, @@ -866,7 +871,7 @@ impl RuntimeWorkRpcHandler { { Ok(response) => { let thread = response.get("thread").unwrap_or(&response); - transcript_messages(thread) + transcript_messages(thread, &self.device_id) } Err(error) => { eprintln!("failed to read Codex app-server thread {thread_id}: {error}"); diff --git a/executor/src/runtime_work/transcript.rs b/executor/src/runtime_work/transcript.rs index bbd75aaad41..f667b23e4fe 100644 --- a/executor/src/runtime_work/transcript.rs +++ b/executor/src/runtime_work/transcript.rs @@ -2,24 +2,32 @@ // // SPDX-License-Identifier: Apache-2.0 -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use super::util::{ - extract_text, item_id, item_type, now_ms, raw_string_field, reasoning_content, string_field, - timestamp_ms_field, + bool_field, extract_text, integer_field, item_id, item_type, normalize_workspace_path, now_ms, + raw_string_field, reasoning_content, string_field, timestamp_ms_field, }; -pub(crate) fn transcript_messages(thread: &Value) -> Vec { +pub(crate) fn transcript_messages(thread: &Value, device_id: &str) -> Vec { let mut messages = Vec::new(); + let workspace_path = string_field(thread, "cwd").unwrap_or_default(); for turn in thread .get("turns") .and_then(Value::as_array) .into_iter() .flatten() { - let created_at = timestamp_ms_field(turn, "createdAt").unwrap_or_else(now_ms); + let created_at = turn_started_at(turn); + let completed_at = turn_completed_at(turn, created_at); let turn_file_changes = file_changes(turn); + let turn_id = item_id(turn, "turn"); + let subtask_id = turn_subtask_id(turn, &turn_id); let mut blocks = Vec::new(); + let mut pending_file_changes = turn_file_changes.clone(); + let mut context_events = Vec::new(); + let mut assistant_parts = Vec::new(); + let mut memory_citations = Vec::new(); for item in turn .get("items") .and_then(Value::as_array) @@ -30,19 +38,108 @@ pub(crate) fn transcript_messages(thread: &Value) -> Vec { "usermessage" => push_user_message(&mut messages, item, created_at), "reasoning" => push_reasoning_block(&mut blocks, item, created_at), "commandexecution" => blocks.push(command_block(item, created_at)), - "agentmessage" | "message" => { - push_assistant_message( - &mut messages, + "functioncall" | "customtoolcall" | "dynamictoolcall" | "mcptoolcall" + | "toolsearchcall" | "websearch" | "imagegeneration" | "imageview" | "sleep" + | "localshellcall" | "shellcall" => blocks.push(tool_block(item, created_at)), + "functioncalloutput" | "customtoolcalloutput" | "toolsearchoutput" => { + merge_tool_output(&mut blocks, item, created_at); + } + "filechange" => { + if let Some(summary) = file_changes_from_file_change_item( + item, + &turn_id, + device_id, + &workspace_path, + ) { + pending_file_changes = merge_file_changes(pending_file_changes, summary); + } + } + "patchapplyend" => { + if let Some(summary) = file_changes_from_patch_apply_end( + item, + &turn_id, + device_id, + &workspace_path, + ) { + pending_file_changes = merge_file_changes(pending_file_changes, summary); + } + } + "contextcompaction" => { + context_events.push(context_event( item, created_at, - &blocks, - file_changes(item).or_else(|| turn_file_changes.clone()), + "context_compaction", + "done", + )); + } + "agentmessage" => { + collect_assistant_message( + item, + created_at, + &mut blocks, + &mut assistant_parts, + &mut memory_citations, + ); + if let Some(file_changes) = file_changes(item) { + pending_file_changes = + merge_file_changes(pending_file_changes, file_changes); + } + } + "message" => match string_field(item, "role") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "user" => push_user_message(&mut messages, item, created_at), + "assistant" => { + collect_assistant_message( + item, + created_at, + &mut blocks, + &mut assistant_parts, + &mut memory_citations, + ); + if let Some(file_changes) = file_changes(item) { + pending_file_changes = + merge_file_changes(pending_file_changes, file_changes); + } + } + _ => {} + }, + "agentmessageevent" => { + collect_assistant_message( + item, + created_at, + &mut blocks, + &mut assistant_parts, + &mut memory_citations, ); - blocks.clear(); + if let Some(file_changes) = file_changes(item) { + pending_file_changes = + merge_file_changes(pending_file_changes, file_changes); + } } _ => {} } } + if !blocks.is_empty() + || pending_file_changes.is_some() + || !context_events.is_empty() + || !assistant_parts.is_empty() + || !memory_citations.is_empty() + { + apply_turn_completed_at(&mut blocks, completed_at); + messages.push(synthetic_assistant_message(AssistantMessageDraft { + turn_id: &turn_id, + subtask_id, + created_at, + blocks: &blocks, + file_changes: pending_file_changes, + context_events: &context_events, + assistant_parts: &assistant_parts, + memory_citations: &memory_citations, + })); + } } messages } @@ -50,35 +147,77 @@ pub(crate) fn transcript_messages(thread: &Value) -> Vec { pub(crate) fn tool_block_from_notification(params: &Value, status: &str) -> Option { let item = params.get("item").unwrap_or(params); let item_type = item_type(item); - if !matches!(item_type.as_str(), "commandexecution" | "shellcall") { + if !is_tool_item_type(&item_type) { return None; } - Some(json!({ - "id": item_id(item, "tool"), - "type": "tool", - "tool_use_id": item_id(item, "tool"), - "tool_name": "bash", - "tool_input": command_input(item), - "status": status, - "timestamp": now_ms(), - })) + let mut block = tool_block(item, now_ms()); + if let Some(object) = block.as_object_mut() { + object.insert("status".to_owned(), Value::String(status.to_owned())); + } + Some(block) } pub(crate) fn tool_update_from_notification(params: &Value) -> Option<(String, Value)> { let item = params.get("item").unwrap_or(params); let item_type = item_type(item); - if !matches!(item_type.as_str(), "commandexecution" | "shellcall") { + if !is_tool_item_type(&item_type) && !is_tool_output_item_type(&item_type) { return None; } Some(( - item_id(item, "tool"), + tool_call_id(item), json!({ - "status": "done", - "tool_output": command_output(item), + "status": tool_status(item), + "tool_output": tool_output(item), }), )) } +fn turn_started_at(turn: &Value) -> i64 { + timestamp_ms_field(turn, "startedAt") + .or_else(|| timestamp_ms_field(turn, "started_at")) + .or_else(|| timestamp_ms_field(turn, "createdAt")) + .or_else(|| timestamp_ms_field(turn, "created_at")) + .unwrap_or_else(now_ms) +} + +fn turn_completed_at(turn: &Value, started_at: i64) -> Option { + timestamp_ms_field(turn, "completedAt") + .or_else(|| timestamp_ms_field(turn, "completed_at")) + .or_else(|| { + integer_field(turn, "durationMs") + .or_else(|| integer_field(turn, "duration_ms")) + .map(|duration| started_at.saturating_add(duration)) + }) + .filter(|completed_at| *completed_at >= started_at) +} + +fn turn_subtask_id(turn: &Value, turn_id: &str) -> i64 { + integer_field(turn, "subtaskId") + .or_else(|| integer_field(turn, "subtask_id")) + .filter(|value| *value != 0) + .unwrap_or_else(|| synthetic_turn_subtask_id(turn_id)) +} + +fn synthetic_turn_subtask_id(turn_id: &str) -> i64 { + let mut hash = 2_166_136_261_u32; + for byte in turn_id.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(16_777_619); + } + let value = i64::from(hash & 0x7fff_ffff); + -value.max(1) +} + +fn apply_turn_completed_at(blocks: &mut [Value], completed_at: Option) { + let Some(completed_at) = completed_at else { + return; + }; + let Some(block) = blocks.last_mut().and_then(Value::as_object_mut) else { + return; + }; + block.insert("timestamp".to_owned(), json!(completed_at)); +} + fn push_user_message(messages: &mut Vec, item: &Value, created_at: i64) { if let Some(content) = extract_text(item) { messages.push(json!({ @@ -103,33 +242,107 @@ fn push_reasoning_block(blocks: &mut Vec, item: &Value, created_at: i64) } } -fn push_assistant_message( - messages: &mut Vec, +fn collect_assistant_message( item: &Value, - created_at: i64, - blocks: &[Value], - file_changes: Option, + fallback_timestamp: i64, + blocks: &mut Vec, + assistant_parts: &mut Vec, + memory_citations: &mut Vec, ) { if let Some(content) = extract_text(item) { - let mut message = json!({ - "id": item_id(item, "assistant"), - "role": "assistant", - "content": content, - "status": "done", - "createdAt": created_at, - "blocks": blocks, - }); - if let Some(file_changes) = file_changes { - if let Some(object) = message.as_object_mut() { - object.insert("fileChanges".to_owned(), file_changes); + match assistant_message_phase(item) { + AssistantMessagePhase::Process => { + blocks.push(process_text_block(item, content, fallback_timestamp)); + } + AssistantMessagePhase::Final => { + assistant_parts.push(content); } } - messages.push(message); } + if let Some(memory_citation) = memory_citation(item) { + memory_citations.push(memory_citation); + } +} + +enum AssistantMessagePhase { + Final, + Process, +} + +fn assistant_message_phase(item: &Value) -> AssistantMessagePhase { + let phase = string_field(item, "phase") + .unwrap_or_default() + .replace(['_', '-'], "") + .to_ascii_lowercase(); + match phase.as_str() { + "commentary" | "analysis" => AssistantMessagePhase::Process, + _ => AssistantMessagePhase::Final, + } +} + +fn process_text_block(item: &Value, content: String, fallback_timestamp: i64) -> Value { + json!({ + "id": item_id(item, "text"), + "type": "text", + "content": content, + "status": "done", + "timestamp": item_timestamp(item).unwrap_or(fallback_timestamp), + }) +} + +fn item_timestamp(item: &Value) -> Option { + timestamp_ms_field(item, "timestamp") + .or_else(|| timestamp_ms_field(item, "createdAt")) + .or_else(|| timestamp_ms_field(item, "created_at")) +} + +struct AssistantMessageDraft<'a> { + turn_id: &'a str, + subtask_id: i64, + created_at: i64, + blocks: &'a [Value], + file_changes: Option, + context_events: &'a [Value], + assistant_parts: &'a [String], + memory_citations: &'a [Value], +} + +fn synthetic_assistant_message(draft: AssistantMessageDraft<'_>) -> Value { + let mut message = json!({ + "id": format!("assistant-{}", draft.turn_id), + "role": "assistant", + "content": draft.assistant_parts.join("\n\n"), + "status": "done", + "subtaskId": draft.subtask_id, + "subtask_id": draft.subtask_id, + "createdAt": draft.created_at, + "blocks": draft.blocks, + }); + if let Some(file_changes) = draft.file_changes { + if let Some(object) = message.as_object_mut() { + object.insert("fileChanges".to_owned(), file_changes); + } + } + if !draft.context_events.is_empty() { + if let Some(object) = message.as_object_mut() { + object.insert( + "contextEvents".to_owned(), + Value::Array(draft.context_events.to_vec()), + ); + } + } + if !draft.memory_citations.is_empty() { + if let Some(object) = message.as_object_mut() { + object.insert( + "memoryCitations".to_owned(), + Value::Array(draft.memory_citations.to_vec()), + ); + } + } + message } fn command_block(item: &Value, timestamp: i64) -> Value { - let status = string_field(item, "status").unwrap_or_else(|| "completed".to_owned()); json!({ "id": item_id(item, "tool"), "type": "tool", @@ -137,28 +350,246 @@ fn command_block(item: &Value, timestamp: i64) -> Value { "tool_name": "bash", "tool_input": command_input(item), "tool_output": command_output(item), - "status": if status.eq_ignore_ascii_case("failed") || status.eq_ignore_ascii_case("error") { - "error" - } else { - "done" - }, + "status": tool_status(item), + "timestamp": timestamp, + }) +} + +fn tool_block(item: &Value, timestamp: i64) -> Value { + if matches!( + item_type(item).as_str(), + "commandexecution" | "shellcall" | "localshellcall" + ) { + return command_block(item, timestamp); + } + json!({ + "id": tool_call_id(item), + "type": "tool", + "tool_use_id": tool_call_id(item), + "tool_name": tool_name(item), + "tool_input": tool_input(item), + "tool_output": tool_output(item), + "status": tool_status(item), "timestamp": timestamp, }) } +fn merge_tool_output(blocks: &mut Vec, item: &Value, timestamp: i64) { + let call_id = tool_call_id(item); + if let Some(block) = blocks.iter_mut().rev().find(|block| { + block + .get("tool_use_id") + .and_then(Value::as_str) + .is_some_and(|value| value == call_id) + }) { + if let Some(object) = block.as_object_mut() { + object.insert("tool_output".to_owned(), tool_output(item)); + object.insert("status".to_owned(), Value::String(tool_status(item))); + } + return; + } + blocks.push(json!({ + "id": call_id, + "type": "tool", + "tool_use_id": tool_call_id(item), + "tool_name": tool_name(item), + "tool_output": tool_output(item), + "status": tool_status(item), + "timestamp": timestamp, + })); +} + fn command_input(item: &Value) -> Value { json!({ - "command": string_field(item, "command").unwrap_or_default(), + "command": string_field(item, "command") + .or_else(|| command_from_local_shell_action(item)) + .unwrap_or_default(), "cwd": string_field(item, "cwd").unwrap_or_default(), }) } fn command_output(item: &Value) -> String { raw_string_field(item, "aggregatedOutput") + .or_else(|| raw_string_field(item, "aggregated_output")) .or_else(|| raw_string_field(item, "output")) .unwrap_or_default() } +fn command_from_local_shell_action(item: &Value) -> Option { + item.get("action").and_then(|action| { + string_field(action, "command") + .or_else(|| string_field(action, "cmd")) + .or_else(|| string_field(action, "commandLine")) + }) +} + +fn is_tool_item_type(item_type: &str) -> bool { + matches!( + item_type, + "commandexecution" + | "shellcall" + | "localshellcall" + | "functioncall" + | "customtoolcall" + | "dynamictoolcall" + | "mcptoolcall" + | "toolsearchcall" + | "websearch" + | "imagegeneration" + | "imageview" + | "sleep" + ) +} + +fn is_tool_output_item_type(item_type: &str) -> bool { + matches!( + item_type, + "functioncalloutput" | "customtoolcalloutput" | "toolsearchoutput" + ) +} + +fn tool_call_id(item: &Value) -> String { + string_field(item, "call_id") + .or_else(|| string_field(item, "callId")) + .or_else(|| string_field(item, "id")) + .unwrap_or_else(|| format!("tool-{}", now_ms())) +} + +fn tool_name(item: &Value) -> String { + match item_type(item).as_str() { + "functioncall" | "functioncalloutput" => string_field(item, "name") + .or_else(|| string_field(item, "tool")) + .unwrap_or_else(|| "function_call".to_owned()), + "customtoolcall" | "customtoolcalloutput" => string_field(item, "name") + .or_else(|| string_field(item, "tool")) + .unwrap_or_else(|| "custom_tool".to_owned()), + "dynamictoolcall" => { + string_field(item, "tool").unwrap_or_else(|| "dynamic_tool".to_owned()) + } + "mcptoolcall" => { + let server = string_field(item, "server"); + let tool = string_field(item, "tool").unwrap_or_else(|| "mcp_tool".to_owned()); + server + .map(|server| format!("{server}.{tool}")) + .unwrap_or(tool) + } + "toolsearchcall" | "toolsearchoutput" => "tool_search".to_owned(), + "websearch" => "web_search".to_owned(), + "imagegeneration" => "image_generation".to_owned(), + "imageview" => "view_image".to_owned(), + "sleep" => "sleep".to_owned(), + _ => "tool".to_owned(), + } +} + +fn tool_input(item: &Value) -> Value { + match item_type(item).as_str() { + "functioncall" => parse_json_object_string(item, "arguments").unwrap_or_else( + || json!({"arguments": raw_string_field(item, "arguments").unwrap_or_default()}), + ), + "customtoolcall" => json!({"input": raw_string_field(item, "input").unwrap_or_default()}), + "dynamictoolcall" | "toolsearchcall" => { + item.get("arguments").cloned().unwrap_or(Value::Null) + } + "mcptoolcall" => item.get("arguments").cloned().unwrap_or(Value::Null), + "websearch" => json!({"query": string_field(item, "query").unwrap_or_default()}), + "imageview" => json!({"path": string_field(item, "path").unwrap_or_default()}), + "sleep" => { + json!({"duration_ms": item.get("durationMs").or_else(|| item.get("duration_ms")).cloned().unwrap_or(Value::Null)}) + } + "imagegeneration" => { + json!({"revised_prompt": string_field(item, "revisedPrompt").or_else(|| string_field(item, "revised_prompt")).unwrap_or_default()}) + } + _ => Value::Object(Map::new()), + } +} + +fn parse_json_object_string(item: &Value, key: &str) -> Option { + let text = raw_string_field(item, key)?; + serde_json::from_str::(&text) + .ok() + .filter(Value::is_object) +} + +fn tool_output(item: &Value) -> Value { + match item_type(item).as_str() { + "commandexecution" | "shellcall" | "localshellcall" => Value::String(command_output(item)), + "functioncalloutput" | "customtoolcalloutput" => output_payload_text(item) + .map(Value::String) + .unwrap_or_else(|| item.get("output").cloned().unwrap_or(Value::Null)), + "toolsearchoutput" => item.get("results").cloned().unwrap_or_else(|| { + output_payload_text(item) + .map(Value::String) + .unwrap_or(Value::Null) + }), + "dynamictoolcall" => item + .get("contentItems") + .or_else(|| item.get("content_items")) + .map(output_content_items_text) + .map(Value::String) + .unwrap_or_else(|| item.get("result").cloned().unwrap_or(Value::Null)), + "mcptoolcall" => item + .get("error") + .and_then(|error| string_field(error, "message")) + .map(Value::String) + .or_else(|| item.get("result").cloned()) + .unwrap_or(Value::Null), + "imagegeneration" => string_field(item, "savedPath") + .or_else(|| string_field(item, "saved_path")) + .or_else(|| raw_string_field(item, "result")) + .map(Value::String) + .unwrap_or(Value::Null), + _ => Value::Null, + } +} + +fn output_payload_text(item: &Value) -> Option { + let output = item.get("output")?; + output + .as_str() + .map(str::to_owned) + .or_else(|| Some(output_content_items_text(output)).filter(|value| !value.is_empty())) +} + +fn output_content_items_text(value: &Value) -> String { + value + .as_array() + .into_iter() + .flatten() + .filter_map(|part| { + part.as_str().map(str::to_owned).or_else(|| { + part.get("text") + .or_else(|| part.get("content")) + .or_else(|| part.get("inputText")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + }) + .collect::>() + .join("\n") +} + +fn tool_status(item: &Value) -> String { + let status = string_field(item, "status").unwrap_or_else(|| "completed".to_owned()); + if status.eq_ignore_ascii_case("failed") + || status.eq_ignore_ascii_case("failure") + || status.eq_ignore_ascii_case("error") + || bool_field(item, "success").is_some_and(|success| !success) + || item.get("error").is_some() + { + "error".to_owned() + } else if status.eq_ignore_ascii_case("completed") + || status.eq_ignore_ascii_case("complete") + || status.eq_ignore_ascii_case("done") + || status.eq_ignore_ascii_case("succeeded") + || bool_field(item, "success").is_some_and(|success| success) + { + "done".to_owned() + } else { + "pending".to_owned() + } +} + fn file_changes(value: &Value) -> Option { value .get("fileChanges") @@ -166,3 +597,368 @@ fn file_changes(value: &Value) -> Option { .filter(|value| value.is_object()) .cloned() } + +fn merge_file_changes(existing: Option, next: Value) -> Option { + let Some(mut current) = existing else { + return Some(next); + }; + let Some(current_object) = current.as_object_mut() else { + return Some(next); + }; + let Some(next_object) = next.as_object() else { + return Some(current); + }; + + let mut files = current_object + .get("files") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for next_file in next_object + .get("files") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(next_path) = string_field(next_file, "path") else { + continue; + }; + if let Some(existing_index) = files + .iter() + .position(|file| string_field(file, "path").is_some_and(|path| path == next_path)) + { + files[existing_index] = next_file.clone(); + } else { + files.push(next_file.clone()); + } + } + if files.is_empty() { + return Some(current); + } + + for key in [ + "status", + "device_id", + "workspace_path", + "reverted_at", + "revertible", + ] { + if let Some(value) = next_object.get(key) { + current_object.insert(key.to_owned(), value.clone()); + } + } + + let additions = files + .iter() + .filter_map(|file| file.get("additions").and_then(Value::as_i64)) + .sum::(); + let deletions = files + .iter() + .filter_map(|file| file.get("deletions").and_then(Value::as_i64)) + .sum::(); + current_object.insert("file_count".to_owned(), json!(files.len())); + current_object.insert("additions".to_owned(), json!(additions)); + current_object.insert("deletions".to_owned(), json!(deletions)); + current_object.insert("files".to_owned(), Value::Array(files)); + + let combined_diff = [ + current_object.get("diff").and_then(Value::as_str), + next_object.get("diff").and_then(Value::as_str), + ] + .into_iter() + .flatten() + .filter(|diff| !diff.trim().is_empty()) + .collect::>() + .join("\n"); + if combined_diff.is_empty() { + current_object.remove("diff"); + } else { + current_object.insert("diff".to_owned(), Value::String(combined_diff)); + } + + Some(current) +} + +fn file_changes_from_file_change_item( + item: &Value, + turn_id: &str, + device_id: &str, + workspace_path: &str, +) -> Option { + let status = string_field(item, "status").unwrap_or_else(|| "completed".to_owned()); + if !status.eq_ignore_ascii_case("completed") { + return None; + } + let changes = item.get("changes")?.as_array()?; + let files = changes + .iter() + .filter_map(|change| file_change_from_codex_change(change, workspace_path)) + .collect::>(); + file_changes_summary( + &item_id(item, "file-change"), + turn_id, + device_id, + workspace_path, + files, + combined_diff_from_file_change_item(item, workspace_path), + ) +} + +fn file_changes_from_patch_apply_end( + item: &Value, + turn_id: &str, + device_id: &str, + workspace_path: &str, +) -> Option { + if bool_field(item, "success").is_some_and(|success| !success) { + return None; + } + let changes = item.get("changes")?.as_object()?; + let files = changes + .iter() + .filter_map(|(path, change)| file_change_from_patch_change(path, change, workspace_path)) + .collect::>(); + file_changes_summary( + &item_id(item, "patch"), + turn_id, + device_id, + workspace_path, + files, + combined_diff_from_patch_apply_end(item, workspace_path), + ) +} + +fn file_changes_summary( + item_id: &str, + turn_id: &str, + device_id: &str, + workspace_path: &str, + files: Vec, + diff: Option, +) -> Option { + if files.is_empty() { + return None; + } + let additions = files + .iter() + .filter_map(|file| file.get("additions").and_then(Value::as_i64)) + .sum::(); + let deletions = files + .iter() + .filter_map(|file| file.get("deletions").and_then(Value::as_i64)) + .sum::(); + let mut summary = json!({ + "version": 1, + "status": "active", + "artifact_id": format!("codex-{turn_id}-{item_id}"), + "device_id": device_id, + "workspace_path": workspace_path, + "file_count": files.len(), + "additions": additions, + "deletions": deletions, + "files": files, + "reverted_at": Value::Null, + "revertible": false, + }); + if let Some(diff) = diff.filter(|diff| !diff.trim().is_empty()) { + if let Some(object) = summary.as_object_mut() { + object.insert("diff".to_owned(), Value::String(diff)); + } + } + Some(summary) +} + +fn file_change_from_codex_change(change: &Value, workspace_path: &str) -> Option { + let path = workspace_relative_path(&string_field(change, "path")?, workspace_path); + let kind = change + .get("kind") + .and_then(Value::as_object) + .and_then(|kind| kind.get("type").and_then(Value::as_str)) + .unwrap_or("update") + .to_ascii_lowercase(); + let diff = raw_string_field(change, "diff").unwrap_or_default(); + let move_path = change + .get("kind") + .and_then(|kind| string_field(kind, "movePath").or_else(|| string_field(kind, "move_path"))) + .map(|path| workspace_relative_path(&path, workspace_path)); + let change_type = match kind.as_str() { + "add" | "create" | "created" => "created", + "delete" | "deleted" => "deleted", + "update" if move_path.is_some() => "renamed", + _ => "modified", + }; + let (additions, deletions) = diff_stats(&diff, change_type); + Some(json!({ + "old_path": if change_type == "renamed" { move_path } else { None }, + "path": path, + "change_type": change_type, + "additions": additions, + "deletions": deletions, + "binary": false, + })) +} + +fn file_change_from_patch_change( + path: &str, + change: &Value, + workspace_path: &str, +) -> Option { + let kind = string_field(change, "type").unwrap_or_else(|| "update".to_owned()); + let diff = raw_string_field(change, "unified_diff") + .or_else(|| raw_string_field(change, "diff")) + .or_else(|| raw_string_field(change, "content")) + .unwrap_or_default(); + let path = workspace_relative_path(path, workspace_path); + let move_path = string_field(change, "move_path") + .or_else(|| string_field(change, "movePath")) + .map(|path| workspace_relative_path(&path, workspace_path)); + let change_type = match kind.to_ascii_lowercase().as_str() { + "add" | "create" | "created" => "created", + "delete" | "deleted" => "deleted", + "update" if move_path.is_some() => "renamed", + _ => "modified", + }; + let (additions, deletions) = diff_stats(&diff, change_type); + Some(json!({ + "old_path": if change_type == "renamed" { move_path } else { None }, + "path": path, + "change_type": change_type, + "additions": additions, + "deletions": deletions, + "binary": false, + })) +} + +fn workspace_relative_path(path: &str, workspace_path: &str) -> String { + let trimmed_path = path.trim(); + let normalized_path = normalize_workspace_path(trimmed_path); + let normalized_workspace = normalize_workspace_path(workspace_path); + if normalized_path.is_empty() || normalized_workspace.is_empty() { + return trimmed_path.replace('\\', "/"); + } + + let workspace_prefix = normalized_workspace.trim_end_matches(['/', '\\']); + if normalized_path == workspace_prefix { + return String::new(); + } + if let Some(relative_path) = normalized_path.strip_prefix(workspace_prefix) { + if relative_path.starts_with('/') || relative_path.starts_with('\\') { + return relative_path + .trim_start_matches(['/', '\\']) + .replace('\\', "/"); + } + } + + trimmed_path.replace('\\', "/") +} + +fn diff_stats(diff: &str, change_type: &str) -> (i64, i64) { + let additions = diff + .lines() + .filter(|line| line.starts_with('+') && !line.starts_with("+++")) + .count() as i64; + let deletions = diff + .lines() + .filter(|line| line.starts_with('-') && !line.starts_with("---")) + .count() as i64; + if additions > 0 || deletions > 0 { + return (additions, deletions); + } + let line_count = diff.lines().filter(|line| !line.trim().is_empty()).count() as i64; + match change_type { + "created" => (line_count, 0), + "deleted" => (0, line_count), + _ => (0, 0), + } +} + +fn combined_diff_from_file_change_item(item: &Value, workspace_path: &str) -> Option { + let diff = item + .get("changes")? + .as_array()? + .iter() + .filter_map(|change| { + let path = string_field(change, "path")?; + let move_path = change.get("kind").and_then(|kind| { + string_field(kind, "movePath").or_else(|| string_field(kind, "move_path")) + }); + raw_string_field(change, "diff").map(|diff| { + diff_with_file_header(&path, move_path.as_deref(), &diff, workspace_path) + }) + }) + .collect::>() + .join("\n"); + Some(diff).filter(|diff| !diff.is_empty()) +} + +fn combined_diff_from_patch_apply_end(item: &Value, workspace_path: &str) -> Option { + let diff = item + .get("changes")? + .as_object()? + .iter() + .filter_map(|(path, change)| { + let move_path = + string_field(change, "move_path").or_else(|| string_field(change, "movePath")); + raw_string_field(change, "unified_diff") + .or_else(|| raw_string_field(change, "diff")) + .or_else(|| raw_string_field(change, "content")) + .map(|diff| { + diff_with_file_header(path, move_path.as_deref(), &diff, workspace_path) + }) + }) + .collect::>() + .join("\n"); + Some(diff).filter(|diff| !diff.is_empty()) +} + +fn diff_with_file_header( + path: &str, + old_path: Option<&str>, + diff: &str, + workspace_path: &str, +) -> String { + if diff.trim_start().starts_with("diff --git ") { + return diff.to_owned(); + } + + let relative_path = workspace_relative_path(path, workspace_path); + if relative_path.is_empty() { + return diff.to_owned(); + } + + let relative_old_path = old_path + .map(|path| workspace_relative_path(path, workspace_path)) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| relative_path.clone()); + format!( + "diff --git {} {}\n{}", + diff_git_path("a", &relative_old_path), + diff_git_path("b", &relative_path), + diff.trim_end() + ) +} + +fn diff_git_path(prefix: &str, path: &str) -> String { + let path = format!("{prefix}/{}", path.replace('\\', "/")); + if path.chars().any(char::is_whitespace) { + format!("\"{path}\"") + } else { + path + } +} + +fn context_event(item: &Value, timestamp: i64, event_type: &str, status: &str) -> Value { + json!({ + "id": item_id(item, event_type), + "type": event_type, + "status": status, + "createdAt": timestamp, + }) +} + +fn memory_citation(item: &Value) -> Option { + item.get("memoryCitation") + .or_else(|| item.get("memory_citation")) + .filter(|value| value.is_object()) + .cloned() +} diff --git a/executor/src/runtime_work/util.rs b/executor/src/runtime_work/util.rs index e0218e616fb..01843c348cc 100644 --- a/executor/src/runtime_work/util.rs +++ b/executor/src/runtime_work/util.rs @@ -187,15 +187,21 @@ pub(crate) fn extract_text(item: &Value) -> Option { if let Some(content) = string_field(item, "content") { return Some(content); } + if let Some(message) = string_field(item, "message") { + return Some(message); + } let text = item .get("content") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|part| { - part.get("text") - .or_else(|| part.get("content")) - .and_then(Value::as_str) + part.as_str().map(str::to_owned).or_else(|| { + part.get("text") + .or_else(|| part.get("content")) + .and_then(Value::as_str) + .map(str::to_owned) + }) }) .collect::>() .join(""); diff --git a/executor/tests/app_runtime_work_file_changes_contract.rs b/executor/tests/app_runtime_work_file_changes_contract.rs index 95f10ae2bb0..912c6742266 100644 --- a/executor/tests/app_runtime_work_file_changes_contract.rs +++ b/executor/tests/app_runtime_work_file_changes_contract.rs @@ -79,6 +79,194 @@ async fn runtime_transcript_preserves_assistant_file_changes_from_codex_items() ); } +#[tokio::test] +async fn runtime_transcript_normalizes_codex_file_change_items_without_backend_subtask_id() { + let _lock = env_lock().await; + let _home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &temp_path("runtime-codex-file-change-home", "dir") + .display() + .to_string(), + ); + let fake_codex = + write_fake_file_change_codex(&temp_path("runtime-codex-file-change-log", "jsonl")); + let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); + + let transcript = handler + .handle_runtime_rpc(json!({ + "method": "runtime.tasks.transcript", + "payload": { + "workspacePath": "/tmp/project", + "localTaskId": "thread-1" + } + })) + .await + .expect("transcript should succeed"); + + assert_eq!(transcript["success"], true); + let assistant = &transcript["messages"][1]; + assert_eq!(assistant["role"], "assistant"); + assert_eq!(assistant["content"], "Done"); + let subtask_id = assistant["subtaskId"] + .as_i64() + .expect("synthetic subtask id should be present"); + assert!(subtask_id < 0); + assert_eq!(assistant["subtask_id"], assistant["subtaskId"]); + assert_eq!(assistant["fileChanges"]["device_id"], "device-1"); + assert_eq!(assistant["fileChanges"]["workspace_path"], "/tmp/project"); + assert_eq!( + assistant["fileChanges"]["artifact_id"], + "codex-turn-file-change-call-1" + ); + assert_eq!(assistant["fileChanges"]["file_count"], 1); + assert_eq!(assistant["fileChanges"]["additions"], 1); + assert_eq!(assistant["fileChanges"]["deletions"], 1); + assert_eq!( + assistant["fileChanges"]["files"][0]["path"], + "references/github-pr-flow.md" + ); + assert!(assistant["fileChanges"]["diff"] + .as_str() + .unwrap() + .starts_with("diff --git a/references/github-pr-flow.md b/references/github-pr-flow.md")); +} + +#[tokio::test] +async fn runtime_transcript_normalizes_codex_rich_turn_items() { + let _lock = env_lock().await; + let _home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &temp_path("runtime-rich-transcript-home", "dir") + .display() + .to_string(), + ); + let fake_codex = write_fake_rich_codex(&temp_path("runtime-rich-transcript-log", "jsonl")); + let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); + + let transcript = handler + .handle_runtime_rpc(json!({ + "method": "runtime.tasks.transcript", + "payload": { + "workspacePath": "/tmp/project", + "localTaskId": "thread-1" + } + })) + .await + .expect("transcript should succeed"); + + assert_eq!(transcript["success"], true); + let assistant = &transcript["messages"][1]; + assert_eq!(assistant["role"], "assistant"); + assert_eq!(assistant["content"], "Done"); + assert!(assistant["subtaskId"].as_i64().unwrap() < 0); + assert_eq!(assistant["blocks"].as_array().unwrap().len(), 3); + assert_eq!(assistant["blocks"][0]["type"], "thinking"); + assert_eq!(assistant["blocks"][1]["type"], "text"); + assert_eq!(assistant["blocks"][1]["content"], "I checked the files."); + assert_eq!(assistant["blocks"][2]["tool_name"], "exec_command"); + assert_eq!(assistant["blocks"][2]["tool_output"], "ok"); + assert_eq!(assistant["blocks"][2]["timestamp"], 1780000007000_i64); + assert_eq!(assistant["fileChanges"]["device_id"], "device-1"); + assert_eq!(assistant["fileChanges"]["workspace_path"], "/tmp/project"); + assert_eq!(assistant["fileChanges"]["file_count"], 1); + assert_eq!(assistant["fileChanges"]["additions"], 1); + assert_eq!(assistant["fileChanges"]["deletions"], 1); + assert_eq!(assistant["fileChanges"]["revertible"], false); + assert_eq!(assistant["fileChanges"]["files"][0]["path"], "src/lib.rs"); + assert!(assistant["fileChanges"]["diff"] + .as_str() + .unwrap() + .starts_with("diff --git a/src/lib.rs b/src/lib.rs")); + assert_eq!( + assistant["memoryCitations"][0]["entries"][0]["path"], + "MEMORY.md" + ); + assert_eq!(assistant["contextEvents"][0]["type"], "context_compaction"); +} + +#[tokio::test] +async fn runtime_transcript_merges_multiple_codex_file_change_items_in_one_turn() { + let _lock = env_lock().await; + let _home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &temp_path("runtime-merged-file-changes-home", "dir") + .display() + .to_string(), + ); + let fake_codex = + write_fake_multi_patch_codex(&temp_path("runtime-merged-file-changes-log", "jsonl")); + let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); + + let transcript = handler + .handle_runtime_rpc(json!({ + "method": "runtime.tasks.transcript", + "payload": { + "workspacePath": "/tmp/project", + "localTaskId": "thread-1" + } + })) + .await + .expect("transcript should succeed"); + + assert_eq!(transcript["success"], true); + let assistant = &transcript["messages"][1]; + assert_eq!(assistant["role"], "assistant"); + assert_eq!(assistant["content"], "Done"); + assert_eq!(assistant["fileChanges"]["file_count"], 4); + assert_eq!(assistant["fileChanges"]["additions"], 69); + assert_eq!(assistant["fileChanges"]["deletions"], 18); + assert_eq!(assistant["fileChanges"]["files"][0]["path"], "SKILL.md"); + assert_eq!( + assistant["fileChanges"]["files"][1]["path"], + "references/acceptance-validation-contract.md" + ); + assert_eq!( + assistant["fileChanges"]["files"][2]["path"], + "references/pr-review-notification.md" + ); + assert_eq!( + assistant["fileChanges"]["files"][3]["path"], + "scripts/notify_pr_ready.sh" + ); + assert!(assistant["fileChanges"]["diff"] + .as_str() + .unwrap() + .contains("scripts/notify_pr_ready.sh")); +} + +fn write_fake_file_change_codex(log_path: &Path) -> PathBuf { + let path = temp_path("fake-codex-file-change-item", "sh"); + let _ = fs::remove_file(log_path); + let content = format!( + r#"#!/bin/sh +LOG_PATH='{}' +while IFS= read -r line; do + printf '%s\n' "$line" >> "$LOG_PATH" + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{{"id":1,"result":{{"protocolVersion":1}}}}' + ;; + *'"method":"initialized"'*) + ;; + *'"method":"thread/read"'*) + printf '%s\n' '{{"id":2,"result":{{"thread":{{"id":"thread-1","cwd":"/tmp/project","name":"File change transcript","preview":"edit","path":"/tmp/codex/thread-1.jsonl","createdAt":1780000000,"updatedAt":1780000060,"status":"idle","turns":[{{"id":"turn-file-change","createdAt":1780000000,"items":[{{"id":"user-1","type":"userMessage","content":[{{"type":"text","text":"edit"}}]}},{{"id":"agent-progress","type":"agentMessage","text":"Editing.","phase":"commentary"}},{{"id":"call-1","type":"fileChange","changes":[{{"path":"/tmp/project/references/github-pr-flow.md","kind":{{"type":"update","move_path":null}},"diff":"@@ -1 +1 @@\n-old\n+new\n"}}],"status":"completed"}},{{"id":"agent-1","type":"agentMessage","text":"Done","phase":"final_answer"}}]}}]}}}}}}' + exit 0 + ;; + esac +done +"#, + log_path.display() + ); + fs::write(&path, content).unwrap(); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&path, permissions).unwrap(); + } + path +} + fn write_fake_codex(log_path: &Path) -> PathBuf { let path = temp_path("fake-codex-file-changes", "sh"); let _ = fs::remove_file(log_path); @@ -112,6 +300,158 @@ done path } +fn write_fake_multi_patch_codex(log_path: &Path) -> PathBuf { + let path = temp_path("fake-codex-multi-patch-transcript", "sh"); + let _ = fs::remove_file(log_path); + let response = json!({ + "id": 2, + "result": { + "thread": { + "id": "thread-1", + "cwd": "/tmp/project", + "name": "Merged file changes", + "preview": "edit", + "path": "/tmp/codex/thread-1.jsonl", + "createdAt": 1780000000_i64, + "updatedAt": 1780000060_i64, + "status": "idle", + "turns": [{ + "id": "turn-merged", + "createdAt": 1780000000_i64, + "items": [ + { + "id": "user-1", + "type": "userMessage", + "content": [{"type": "text", "text": "edit files"}], + }, + patch_apply_item( + "patch-1", + "SKILL.md", + counted_diff("SKILL.md", 5, 3), + ), + patch_apply_item( + "patch-2", + "references/acceptance-validation-contract.md", + counted_diff("references/acceptance-validation-contract.md", 16, 2), + ), + patch_apply_item( + "patch-3", + "references/pr-review-notification.md", + counted_diff("references/pr-review-notification.md", 12, 8), + ), + patch_apply_item( + "patch-4", + "scripts/notify_pr_ready.sh", + counted_diff("scripts/notify_pr_ready.sh", 36, 5), + ), + { + "id": "agent-1", + "type": "agentMessage", + "text": "Done", + "phase": "final_answer", + }, + ], + }], + }, + }, + }); + let content = format!( + r#"#!/bin/sh +LOG_PATH='{}' +while IFS= read -r line; do + printf '%s\n' "$line" >> "$LOG_PATH" + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{{"id":1,"result":{{"protocolVersion":1}}}}' + ;; + *'"method":"initialized"'*) + ;; + *'"method":"thread/read"'*) + printf '%s\n' {} + exit 0 + ;; + esac +done +"#, + log_path.display(), + shell_single_quote(&response.to_string()), + ); + fs::write(&path, content).unwrap(); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&path, permissions).unwrap(); + } + path +} + +fn patch_apply_item(id: &str, path: &str, diff: String) -> serde_json::Value { + json!({ + "id": id, + "type": "patch_apply_end", + "success": true, + "changes": { + path: { + "type": "update", + "unified_diff": diff, + "move_path": null, + }, + }, + }) +} + +fn counted_diff(path: &str, additions: usize, deletions: usize) -> String { + let mut lines = vec![ + format!("diff --git a/{path} b/{path}"), + "@@ -1 +1 @@".to_owned(), + ]; + for index in 1..=deletions { + lines.push(format!("-old {index}")); + } + for index in 1..=additions { + lines.push(format!("+new {index}")); + } + lines.join("\n") +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn write_fake_rich_codex(log_path: &Path) -> PathBuf { + let path = temp_path("fake-codex-rich-transcript", "sh"); + let _ = fs::remove_file(log_path); + let content = format!( + r#"#!/bin/sh +LOG_PATH='{}' +while IFS= read -r line; do + printf '%s\n' "$line" >> "$LOG_PATH" + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{{"id":1,"result":{{"protocolVersion":1}}}}' + ;; + *'"method":"initialized"'*) + ;; + *'"method":"thread/read"'*) + printf '%s\n' '{{"id":2,"result":{{"thread":{{"id":"thread-1","cwd":"/tmp/project","name":"Rich transcript","preview":"rich","path":"/tmp/codex/thread-1.jsonl","createdAt":1780000000,"updatedAt":1780000060,"status":"idle","turns":[{{"id":"turn-rich","startedAt":1780000000,"durationMs":7000,"items":[{{"id":"user-1","type":"userMessage","content":[{{"type":"text","text":"inspect"}}]}},{{"id":"reason-1","type":"reasoning","summary":["thinking"]}},{{"id":"agent-progress","type":"agentMessage","text":"I checked the files.","phase":"analysis"}},{{"type":"function_call","name":"exec_command","arguments":"{{\"cmd\":\"rg -n test\"}}","call_id":"call-1"}},{{"type":"function_call_output","call_id":"call-1","output":"ok"}},{{"id":"patch-1","type":"patch_apply_end","success":true,"changes":{{"/tmp/project/src/lib.rs":{{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new\n","move_path":null}}}}}},{{"id":"ctx-1","type":"contextCompaction"}},{{"id":"agent-1","type":"agentMessage","text":"Done","phase":"final_answer","memoryCitation":{{"entries":[{{"path":"MEMORY.md","lineStart":1,"lineEnd":2,"note":"note"}}],"threadIds":["thread-a"]}}}}]}}]}}}}}}' + exit 0 + ;; + esac +done +"#, + log_path.display() + ); + fs::write(&path, content).unwrap(); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&path, permissions).unwrap(); + } + path +} + fn temp_path(prefix: &str, extension: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5b5883ff9c..b8894189d81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -532,6 +532,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-syntax-highlighter: + specifier: ^15.6.6 + version: 15.6.6(react@19.2.7) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -563,6 +566,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) diff --git a/wework/package.json b/wework/package.json index 608d4abc9d3..263d11db005 100644 --- a/wework/package.json +++ b/wework/package.json @@ -44,6 +44,7 @@ "react-dom": "^19.2.6", "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0" }, @@ -56,6 +57,7 @@ "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-istanbul": "^4.1.8", "autoprefixer": "^10.5.0", diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 28980809af5..cabfdd71d1f 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -1,18 +1,45 @@ import type { WorkbenchServices } from '@/features/workbench/WorkbenchProvider' import { createExecutorClientFromApis } from '@/api/executorAccess' import type { + ArchivedConversationsListRequest, + ArchivedConversationsListResponse, + DeleteDeviceWorkspaceRequest, + DeleteDeviceWorkspaceResponse, DeviceCommandResponse, - DeviceInfo, + DeviceWorkspacePrepareRequest, + DeviceWorkspacePrepareResponse, LocalTaskSummary, LocalDeviceSkill, + RuntimeArchiveProjectConversationsRequest, + RuntimeArchivedConversationBulkRequest, + RuntimeArchivedConversationBulkResponse, RuntimeDeviceWorkspace, + RuntimeFileChangesRevertRequest, + RuntimeFileChangesRevertResponse, RuntimeTaskAddress, + RuntimeTaskArchiveResponse, + RuntimeTaskCancelResponse, RuntimeTaskCreateRequest, + RuntimeTaskCreateResponse, + RuntimeTaskForkRequest, + RuntimeTaskForkResponse, + RuntimeTaskRenameRequest, + RuntimeSendRequest, + RuntimeSendResponse, + RuntimeTranscriptRequest, + RuntimeTranscriptResponse, + RuntimeWorkspaceOpenRequest, + RuntimeWorkspaceOpenResponse, + RuntimeWorkspaceRemoveRequest, + RuntimeWorkspaceRenameRequest, RuntimeWorkListResponse, + RuntimeWorkSearchRequest, + RuntimeWorkSearchResponse, Team, UnifiedModel, User, } from '@/types/api' +import type { DeviceInfo } from '@/types/devices' import type { WorkspaceFileEntry, WorkspaceTextFileResponse, @@ -80,13 +107,13 @@ function cloudConnectionRequired(name: string): never { function localDeviceFromStatus(status: LocalExecutorStatus): DeviceInfo { const online = status.running && status.ready !== false && !status.error - return { + const device = { id: 0, device_id: status.deviceId || LOCAL_DEVICE_ID, name: 'Local Executor', - status: online ? 'online' : 'offline', + status: online ? ('online' as const) : ('offline' as const), is_default: true, - device_type: 'local', + device_type: 'local' as const, capabilities: ['runtime-work', 'device-commands'], slot_used: 0, slot_max: 5, @@ -95,9 +122,10 @@ function localDeviceFromStatus(status: LocalExecutorStatus): DeviceInfo { latest_version: null, update_available: false, error: status.error ?? null, - bind_shell: 'claudecode', + bind_shell: 'claudecode' as const, runtime_transfer_host: null, } + return device } function localDeviceIdFromStatus(status: LocalExecutorStatus | null | undefined): string { @@ -609,13 +637,16 @@ function createRuntimeWorkApi( request: (method: string, params?: Record) => Promise, getLocalDeviceId: () => Promise ) { - const normalizeRequest = async >(data: T): Promise => - normalizeLocalDeviceRecord(data, await getLocalDeviceId()) + const normalizeRequest = async ( + data: T + ): Promise> => + normalizeLocalDeviceRecord(data as Record, await getLocalDeviceId()) as T & + Record - const requestWithLocalDevice = async ( + const requestWithLocalDevice = async ( method: string, - data: Record - ): Promise => request(method, await normalizeRequest(data)) + data: TRequest + ): Promise => request(method, await normalizeRequest(data)) return { async listRuntimeWork(): Promise { @@ -625,31 +656,41 @@ function createRuntimeWorkApi( upsertDeviceWorkspace() { return cloudConnectionRequired('upsertDeviceWorkspace') }, - prepareDeviceWorkspace(data: Record) { + prepareDeviceWorkspace( + data: DeviceWorkspacePrepareRequest + ): Promise { return requestWithLocalDevice('runtime.workspaces.prepare', data) }, - deleteDeviceWorkspace(data: Record) { + deleteDeviceWorkspace( + data: DeleteDeviceWorkspaceRequest + ): Promise { return requestWithLocalDevice('runtime.workspaces.delete', data) }, - getRuntimeTranscript(data: Record) { + getRuntimeTranscript(data: RuntimeTranscriptRequest): Promise { return requestWithLocalDevice('runtime.tasks.transcript', data) }, - searchRuntimeWork(data: Record) { + searchRuntimeWork(data: RuntimeWorkSearchRequest): Promise { return requestWithLocalDevice('runtime.tasks.search', data) }, - revertRuntimeFileChanges(data: Record) { + revertRuntimeFileChanges( + data: RuntimeFileChangesRevertRequest + ): Promise { return requestWithLocalDevice('runtime.tasks.revert_file_changes', data) }, - sendRuntimeMessage(data: Record) { + sendRuntimeMessage(data: RuntimeSendRequest): Promise { return requestWithLocalDevice('runtime.tasks.send', data) }, - openRuntimeWorkspace(data: Record) { + openRuntimeWorkspace(data: RuntimeWorkspaceOpenRequest): Promise { return requestWithLocalDevice('runtime.workspaces.open', data) }, - renameRuntimeWorkspace(data: Record) { + renameRuntimeWorkspace( + data: RuntimeWorkspaceRenameRequest + ): Promise { return requestWithLocalDevice('runtime.workspaces.rename', data) }, - removeRuntimeWorkspace(data: Record) { + removeRuntimeWorkspace( + data: RuntimeWorkspaceRemoveRequest + ): Promise { return requestWithLocalDevice('runtime.workspaces.remove', data) }, bindRuntimeTaskImSessions() { @@ -670,61 +711,59 @@ function createRuntimeWorkApi( unsubscribeRuntimeTaskNotifications(address: RuntimeTaskAddress) { return Promise.resolve({ address, subscribed: false, sessionKeys: [] }) }, - archiveRuntimeTask(data: RuntimeTaskAddress) { - return requestWithLocalDevice( - 'runtime.tasks.archive', - data as unknown as Record - ) + archiveRuntimeTask(data: RuntimeTaskAddress): Promise { + return requestWithLocalDevice('runtime.tasks.archive', data) }, - renameRuntimeTask(data: Record) { + renameRuntimeTask(data: RuntimeTaskRenameRequest): Promise { return requestWithLocalDevice('runtime.tasks.rename', data) }, - listArchivedConversations(data: Record = {}) { + listArchivedConversations( + data: ArchivedConversationsListRequest = {} + ): Promise { return requestWithLocalDevice('runtime.archived_conversations.list', data) }, - archiveConversation(data: RuntimeTaskAddress) { - return requestWithLocalDevice( - 'runtime.tasks.archive', - data as unknown as Record - ) + archiveConversation(data: RuntimeTaskAddress): Promise { + return requestWithLocalDevice('runtime.tasks.archive', data) }, - archiveProjectConversations(data: Record) { + archiveProjectConversations( + data: RuntimeArchiveProjectConversationsRequest + ): Promise { return requestWithLocalDevice('runtime.archived_conversations.archive_project', data) }, - archiveAllConversations() { + archiveAllConversations(): Promise { return request('runtime.archived_conversations.archive_all', {}) }, - unarchiveConversation(data: RuntimeTaskAddress) { - return requestWithLocalDevice( - 'runtime.archived_conversations.unarchive', - data as unknown as Record - ) + unarchiveConversation(data: RuntimeTaskAddress): Promise { + return requestWithLocalDevice('runtime.archived_conversations.unarchive', data) }, - deleteArchivedConversation(data: RuntimeTaskAddress) { - return requestWithLocalDevice( - 'runtime.archived_conversations.delete', - data as unknown as Record - ) + deleteArchivedConversation(data: RuntimeTaskAddress): Promise { + return requestWithLocalDevice('runtime.archived_conversations.delete', data) }, - deleteArchivedConversationsBulk(data: Record) { + deleteArchivedConversationsBulk( + data: RuntimeArchivedConversationBulkRequest + ): Promise { return requestWithLocalDevice('runtime.archived_conversations.delete_bulk', data) }, - cancelRuntimeTask(data: RuntimeTaskAddress) { - return requestWithLocalDevice( - 'runtime.tasks.cancel', - data as unknown as Record - ) + cancelRuntimeTask(data: RuntimeTaskAddress): Promise { + return requestWithLocalDevice('runtime.tasks.cancel', data) }, - async createRuntimeTask(data: RuntimeTaskCreateRequest) { + async createRuntimeTask(data: RuntimeTaskCreateRequest): Promise { const localDeviceId = await getLocalDeviceId() const payload = createLocalRuntimeTaskPayload(data, localDeviceId) - const response = await request>('runtime.tasks.create', payload) + const response = await request>( + 'runtime.tasks.create', + payload + ) return { ...response, + accepted: response.accepted ?? true, deviceId: localDeviceId, + localTaskId: response.localTaskId ?? data.localTaskId ?? '', + workspacePath: response.workspacePath ?? requiredRuntimeWorkspacePath(data), + runtime: response.runtime ?? data.runtime, } }, - forkRuntimeTask(data: Record) { + forkRuntimeTask(data: RuntimeTaskForkRequest): Promise { return requestWithLocalDevice('runtime.tasks.import_fork', data) }, } diff --git a/wework/src/components/chat/AssistantMarkdown.tsx b/wework/src/components/chat/AssistantMarkdown.tsx new file mode 100644 index 00000000000..8637f60beb4 --- /dev/null +++ b/wework/src/components/chat/AssistantMarkdown.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { FileText, Link2 } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { + classifyMarkdownLink, + getAuthenticatedImageFetchUrl, + isAuthenticatedAttachmentImageSrc, + resolveDirectMarkdownImageSrc, + type MarkdownLinkTarget, +} from './assistantMarkdownLinks' +import { MarkdownCodeBlock } from './MarkdownCodeBlock' + +const ASSISTANT_MARKDOWN_LINK_CLASS = [ + 'inline-flex max-w-full items-center gap-1 rounded-md px-0.5 align-baseline', + 'text-[13px] font-medium leading-5 text-blue-600 no-underline', + 'transition-colors hover:text-blue-700', + 'dark:text-blue-300 dark:hover:text-blue-200', +].join(' ') + +interface AssistantMarkdownProps { + content: string + onOpenFile?: (path: string) => void +} + +export function AssistantMarkdown({ content, onOpenFile }: AssistantMarkdownProps) { + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + code: ({ className, children }) => { + const match = /language-(\w*)/.exec(className || '') + const isBlock = Boolean(match) || String(children).includes('\n') + if (isBlock) { + const lang = match ? match[1] || '' : '' + return {children} + } + return ( + + {children} + + ) + }, + pre: ({ children }) => <>{children}, + blockquote: ({ children }) => ( +
    + {children} +
    + ), + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => {children}, + a: ({ href, children }) => ( + + {children} + + ), + img: ({ src, alt }) => , + }} + > + {content} +
    +
    + ) +} + +function formatMarkdownLineLabel(target: Extract): string { + if (typeof target.lineStart !== 'number') return '' + if (typeof target.lineEnd === 'number' && target.lineEnd !== target.lineStart) { + return `lines ${target.lineStart}-${target.lineEnd}` + } + return `line ${target.lineStart}` +} + +function formatMarkdownFileTooltip(target: Extract): string { + const lineLabel = formatMarkdownLineLabel(target) + return lineLabel ? `${target.path} (${lineLabel})` : target.path +} + +function AssistantMarkdownLink({ + href, + onOpenFile, + children, +}: { + href?: string + onOpenFile?: (path: string) => void + children?: ReactNode +}) { + const target = classifyMarkdownLink(href) + const LinkIcon = target.kind === 'file' ? FileText : Link2 + const icon = ( +