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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions .github/actions/build-wework-core-e2e/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ inputs:
backend-rs-image:
description: Content-addressed Rust backend runtime image
required: true
runtime-binaries-ready:
description: Whether the caller already provided the runtime binaries from images that could not be pulled
required: false
default: "false"
save-cache:
description: Whether writable dependency caches may be saved
required: false
Expand Down Expand Up @@ -38,6 +42,7 @@ runs:
env:
EXECUTOR_IMAGE: ${{ inputs.executor-image }}
BACKEND_RS_IMAGE: ${{ inputs.backend-rs-image }}
RUNTIME_BINARIES_READY: ${{ inputs.runtime-binaries-ready }}
GITHUB_TOKEN: ${{ github.token }}
OCI_RUNTIME_WAIT_SECONDS: "420"
CI: true
Expand All @@ -46,21 +51,34 @@ runs:
WEWORK_ELECTRON_DEPENDENCIES_READY: "true"
run: |
mkdir -p .ci-artifacts
.github/scripts/restore-oci-runtime-binary.sh \
"$EXECUTOR_IMAGE" \
/app/executor \
.ci-artifacts/wegent-executor &
executor_pid=$!
.github/scripts/restore-oci-runtime-binary.sh \
"$BACKEND_RS_IMAGE" \
/app/wegent-backend-rs \
.ci-artifacts/wegent-backend-rs &
backend_pid=$!
executor_pid=""
backend_pid=""
if [[ "$RUNTIME_BINARIES_READY" == "true" ]]; then
for runtime in executor backend-rs; do
if [[ ! -s ".ci-artifacts/wegent-$runtime" ]]; then
echo "Provided runtime binary is missing: .ci-artifacts/wegent-$runtime" >&2
exit 1
fi
# Artifact upload does not preserve the executable bit.
chmod 0755 ".ci-artifacts/wegent-$runtime"
done
else
.github/scripts/restore-oci-runtime-binary.sh \
"$EXECUTOR_IMAGE" \
/app/executor \
.ci-artifacts/wegent-executor &
executor_pid=$!
.github/scripts/restore-oci-runtime-binary.sh \
"$BACKEND_RS_IMAGE" \
/app/wegent-backend-rs \
.ci-artifacts/wegent-backend-rs &
backend_pid=$!
fi

status=0
pnpm --filter wework ai:verify:electron:build || status=$?
if ! wait "$executor_pid"; then status=1; fi
if ! wait "$backend_pid"; then status=1; fi
if [[ -n "$executor_pid" ]] && ! wait "$executor_pid"; then status=1; fi
if [[ -n "$backend_pid" ]] && ! wait "$backend_pid"; then status=1; fi
if ((status == 0)); then
test -x wework/electron/release/WeWork-linux-x64/WeWork
test -x wework/electron/release/WeWork-linux-x64/resources/bin/wegent-executor
Expand Down
119 changes: 118 additions & 1 deletion .github/workflows/wework-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
wework_desktop_other_e2e: ${{ steps.classify.outputs.wework_desktop_other_e2e }}
wework_desktop_other_e2e_matrix: ${{ steps.classify.outputs.wework_desktop_other_e2e_matrix }}
wework_desktop_macos_inspector_e2e: ${{ steps.classify.outputs.wework_desktop_macos_inspector_e2e }}
wework_desktop_rust_runtime_missing: ${{ steps.rust-runtime-check.outputs.missing }}

steps:
- name: Checkout code
Expand Down Expand Up @@ -146,6 +147,26 @@ jobs:
echo "backend-rs-ref=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/wegent-backend-rs-e2e:v1-$BACKEND_RS_SOURCE_DIGEST"
} >> "$GITHUB_OUTPUT"

# Content-addressed Rust runtimes are only published by main and by same-repo
# pull requests. Pull requests from forks cannot publish them, so they have to
# rebuild the missing runtimes locally instead of waiting for an image that
# will never appear.
- name: Check shared Rust E2E runtimes
if: steps.classify.outputs.wework_desktop_e2e == 'true' && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (github.event.action != 'labeled' || github.event.label.name == 'ci:all')))
id: rust-runtime-check
env:
EXECUTOR_IMAGE: ${{ steps.runtime.outputs.executor-ref }}
BACKEND_RS_IMAGE: ${{ steps.runtime.outputs.backend-rs-ref }}
run: |
missing=false
for image in "$EXECUTOR_IMAGE" "$BACKEND_RS_IMAGE"; do
if ! docker manifest inspect "$image" >/dev/null 2>&1; then
echo "Missing content-addressed runtime: $image" >&2
missing=true
fi
done
echo "missing=$missing" >> "$GITHUB_OUTPUT"

