diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 355c24dea34..279cafaf42b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: working-directory: ./backend run: | curl -LsSf https://astral.sh/uv/install.sh | sh - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv sync --dev - name: Run backend tests @@ -50,11 +50,11 @@ jobs: env: PYTHONPATH: ${{ github.workspace }} run: | - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv run pytest tests/ --cov=app --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./backend/coverage.xml flags: backend @@ -68,12 +68,14 @@ jobs: contents: read env: CARGO_PROFILE_TEST_DEBUG: 0 - CARGO_TARGET_DIR: ${{ runner.temp }}/executor-target steps: - name: Checkout code uses: actions/checkout@v4 + - name: Configure executor target directory + run: echo "CARGO_TARGET_DIR=$RUNNER_TEMP/executor-target" >> "$GITHUB_ENV" + - name: Free executor runner disk space run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL @@ -134,7 +136,7 @@ jobs: working-directory: ./executor_manager run: | curl -LsSf https://astral.sh/uv/install.sh | sh - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv sync --dev - name: Run executor manager tests @@ -145,11 +147,11 @@ jobs: PYTHONUNBUFFERED: "1" PYTHONFAULTHANDLER: "1" run: | - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv run pytest tests/ --cov=executors --cov-report=xml --cov-report=term-missing -v -s - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./executor_manager/coverage.xml flags: executor-manager @@ -180,7 +182,7 @@ jobs: working-directory: ./shared run: | curl -LsSf https://astral.sh/uv/install.sh | sh - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv sync --dev - name: Run shared tests @@ -190,7 +192,7 @@ jobs: PYTHONUNBUFFERED: "1" PYTHONFAULTHANDLER: "1" run: | - source $HOME/.cargo/env + source "$HOME/.cargo/env" # Run pytest and capture exit code, ignoring thread cleanup errors set +e uv run pytest tests/ --cov=utils --cov-config=.coveragerc --cov-report=xml --cov-report=term-missing -v -p no:cacheprovider 2>&1 @@ -198,16 +200,16 @@ jobs: set -e # Exit code 0 = success, others indicate test failures # Thread cleanup errors cause exit 134 but tests pass, so check coverage.xml exists - if [ $PYTEST_EXIT -eq 0 ] || ([ $PYTEST_EXIT -eq 134 ] && [ -f coverage.xml ]); then + if [ "$PYTEST_EXIT" -eq 0 ] || { [ "$PYTEST_EXIT" -eq 134 ] && [ -f coverage.xml ]; }; then echo "Tests completed successfully" exit 0 else echo "Tests failed with exit code $PYTEST_EXIT" - exit $PYTEST_EXIT + exit "$PYTEST_EXIT" fi - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./shared/coverage.xml flags: shared @@ -238,7 +240,7 @@ jobs: working-directory: ./knowledge_engine run: | curl -LsSf https://astral.sh/uv/install.sh | sh - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv sync --dev - name: Run knowledge engine tests @@ -248,11 +250,11 @@ jobs: PYTHONUNBUFFERED: "1" PYTHONFAULTHANDLER: "1" run: | - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv run pytest tests/ --cov=knowledge_engine --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./knowledge_engine/coverage.xml flags: knowledge-engine @@ -298,7 +300,7 @@ jobs: run: pnpm --filter wecode-ai-assistant run test --coverage --passWithNoTests - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./frontend/coverage/coverage-final.json flags: frontend @@ -331,7 +333,7 @@ jobs: run: pnpm --filter wework exec vitest run --coverage --passWithNoTests - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./wework/coverage/coverage-final.json flags: wework @@ -371,7 +373,7 @@ jobs: pytest tests/ --ignore=tests/test_integration.py --cov=wegent --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./wegent-cli/coverage.xml flags: wegent-cli @@ -427,7 +429,7 @@ jobs: working-directory: ./backend run: | curl -LsSf https://astral.sh/uv/install.sh | sh - source $HOME/.cargo/env + source "$HOME/.cargo/env" uv sync - name: Install shared module diff --git a/docs/en/developer-guide/testing.md b/docs/en/developer-guide/testing.md index 72aa2cc0135..1db16dd91af 100644 --- a/docs/en/developer-guide/testing.md +++ b/docs/en/developer-guide/testing.md @@ -232,35 +232,25 @@ The `.github/workflows/test.yml` workflow runs automatically on: ### Workflow Jobs -1. **test-backend**: Python backend tests - - Matrix strategy: Python 3.10 and 3.11 - - Coverage reports uploaded to Codecov - - Dependency caching for faster builds - -2. **test-executor**: Executor engine tests - - Python 3.10 - - Coverage for agents module - - Tests AI agent factory and base classes - -3. **test-executor-manager**: Task manager tests - - Python 3.10 - - Coverage for executors module - - Tests Docker executor and dispatcher - -4. **test-shared**: Shared utilities tests - - Python 3.10 - - Coverage for utils module - - Tests cryptography and data masking - -5. **test-frontend**: Frontend tests (Node.js 20.x) - - Jest with React Testing Library - - Runs with `--passWithNoTests` flag - - Coverage uploaded to Codecov - -6. **test-summary**: Aggregate results - - Depends on all test jobs - - Fails if any test job fails - - Always runs regardless of individual job status +Regular PR checks must cover module tests; E2E jobs do not replace them: + +1. **test-backend** runs the Backend suite with Python 3.11 and `uv run pytest`. +2. **test-executor** runs `cargo fmt --check`, `cargo test --all-features`, and + Clippy for the Rust Executor. It places `CARGO_TARGET_DIR` under the runner's + temporary directory to avoid shared build contamination and workspace disk pressure. +3. **test-executor-manager**, **test-shared**, and **test-knowledge-engine** run + their Python suites with `uv run pytest`. +4. **test-frontend** runs the Chat Core and Frontend unit tests. +5. **test-wework** runs the Wework unit suite with Vitest and generates coverage. +6. **test-wegent-cli** and **test-wegent-cli-integration** run CLI unit tests and + integration tests backed by MySQL, Redis, and a real Backend, respectively. +7. **test-summary** always runs and depends on every job above. It must fail when + any module test job fails. + +E2E coverage lives in dedicated workflows: `.github/workflows/e2e-tests.yml` +covers product end-to-end flows, while `.github/workflows/wework-e2e.yml` covers +Wework flows. They add cross-module verification and do not replace the module +tests in `.github/workflows/test.yml`. ### Coverage Reports diff --git a/docs/zh/developer-guide/testing.md b/docs/zh/developer-guide/testing.md index 4eff14e1c5e..e572b02e496 100644 --- a/docs/zh/developer-guide/testing.md +++ b/docs/zh/developer-guide/testing.md @@ -232,35 +232,23 @@ frontend/src/__tests__/ ### 工作流任务 -1. **test-backend**:Python 后端测试 - - 矩阵策略:Python 3.10 和 3.11 - - 覆盖率报告上传至 Codecov - - 依赖缓存以加快构建速度 - -2. **test-executor**:执行器引擎测试 - - Python 3.10 - - agents 模块的覆盖率 - - 测试 AI agent 工厂和基础类 - -3. **test-executor-manager**:任务管理器测试 - - Python 3.10 - - executors 模块的覆盖率 - - 测试 Docker 执行器和调度器 - -4. **test-shared**:共享工具库测试 - - Python 3.10 - - utils 模块的覆盖率 - - 测试加密和数据脱敏 - -5. **test-frontend**:前端测试(Node.js 20.x) - - 使用 React Testing Library 的 Jest - - 使用 `--passWithNoTests` 标志运行 - - 覆盖率上传至 Codecov - -6. **test-summary**:汇总结果 - - 依赖于所有测试任务 - - 如果任何测试任务失败则失败 - - 始终运行,无论单个任务的状态如何 +常规 PR 测试必须覆盖各模块测试,不能用 E2E 任务代替: + +1. **test-backend**:使用 Python 3.11 和 `uv run pytest` 运行 Backend 测试。 +2. **test-executor**:运行 Rust Executor 的 `cargo fmt --check`、 + `cargo test --all-features` 和 Clippy。该任务把 `CARGO_TARGET_DIR` 设置到 runner + 临时目录,避免共享构建目录污染或占满工作区。 +3. **test-executor-manager**、**test-shared** 和 **test-knowledge-engine**:使用 + `uv run pytest` 运行对应 Python 模块测试。 +4. **test-frontend**:运行 Chat Core 与 Frontend 单元测试。 +5. **test-wework**:使用 Vitest 运行 Wework 单元测试并生成覆盖率。 +6. **test-wegent-cli** 与 **test-wegent-cli-integration**:分别运行 CLI 单元测试, + 以及依赖 MySQL、Redis 和真实 Backend 的集成测试。 +7. **test-summary**:始终运行并依赖以上所有任务;任何模块测试失败时,汇总任务必须失败。 + +E2E 使用独立 workflow:`.github/workflows/e2e-tests.yml` 覆盖产品端到端流程, +`.github/workflows/wework-e2e.yml` 覆盖 Wework 流程。它们提供额外的跨模块验证, +不会替代 `.github/workflows/test.yml` 中的模块测试。 ### 覆盖率报告 diff --git a/executor/src/bin/wegent-executor.rs b/executor/src/bin/wegent-executor.rs index a5a5f06a9f3..b2697abeb67 100644 --- a/executor/src/bin/wegent-executor.rs +++ b/executor/src/bin/wegent-executor.rs @@ -22,7 +22,7 @@ async fn main() { fn install_termination_signal_diagnostics() { unsafe { let mut action: libc::sigaction = std::mem::zeroed(); - action.sa_sigaction = termination_signal_handler as usize; + action.sa_sigaction = termination_signal_handler as *const () as usize; action.sa_flags = libc::SA_SIGINFO; libc::sigemptyset(&mut action.sa_mask); libc::sigaction(libc::SIGTERM, &action, std::ptr::null_mut()); @@ -46,7 +46,11 @@ extern "C" fn termination_signal_handler( let process_id = unsafe { libc::getpid() }; let mut line = [0_u8; 128]; let mut length = 0; - append_signal_text(&mut line, &mut length, b"wegent-executor received SIGTERM sender_pid="); + append_signal_text( + &mut line, + &mut length, + b"wegent-executor received SIGTERM sender_pid=", + ); append_signal_number(&mut line, &mut length, sender_pid); append_signal_text(&mut line, &mut length, b" process_id="); append_signal_number(&mut line, &mut length, process_id); diff --git a/executor/src/browser_mcp.rs b/executor/src/browser_mcp.rs index 5a5081588a4..1b2319aa7ee 100644 --- a/executor/src/browser_mcp.rs +++ b/executor/src/browser_mcp.rs @@ -630,7 +630,7 @@ mod tests { #[tokio::test] async fn exposes_expected_browser_tools() { let request = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }); - let response = handle_request(&reqwest::Client::new(), &request) + let response = handle_request(&reqwest::Client::new(), &request, 1, Instant::now()) .await .unwrap(); let names = response["result"]["tools"] diff --git a/executor/tests/app_runtime_work_contract.rs b/executor/tests/app_runtime_work_contract.rs index b45260b1be3..1264be1be7b 100644 --- a/executor/tests/app_runtime_work_contract.rs +++ b/executor/tests/app_runtime_work_contract.rs @@ -14,7 +14,7 @@ use std::os::unix::fs::PermissionsExt; use rusqlite::Connection; use serde_json::{json, Value}; -use tokio::sync::{broadcast, Mutex, MutexGuard}; +use tokio::sync::{Mutex, MutexGuard}; use wegent_executor::{ local::app_ipc::{AppIpcServer, RuntimeWorkHandler}, runtime_work::RuntimeWorkRpcHandler, @@ -109,48 +109,6 @@ async fn app_runtime_lists_codex_threads_through_app_server() { assert!(calls.iter().any(|call| call["method"] == "thread/list")); } -#[tokio::test] -async fn app_runtime_caches_consecutive_codex_thread_lists() { - let _lock = env_lock().await; - let _home = EnvGuard::set( - "WEGENT_EXECUTOR_HOME", - &temp_path("wegent-app-runtime-list-cache-home", "dir") - .display() - .to_string(), - ); - let _codex_home = set_temp_codex_home("wegent-app-runtime-list-cache-codex-home"); - let log_path = temp_path("wegent-app-runtime-list-cache-log", "jsonl"); - let fake_codex = write_fake_codex(&log_path); - let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); - - for _ in 0..2 { - let listed = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.list", - "payload": {} - })) - .await - .expect("runtime task list should succeed"); - assert_eq!(listed["success"], true); - } - - let calls = read_json_lines(&log_path); - assert_eq!( - calls - .iter() - .filter(|call| call["method"] == "initialize") - .count(), - 1 - ); - assert_eq!( - calls - .iter() - .filter(|call| call["method"] == "thread/list") - .count(), - 1 - ); -} - #[tokio::test] async fn app_runtime_preserves_codex_thread_list_recency_order() { let _lock = env_lock().await; @@ -252,7 +210,7 @@ async fn app_runtime_reads_codex_thread_transcript_through_app_server() { } #[tokio::test] -async fn app_runtime_pages_codex_thread_transcript_from_cache() { +async fn app_runtime_pages_codex_thread_transcript_from_provider() { let _lock = env_lock().await; let _home = EnvGuard::set( "WEGENT_EXECUTOR_HOME", @@ -304,159 +262,7 @@ async fn app_runtime_pages_codex_thread_transcript_from_cache() { .into_iter() .filter(|line| line["method"] == "thread/read") .count(); - assert_eq!(read_count, 1); -} - -#[tokio::test] -async fn app_runtime_reads_transcript_from_cached_thread_list_rollout_path() { - let _lock = env_lock().await; - let _home = EnvGuard::set( - "WEGENT_EXECUTOR_HOME", - &temp_path("wegent-app-runtime-list-transcript-home", "dir") - .display() - .to_string(), - ); - let _codex_home = set_temp_codex_home("wegent-app-runtime-list-transcript-codex-home"); - let log_path = temp_path("wegent-app-runtime-list-transcript-log", "jsonl"); - let rollout_path = temp_path("wegent-app-runtime-list-transcript-rollout", "jsonl"); - write_rollout_messages(&rollout_path, &["from cached list"]); - let fake_codex = write_fake_codex_list_with_rollout_path(&log_path, &rollout_path); - let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); - - handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.list", - "payload": {} - })) - .await - .expect("list should succeed"); - let transcript = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "taskId": "thread-fast", - "workspacePath": "/tmp/project", - "runtimeHandle": {"threadId": "thread-fast"}, - "limit": 50 - } - })) - .await - .expect("transcript should use cached list path"); - - assert_eq!(transcript["messages"][0]["content"], "from cached list"); - assert_eq!(count_thread_reads(&log_path), 0); -} - -#[tokio::test] -async fn app_runtime_refresh_uses_rollout_signature_before_reloading_transcript() { - let _lock = env_lock().await; - let _home = EnvGuard::set( - "WEGENT_EXECUTOR_HOME", - &temp_path("wegent-app-runtime-transcript-signature-home", "dir") - .display() - .to_string(), - ); - let _codex_home = set_temp_codex_home("wegent-app-runtime-transcript-signature-codex-home"); - let log_path = temp_path("wegent-app-runtime-transcript-signature-log", "jsonl"); - let rollout_path = temp_path("wegent-app-runtime-transcript-signature-rollout", "jsonl"); - write_rollout_messages(&rollout_path, &["first message"]); - let fake_codex = write_fake_codex_thread_with_rollout_path(&log_path, &rollout_path); - let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); - - let first = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "taskId": "thread-signature", - "workspacePath": "/tmp/project", - "runtimeHandle": {"threadId": "thread-signature"}, - "limit": 50, - "refresh": true - } - })) - .await - .expect("first transcript should succeed"); - let cached = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "taskId": "thread-signature", - "workspacePath": "/tmp/project", - "runtimeHandle": {"threadId": "thread-signature"}, - "limit": 50, - "refresh": true - } - })) - .await - .expect("cached transcript should succeed"); - - assert_eq!(first["messages"][0]["content"], "first message"); - assert_eq!(cached["messages"][0]["content"], "first message"); - assert_eq!(count_thread_reads(&log_path), 1); - - write_rollout_messages(&rollout_path, &["first message", "second message"]); - let refreshed = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "taskId": "thread-signature", - "workspacePath": "/tmp/project", - "runtimeHandle": {"threadId": "thread-signature"}, - "limit": 50, - "refresh": true - } - })) - .await - .expect("refreshed transcript should succeed"); - - assert_eq!(refreshed["messages"][2]["content"], "second message"); - assert_eq!(count_thread_reads(&log_path), 2); -} - -#[tokio::test] -async fn app_runtime_rebuilds_transcript_from_rollout_when_thread_read_has_no_turns() { - let _lock = env_lock().await; - let _home = EnvGuard::set( - "WEGENT_EXECUTOR_HOME", - &temp_path("wegent-app-runtime-rollout-transcript-home", "dir") - .display() - .to_string(), - ); - let _codex_home = set_temp_codex_home("wegent-app-runtime-rollout-transcript-codex-home"); - let fake_codex = write_fake_codex_empty_thread_with_rollout(&temp_path( - "wegent-app-runtime-rollout-transcript-log", - "jsonl", - )); - let handler = RuntimeWorkRpcHandler::new("device-1", fake_codex.display().to_string()); - - let response = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "taskId": "thread-rollout", - "workspacePath": "/tmp/project", - "runtimeHandle": {"threadId": "thread-rollout"}, - "limit": 50 - } - })) - .await - .expect("runtime transcript should succeed"); - - let blocks = response["messages"][1]["blocks"] - .as_array() - .expect("assistant blocks should exist"); - - assert_eq!(response["success"], true); - assert_eq!(response["messages"][0]["role"], "user"); - assert_eq!(response["messages"][0]["content"], "inspect files"); - assert_eq!(response["messages"][1]["role"], "assistant"); - assert_eq!(response["messages"][1]["content"], "done"); - assert_eq!(blocks[0]["type"], "thinking"); - assert_eq!(blocks[0]["content"], "inspect context"); - assert_eq!(blocks[1]["type"], "tool"); - assert_eq!(blocks[1]["tool_name"], "exec_command"); - assert_eq!(blocks[1]["tool_input"]["cmd"], "sed -n '1,20p' src/main.rs"); - assert_eq!(blocks[1]["tool_output"], "line 1\n"); + assert_eq!(read_count, 2); } #[tokio::test] @@ -583,97 +389,6 @@ async fn app_runtime_create_standalone_chat_generates_default_workspace() { assert!(workspace_path.ends_with("/standalone-chat-1")); } -#[tokio::test] -async fn app_runtime_transcript_includes_running_tool_blocks_before_thread_mapping() { - let _lock = env_lock().await; - let _home = EnvGuard::set( - "WEGENT_EXECUTOR_HOME", - &temp_path("wegent-app-runtime-running-tool-home", "dir") - .display() - .to_string(), - ); - let _codex_home = set_temp_codex_home("wegent-app-runtime-running-tool-codex-home"); - let fake_codex = - write_fake_codex_running_tool(&temp_path("wegent-app-runtime-running-tool-log", "jsonl")); - let (event_tx, mut event_rx) = broadcast::channel(16); - let handler = RuntimeWorkRpcHandler::with_event_sender( - "device-1", - fake_codex.display().to_string(), - event_tx, - ); - - let created = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.create", - "payload": { - "taskId": "running-tool-task", - "workspacePath": "/tmp/project", - "title": "Running tool task", - "executionRequest": { - "task_id": 91, - "subtask_id": 92, - "prompt": "run tests", - "bot": [{"shell_type": "ClaudeCode"}], - "model_config": { - "model": "openai", - "model_id": "gpt-5", - "api_format": "responses", - "protocol": "openai-responses" - }, - "project_workspace_path": "/tmp/project" - } - } - })) - .await - .expect("create should be accepted"); - - assert_eq!(created["accepted"], true); - - wait_for_running_tool_transcript_events(&mut event_rx).await; - - let transcript = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "taskId": "running-tool-task", - "workspacePath": "/tmp/project", - "limit": 50 - } - })) - .await - .expect("runtime transcript should succeed"); - let messages = transcript["messages"] - .as_array() - .expect("transcript messages should exist"); - let assistant = messages - .iter() - .find(|message| message["role"] == "assistant" && message["subtaskId"] == "92") - .expect("assistant message should exist"); - let blocks = assistant["blocks"] - .as_array() - .expect("assistant blocks should exist"); - let tool_block = blocks - .iter() - .find(|block| block["status"] == "done" && block["tool_output"] == "ok\n") - .expect("running task transcript should include cached tool block"); - - assert_eq!(assistant["role"], "assistant"); - assert_eq!(assistant["subtaskId"], "92"); - assert_eq!(assistant["status"], "streaming"); - assert_eq!(assistant["content"], "done"); - assert!(assistant.get("completedAt").is_none()); - assert!(assistant.get("fileChanges").is_none()); - assert_eq!(blocks[0]["type"], "thinking"); - assert_eq!(blocks[0]["content"], "checking context"); - assert_eq!(blocks[0]["status"], "done"); - assert_eq!(blocks[1]["type"], "text"); - assert_eq!(blocks[1]["content"], "running tests"); - assert_eq!(blocks[1]["status"], "done"); - assert_eq!(tool_block["type"], "tool"); - assert_eq!(tool_block["tool_name"], "bash"); - assert_eq!(tool_block["tool_input"]["command"], "cargo test"); -} - #[tokio::test] async fn app_runtime_archives_and_unarchives_codex_threads_through_app_server() { let _lock = env_lock().await; @@ -1140,199 +855,6 @@ done path } -fn write_fake_codex_empty_thread_with_rollout(log_path: &Path) -> PathBuf { - let path = temp_path("fake-codex-app-runtime-rollout-transcript", "sh"); - let rollout_path = temp_path("fake-codex-app-runtime-rollout", "jsonl"); - let _ = fs::remove_file(log_path); - fs::write( - &rollout_path, - [ - json!({"type":"event_msg","payload":{"type":"task_started"}}).to_string(), - json!({"type":"response_item","payload":{"id":"user-1","type":"message","role":"user","content":[{"type":"input_text","text":"inspect files"}]}}).to_string(), - json!({"type":"event_msg","payload":{"type":"user_message","message":"inspect files"}}).to_string(), - json!({"type":"response_item","payload":{"id":"reason-1","type":"reasoning","summary":["inspect context"]}}).to_string(), - json!({"type":"response_item","payload":{"id":"call-1","type":"function_call","name":"exec_command","call_id":"call-1","arguments":"{\"cmd\":\"sed -n '1,20p' src/main.rs\",\"workdir\":\"/tmp/project\"}"}}).to_string(), - json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"line 1\n"}}).to_string(), - json!({"type":"event_msg","payload":{"type":"agent_message","phase":"final_answer","message":"done"}}).to_string(), - json!({"type":"response_item","payload":{"id":"assistant-1","type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"done"}]}}).to_string(), - json!({"type":"event_msg","payload":{"type":"task_complete"}}).to_string(), - ] - .join("\n"), - ) - .unwrap(); - let content = format!( - r#"#!/bin/sh -LOG_PATH='{}' -while IFS= read -r line; do - printf '%s\n' "$line" >> "$LOG_PATH" - request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') - case "$line" in - *'"method":"initialize"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}' - ;; - *'"method":"initialized"'*) - ;; - *'"method":"thread/read"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-rollout","cwd":"/tmp/project","name":"Rollout transcript","preview":"rollout","path":"{}","createdAt":1780000000,"updatedAt":1780000060,"status":{{"type":"notLoaded"}},"turns":[]}}}}}}' - ;; - esac -done -"#, - log_path.display(), - rollout_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_list_with_rollout_path(log_path: &Path, rollout_path: &Path) -> PathBuf { - let path = temp_path("fake-codex-app-runtime-list-rollout", "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" - request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') - case "$line" in - *'"method":"initialize"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}' - ;; - *'"method":"initialized"'*) - ;; - *'"method":"thread/list"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"data":[{{"id":"thread-fast","cwd":"/tmp/project","name":"Fast transcript","preview":"fast","path":"{}","createdAt":1780000000,"updatedAt":1780000060,"status":"idle","turns":[]}}],"nextCursor":null,"backwardsCursor":null}}}}' - ;; - *'"method":"thread/read"'*) - printf '%s\n' '{{"id":'"$request_id"',"error":{{"code":-32000,"message":"thread/read should not be called"}}}}' - ;; - esac -done -"#, - log_path.display(), - rollout_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_thread_with_rollout_path(log_path: &Path, rollout_path: &Path) -> PathBuf { - let path = temp_path("fake-codex-app-runtime-signature-rollout", "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" - request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') - case "$line" in - *'"method":"initialize"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}' - ;; - *'"method":"initialized"'*) - ;; - *'"method":"thread/read"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-signature","cwd":"/tmp/project","name":"Signature transcript","preview":"signature","path":"{}","createdAt":1780000000,"updatedAt":1780000060,"status":{{"type":"notLoaded"}},"turns":[]}}}}}}' - ;; - esac -done -"#, - log_path.display(), - rollout_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_rollout_messages(path: &Path, messages: &[&str]) { - let mut lines = Vec::new(); - for (index, message) in messages.iter().enumerate() { - lines.push(json!({"type":"event_msg","payload":{"type":"task_started"}}).to_string()); - lines.push( - json!({"type":"response_item","payload":{"id":format!("user-{index}"),"type":"message","role":"user","content":[{"type":"input_text","text":message}]}}) - .to_string(), - ); - lines.push( - json!({"type":"response_item","payload":{"id":format!("assistant-{index}"),"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":format!("ack {message}")}]}}) - .to_string(), - ); - lines.push(json!({"type":"event_msg","payload":{"type":"task_complete"}}).to_string()); - } - fs::write(path, format!("{}\n", lines.join("\n"))).unwrap(); -} - -fn count_thread_reads(log_path: &Path) -> usize { - read_json_lines(log_path) - .into_iter() - .filter(|line| line["method"] == "thread/read") - .count() -} - -fn write_fake_codex_running_tool(log_path: &Path) -> PathBuf { - let path = temp_path("fake-codex-app-runtime-running-tool", "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" - request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') - case "$line" in - *'"method":"initialize"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}' - ;; - *'"method":"initialized"'*) - ;; - *'"method":"thread/start"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-running-tool"}}}}}}' - ;; - *'"method":"thread/name/set"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{}}}}' - ;; - *'"method":"thread/read"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-running-tool","cwd":"/tmp/project","name":"Running tool task","preview":"run tests","path":"/tmp/codex/thread-running-tool.jsonl","createdAt":1780000000,"updatedAt":1780000060,"status":"idle","turns":[]}}}}}}' - ;; - *'"method":"turn/start"'*) - printf '%s\n' '{{"id":'"$request_id"',"result":{{"turn":{{"id":"turn-running-tool","status":"inProgress"}}}}}}' - printf '%s\n' '{{"method":"item/reasoning/delta","params":{{"delta":"checking context"}}}}' - printf '%s\n' '{{"method":"item/agentMessage/delta","params":{{"delta":"running tests","phase":"commentary"}}}}' - printf '%s\n' '{{"method":"item/started","params":{{"item":{{"id":"cmd-1","type":"commandExecution","command":"cargo test","cwd":"/tmp/project","status":"inProgress"}}}}}}' - printf '%s\n' '{{"method":"item/completed","params":{{"item":{{"id":"cmd-1","type":"commandExecution","command":"cargo test","cwd":"/tmp/project","status":"completed","aggregatedOutput":"ok\n","exitCode":0}}}}}}' - printf '%s\n' '{{"method":"item/agentMessage/delta","params":{{"delta":"done","phase":"finalAnswer"}}}}' - ;; - 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) @@ -1473,31 +995,3 @@ async fn wait_for_persisted_mapping(handler: &RuntimeWorkRpcHandler) -> Value { } panic!("persisted local task mapping was not restored"); } - -async fn wait_for_running_tool_transcript_events(receiver: &mut broadcast::Receiver) { - tokio::time::timeout(std::time::Duration::from_secs(5), async { - let mut saw_tool_update = false; - let mut saw_final_delta = false; - loop { - let event = receiver - .recv() - .await - .expect("runtime event channel should stay open"); - if event["event"] == "response.block.updated" - && event["payload"]["data"]["updates"]["tool_output"] == "ok\n" - { - saw_tool_update = true; - } - if event["event"] == "response.output_text.delta" - && event["payload"]["data"]["delta"] == "done" - { - saw_final_delta = true; - } - if saw_tool_update && saw_final_delta { - return; - } - } - }) - .await - .expect("timed out waiting for running transcript events"); -} diff --git a/executor/tests/app_runtime_work_send_contract.rs b/executor/tests/app_runtime_work_send_contract.rs index 43e3f234aa6..2af29ecc08f 100644 --- a/executor/tests/app_runtime_work_send_contract.rs +++ b/executor/tests/app_runtime_work_send_contract.rs @@ -5,7 +5,7 @@ use std::{ fs, path::{Path, PathBuf}, - sync::OnceLock, + sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard, OnceLock}, time::{SystemTime, UNIX_EPOCH}, }; @@ -14,12 +14,21 @@ use std::os::unix::fs::PermissionsExt; use rusqlite::Connection; use serde_json::{json, Value}; -use tokio::sync::{broadcast, Mutex, MutexGuard}; +use tokio::sync::broadcast; use wegent_executor::{local::app_ipc::RuntimeWorkHandler, runtime_work::RuntimeWorkRpcHandler}; -async fn env_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().await +struct EnvLockGuard { + _guard: StdMutexGuard<'static, ()>, +} + +async fn env_lock() -> EnvLockGuard { + static LOCK: OnceLock> = OnceLock::new(); + EnvLockGuard { + _guard: LOCK + .get_or_init(|| StdMutex::new(())) + .lock() + .expect("environment lock should be available"), + } } struct EnvGuard { @@ -325,33 +334,6 @@ async fn runtime_tasks_send_accepts_address_content_source_and_attachments() { }) .expect("completed response should contain only the final answer"); assert_eq!(completed["payload"]["data"]["value"], "done"); - - let transcript = handler - .handle_runtime_rpc(json!({ - "method": "runtime.tasks.transcript", - "payload": { - "workspacePath": "/tmp/project", - "taskId": "local-task-1" - } - })) - .await - .expect("transcript should succeed"); - let user = transcript["messages"] - .as_array() - .unwrap() - .iter() - .find(|message| { - message["content"] - .as_str() - .is_some_and(|content| content.starts_with("continue from content")) - }) - .expect("cached follow-up user message should be present"); - assert_eq!(user["source"], source); - assert_eq!(user["attachments"][0]["filename"], "photo.png"); - assert_eq!(user["attachments"][0]["status"], "ready"); - assert_eq!(user["attachments"][0]["file_size"], 1200); - assert_eq!(user["attachments"][0]["local_preview_url"], attachment_path); - assert_eq!(user["attachments"][0]["local_path"], attachment_path); } #[tokio::test] @@ -1642,6 +1624,7 @@ async fn runtime_tasks_send_after_cancel_resumes_started_thread_not_local_task_i .await .expect("create should be accepted"); wait_for_thread_mapping(&handler, "local-visible-task", "thread-1").await; + wait_for_method_count(&log_path, "turn/start", 1).await; handler .handle_runtime_rpc(json!({ @@ -2169,7 +2152,6 @@ while IFS= read -r line; do elif printf '%s\n' "$line" | grep -q '"id":99' && printf '%s\n' "$line" | grep -q '"result"'; then printf '%s\n' '{{"method":"item/agentMessage/delta","params":{{"delta":"answered","phase":"finalAnswer"}}}}' printf '%s\n' '{{"method":"turn/completed","params":{{"turn":{{"id":"turn-input","status":"completed"}}}}}}' - exit 0 fi done "#, @@ -2217,12 +2199,12 @@ while IFS= read -r line; do case "$line" in *'second turn'*) printf '%s\n' '{{"method":"turn/completed","params":{{"turn":{{"id":"turn-2","status":"completed"}}}}}}' - exit 0 ;; esac - sleep 2 - printf '%s\n' '{{"method":"turn/completed","params":{{"turn":{{"id":"turn-1","status":"completed"}}}}}}' - exit 0 + ;; + *'"method":"turn/interrupt"'*) + printf '%s\n' '{{"id":'"$request_id"',"result":{{}}}}' + printf '%s\n' '{{"method":"turn/completed","params":{{"turn":{{"id":"turn-1","status":"cancelled"}}}}}}' ;; esac done diff --git a/executor/tests/build_entrypoint_contract.rs b/executor/tests/build_entrypoint_contract.rs index 769f7d5ef6e..d328584e90e 100644 --- a/executor/tests/build_entrypoint_contract.rs +++ b/executor/tests/build_entrypoint_contract.rs @@ -70,7 +70,8 @@ fn executor_build_entrypoints_use_rust_binary_build() { assert!(dev_sidecar.contains("--bin wegent-executor-dev")); assert!(dev_sidecar.contains("cargo build")); assert!(dev_sidecar.contains("configure_wegent_cargo_target_dir \"$PROJECT_DIR\" \"executor\"")); - assert!(dev_sidecar.contains("executor-dev/$(executor_source_cache_key)")); + assert!(dev_sidecar.contains("$cache_root/executor-dev")); + assert!(!dev_sidecar.contains("executor_source_cache_key")); assert!(dev_sidecar.contains("WEGENT_EXECUTOR_SOURCE_DIR")); assert!(dev_sidecar .contains("cargo_target_binary_path \"$EXECUTOR_DIR\" debug wegent-executor-dev")); diff --git a/executor/tests/local_app_ipc_contract.rs b/executor/tests/local_app_ipc_contract.rs index 96c01dbef74..a9fa6893aa5 100644 --- a/executor/tests/local_app_ipc_contract.rs +++ b/executor/tests/local_app_ipc_contract.rs @@ -6,11 +6,10 @@ use std::{ fs, future::Future, pin::Pin, - sync::{Arc, Mutex, OnceLock}, + sync::{Arc, Mutex, MutexGuard, OnceLock}, }; use serde_json::{json, Value}; -use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard}; use wegent_executor::local::{ app_ipc::{ app_ipc_listening_log_line, read_app_ipc_addr_file, AppIpcError, AppIpcServer, @@ -37,9 +36,18 @@ const LOCAL_GIT_ENV_VARS: &[&str] = &[ "GIT_COMMON_DIR", ]; -async fn env_lock() -> AsyncMutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| AsyncMutex::new(())).lock().await +struct EnvLockGuard { + _guard: MutexGuard<'static, ()>, +} + +async fn env_lock() -> EnvLockGuard { + static LOCK: OnceLock> = OnceLock::new(); + EnvLockGuard { + _guard: LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("environment lock should be available"), + } } struct EnvGuard { @@ -868,6 +876,7 @@ async fn app_ipc_addr_can_be_overridden() { } task.abort(); + let _ = task.await; let _ = std::fs::remove_file(&addr_file); let _ = std::fs::remove_dir_all(addr_file.parent().unwrap()); @@ -955,6 +964,7 @@ async fn app_ipc_socket_serves_ready_event_and_responses() { assert_eq!(response["error"]["code"], "unsupported_method"); task.abort(); + let _ = task.await; let _ = std::fs::remove_file(&addr_file); let _ = std::fs::remove_dir_all(addr_file.parent().unwrap()); } diff --git a/executor/tests/local_backend_contract.rs b/executor/tests/local_backend_contract.rs index f1a0391a52c..9dd0b27be42 100644 --- a/executor/tests/local_backend_contract.rs +++ b/executor/tests/local_backend_contract.rs @@ -74,9 +74,12 @@ async fn local_backend_accepts_socketio_wrapped_registration_ack() { #[tokio::test] async fn local_backend_heartbeat_reports_running_tasks_capabilities_and_auth_files() { + let _lock = ENV_LOCK.lock().await; + let _codex_home = EnvGuard::set("CODEX_HOME", ""); let home = temp_home("auth-report"); std::fs::create_dir_all(home.join(".codex")).unwrap(); std::fs::write(home.join(".codex/auth.json"), "{}").unwrap(); + let expected_auth_path = home.join(".codex/auth.json").display().to_string(); let transport = RecordingTransport::with_responses(vec![json!({"success": true})]); let mut config = local_backend_config(); @@ -101,7 +104,7 @@ async fn local_backend_heartbeat_reports_running_tasks_capabilities_and_auth_fil assert_eq!(calls[0].payload["capabilities"]["skills"], json!([])); assert_eq!( calls[0].payload["runtime_auth_files"]["codex"], - json!({"target_path": "~/.codex/auth.json", "exists": true}) + json!({"target_path": expected_auth_path, "exists": true}) ); } @@ -382,7 +385,7 @@ async fn local_backend_task_execute_streams_claude_tool_use_blocks() { #[cfg(unix)] #[tokio::test] -async fn local_backend_task_execute_splits_large_claude_assistant_message_into_deltas() { +async fn local_backend_task_execute_streams_large_claude_assistant_message() { let _lock = ENV_LOCK.lock().await; let long_text = "abcdefghijklmnopqrstuvwxyz".repeat(7); let claude_event = json!({ @@ -424,15 +427,12 @@ async fn local_backend_task_execute_splits_large_claude_assistant_message_into_d .await; assert_eq!(ack, None); - let expected_chunks = long_text.chars().count().div_ceil(20); - let emits = transport.wait_for_emits(expected_chunks + 2).await; + let emits = transport.wait_for_emit_event("response.completed").await; assert_eq!(emits[0].event, "response.created"); - for emit in &emits[1..=expected_chunks] { - assert_eq!(emit.event, "response.output_text.delta"); - } - assert_eq!(emits[expected_chunks + 1].event, "response.completed"); - let streamed = emits[1..=expected_chunks] + assert_eq!(emits.last().unwrap().event, "response.completed"); + let streamed = emits .iter() + .filter(|event| event.event == "response.output_text.delta") .map(|event| event.payload["data"]["delta"].as_str().unwrap()) .collect::(); assert_eq!(streamed, long_text); @@ -509,12 +509,15 @@ fn local_backend_config_uses_device_config_and_normalizes_token() { assert_eq!(config.configured_capabilities, vec!["claude"]); } -#[test] -fn local_backend_auth_file_report_and_ip_filter_match_python_contract() { +#[tokio::test] +async fn local_backend_auth_file_report_and_ip_filter_follow_runtime_paths() { + let _lock = ENV_LOCK.lock().await; + let _codex_home = EnvGuard::set("CODEX_HOME", ""); let home = temp_home("missing-auth-report"); + let expected_auth_path = home.join(".codex/auth.json").display().to_string(); assert_eq!( build_runtime_auth_file_report(&home), - json!({"codex": {"target_path": "~/.codex/auth.json", "exists": false}}) + json!({"codex": {"target_path": expected_auth_path, "exists": false}}) ); assert!(is_usable_device_ip("192.0.2.10")); @@ -581,6 +584,20 @@ impl RecordingTransport { .await .unwrap() } + + async fn wait_for_emit_event(&self, event: &str) -> Vec { + timeout(Duration::from_secs(3), async { + loop { + let emits = self.emits(); + if emits.iter().any(|emit| emit.event == event) { + return emits; + } + self.notify.notified().await; + } + }) + .await + .unwrap() + } } impl LocalBackendTransport for RecordingTransport { @@ -705,7 +722,6 @@ struct EnvGuard { } impl EnvGuard { - #[cfg(unix)] fn set(key: &'static str, value: &str) -> Self { let previous = std::env::var(key).ok(); std::env::set_var(key, value);