- name: Build and publish Wework browser E2E image
if: steps.browser-image-check.outputs.exists == 'false'
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
Expand Down Expand Up @@ -218,11 +239,99 @@ jobs:
if-no-files-found: ignore
retention-days: 7

prepare-wework-desktop-e2e-runtime:
name: Rebuild Missing Wework Desktop E2E Runtimes
needs:
- changes
if: needs.changes.outputs.wework_desktop_rust_runtime_missing == 'true' && (needs.changes.outputs.wework_desktop_core_e2e == 'true' || needs.changes.outputs.wework_desktop_cloud_e2e == 'true' || needs.changes.outputs.wework_desktop_other_e2e == 'true') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (github.event.action != 'labeled' || github.event.label.name == 'ci:all')))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: read

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
filter: blob:none
persist-credentials: false

- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f

- name: Resolve Executor E2E base image
id: executor-runtime
env:
SOURCE_DIGEST: ${{ hashFiles('frontend/e2e/fixtures/claudecode-executor/Dockerfile', 'executor/Cargo.toml', 'executor/Cargo.lock', 'executor/src/**', 'shared/assets/**') }}
run: .github/scripts/resolve-executor-e2e-runtime.sh

- name: Build Executor E2E runtime image
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
with:
context: .
file: frontend/e2e/fixtures/claudecode-executor/Dockerfile
build-args: BASE_IMAGE=${{ steps.executor-runtime.outputs.base-image }}
load: true
push: false
tags: wegent/e2e-claudecode-executor:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/wegent-executor:buildcache-e2e

- name: Extract Executor E2E runtime binary
run: |
mkdir -p .ci-artifacts
container_id="$(docker create wegent/e2e-claudecode-executor:latest)"
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker cp "$container_id:/app/executor" .ci-artifacts/wegent-executor
chmod 0755 .ci-artifacts/wegent-executor

- name: Set up Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8

- name: Set up executor Rust cache
uses: ./.github/actions/setup-executor-rust-cache

- name: Build Rust backend E2E gateway
env:
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_STRIP: "false"
CARGO_TARGET_DIR: ${{ runner.temp }}/backend-rs-target
run: |
cargo build \
--release \
--locked \
--manifest-path backend-rs/Cargo.toml \
--bin wegent-backend-rs
mkdir -p .ci-artifacts
cp "$CARGO_TARGET_DIR/release/wegent-backend-rs" .ci-artifacts/wegent-backend-rs
chmod 0755 .ci-artifacts/wegent-backend-rs

- name: Upload rebuilt Wework desktop E2E runtimes
uses: actions/upload-artifact@v4
with:
name: wework-desktop-e2e-runtimes
path: |
.ci-artifacts/wegent-executor
.ci-artifacts/wegent-backend-rs
Comment thread
sdadunderscoresdad marked this conversation as resolved.
if-no-files-found: error
retention-days: 1
compression-level: 0

build-wework-desktop-core-e2e:
name: Build Shared Wework Desktop E2E Artifact
needs:
- changes
if: (needs.changes.outputs.wework_desktop_core_e2e == 'true' || needs.changes.outputs.wework_desktop_cloud_e2e == 'true' || needs.changes.outputs.wework_desktop_other_e2e == 'true') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (github.event.action != 'labeled' || github.event.label.name == 'ci:all')))
- prepare-wework-desktop-e2e-runtime
# `always()` keeps this job running when the runtime rebuild was skipped, while
# the explicit result checks still stop it when that rebuild failed or was cancelled.
if: always() && needs.prepare-wework-desktop-e2e-runtime.result != 'failure' && needs.prepare-wework-desktop-e2e-runtime.result != 'cancelled' && (needs.changes.outputs.wework_desktop_core_e2e == 'true' || needs.changes.outputs.wework_desktop_cloud_e2e == 'true' || needs.changes.outputs.wework_desktop_other_e2e == 'true') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (github.event.action != 'labeled' || github.event.label.name == 'ci:all')))
runs-on: ubuntu-latest
timeout-minutes: 35
env:
Expand All @@ -240,6 +349,13 @@ jobs:
filter: blob:none
persist-credentials: false

- name: Download rebuilt Wework desktop E2E runtimes
if: needs.prepare-wework-desktop-e2e-runtime.result == 'success'
uses: actions/download-artifact@v4
with:
name: wework-desktop-e2e-runtimes
path: .ci-artifacts
Comment thread
sdadunderscoresdad marked this conversation as resolved.

- name: Set up ORAS
uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d
with:
Expand Down Expand Up @@ -287,6 +403,7 @@ jobs:
with:
executor-image: ${{ needs.changes.outputs.executor_image }}
backend-rs-image: ${{ needs.changes.outputs.backend_rs_image }}
runtime-binaries-ready: ${{ needs.prepare-wework-desktop-e2e-runtime.result == 'success' }}
save-cache: ${{ github.ref == 'refs/heads/main' }}

- name: Upload Wework desktop Core E2E build
Expand Down
115 changes: 115 additions & 0 deletions executor/src/runtime_work/codex_user_input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2026 Weibo, Inc.
//
// SPDX-License-Identifier: Apache-2.0

use serde_json::{json, Value};

use super::util::string_field;

/// A `request_user_input_async` question: one prompt plus suggested answers.
///
/// Codex uses the same shape in the live `agentMessage.questions` payload and in
/// the replayed `request_user_input_async` tool arguments, so both projections of
/// the interactive card normalize here. The answer to such a question is always
/// the next user message, never a runtime response.
pub(crate) fn async_question_render_payload(item_id: &str, questions: &[Value]) -> Option<Value> {
let questions = questions
.iter()
.enumerate()
.filter_map(|(index, question)| async_question(index, question))
.collect::<Vec<_>>();
if questions.is_empty() {
return None;
}
Some(json!({
"kind": "request_user_input",
"delivery": "async",
"itemId": item_id,
"questions": questions,
}))
}

fn async_question(index: usize, question: &Value) -> Option<Value> {
let prompt = string_field(question, "title")
.or_else(|| string_field(question, "question"))
.unwrap_or_default()
.trim()
.to_owned();
let options = question
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(option_label)
.collect::<Vec<_>>();
if prompt.is_empty() && options.is_empty() {
return None;
}
Some(json!({
"id": format!("question_{}", index + 1),
"question": prompt,
"options": options,
// Codex accepts a free-text answer next to the suggested options.
"is_other": true,
}))
}

fn option_label(option: &Value) -> Option<Value> {
let label = option
.as_str()
.map(str::to_owned)
.or_else(|| string_field(option, "label"))
.unwrap_or_default();
let label = label.trim();
(!label.is_empty()).then(|| json!({ "label": label }))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn normalizes_title_and_plain_string_options() {
let payload = async_question_render_payload(
"call-1",
&[json!({
"title": "Which state jitters?",
"options": ["Following", "Reading", "Both"],
})],
)
.expect("payload");

assert_eq!(payload["kind"], "request_user_input");
assert_eq!(payload["delivery"], "async");
assert_eq!(payload["itemId"], "call-1");
assert_eq!(payload["questions"][0]["id"], "question_1");
assert_eq!(payload["questions"][0]["question"], "Which state jitters?");
assert_eq!(payload["questions"][0]["is_other"], true);
assert_eq!(payload["questions"][0]["options"][0]["label"], "Following");
assert_eq!(payload["questions"][0]["options"][2]["label"], "Both");
}

#[test]
fn drops_blank_questions_and_blank_options() {
let payload = async_question_render_payload(
"call-2",
&[
json!({"title": " "}),
json!({"title": "Pick one", "options": [" ", "Second"]}),
],
)
.expect("payload");

let questions = payload["questions"].as_array().expect("questions");
assert_eq!(questions.len(), 1);
assert_eq!(questions[0]["question"], "Pick one");
assert_eq!(questions[0]["options"].as_array().map(Vec::len), Some(1));
assert_eq!(questions[0]["options"][0]["label"], "Second");
}

#[test]
fn rejects_questions_without_prompt_or_options() {
assert!(async_question_render_payload("call-3", &[json!({"title": ""})]).is_none());
assert!(async_question_render_payload("call-4", &[]).is_none());
}
}
Loading
Loading