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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 269 additions & 15 deletions executor/src/runtime_work/codex_global_state.rs

Large diffs are not rendered by default.

117 changes: 106 additions & 11 deletions executor/src/runtime_work/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ use crate::{

use super::{
codex_global_state::{
open_codex_global_project, register_codex_global_thread_workspace_root,
remove_codex_global_project, rename_codex_global_project,
reorder_codex_global_project_thread, reorder_codex_global_projects,
set_codex_global_project_appearance, set_codex_global_project_pinned,
set_codex_global_thread_pinned, CodexGlobalProjectIndex,
activate_codex_global_project, open_codex_global_project,
register_codex_global_thread_workspace_root, remove_codex_global_project,
rename_codex_global_project, reorder_codex_global_project_thread,
reorder_codex_global_projects, set_codex_global_project_appearance,
set_codex_global_project_pinned, set_codex_global_thread_pinned,
sync_codex_global_remote_projects, CodexGlobalProjectIndex, CodexGlobalRemoteProject,
},
codex_notifications::codex_notification,
codex_rollout::rollout_context_usage,
Expand Down Expand Up @@ -383,6 +384,10 @@ impl RuntimeWorkRpcHandler {
"runtime.sidebar.projects.appearance" => {
self.set_sidebar_project_appearance(payload).await
}
"runtime.sidebar.projects.sync_remote" => {
self.sync_sidebar_remote_projects(payload).await
}
"runtime.sidebar.projects.activate" => self.activate_sidebar_project(payload).await,
"runtime.sidebar.tasks.reorder" => self.reorder_sidebar_project_task(payload).await,
"runtime.sidebar.tasks.pin" => self.pin_sidebar_task(payload).await,
unsupported => Err(AppIpcError::new(
Expand Down Expand Up @@ -2401,6 +2406,50 @@ impl RuntimeWorkRpcHandler {
Ok(sidebar_mutation_response(&self.device_id))
}

async fn sync_sidebar_remote_projects(&self, payload: Value) -> Result<Value, AppIpcError> {
let items = payload
.get("projects")
.and_then(Value::as_array)
.ok_or_else(|| AppIpcError::new("bad_request", "projects is required"))?;
let projects = items
.iter()
.map(|item| {
Ok(CodexGlobalRemoteProject {
id: string_field(item, "id").ok_or_else(|| {
AppIpcError::new("bad_request", "remote project id is required")
})?,
host_id: string_field(item, "hostId")
.or_else(|| string_field(item, "host_id"))
.ok_or_else(|| {
AppIpcError::new("bad_request", "remote project hostId is required")
})?,
remote_path: string_field(item, "remotePath")
.or_else(|| string_field(item, "remote_path"))
.ok_or_else(|| {
AppIpcError::new("bad_request", "remote project remotePath is required")
})?,
label: string_field(item, "label"),
})
})
.collect::<Result<Vec<_>, AppIpcError>>()?;
sync_codex_global_remote_projects(&projects)
.map_err(|error| AppIpcError::new("codex_global_state_error", error))?;
Ok(sidebar_mutation_response(&self.device_id))
}

async fn activate_sidebar_project(&self, payload: Value) -> Result<Value, AppIpcError> {
let project_key = string_field(&payload, "projectKey")
.or_else(|| string_field(&payload, "project_key"))
.ok_or_else(|| AppIpcError::new("bad_request", "projectKey is required"))?;
let workspace_path = workspace_path(&payload)
.ok_or_else(|| AppIpcError::new("bad_request", "workspacePath is required"))?;
let remote_host_id = string_field(&payload, "remoteHostId")
.or_else(|| string_field(&payload, "remote_host_id"));
activate_codex_global_project(&project_key, &workspace_path, remote_host_id.as_deref())
.map_err(|error| AppIpcError::new("codex_global_state_error", error))?;
Ok(sidebar_mutation_response(&self.device_id))
}

async fn reorder_sidebar_project_task(&self, payload: Value) -> Result<Value, AppIpcError> {
let project_key = string_field(&payload, "projectKey")
.or_else(|| string_field(&payload, "project_key"))
Expand Down Expand Up @@ -4046,6 +4095,8 @@ fn codex_project_workspaces(project_index: &CodexGlobalProjectIndex) -> Vec<Runt
project_source: project.source.clone(),
project_roots: project.roots.clone(),
project_pinned: project.pinned,
project_pinned_order: project.pinned_order,
project_active: project.active,
project_appearance: project.appearance.clone(),
})
})
Expand Down Expand Up @@ -4644,6 +4695,17 @@ fn runtime_handle_json(link: &RuntimeTaskLink) -> Value {
}

fn set_runtime_handle_model_selection(runtime_handle: &mut Value, payload: &Value) {
if let Some(selection) = payload
.get("modelSelection")
.or_else(|| payload.get("model_selection"))
.filter(|value| value.is_object())
{
let mut object = runtime_handle.as_object().cloned().unwrap_or_default();
object.insert("modelSelection".to_owned(), selection.clone());
*runtime_handle = Value::Object(object);
return;
}

let Some(model_name) =
string_field(payload, "modelId").or_else(|| string_field(payload, "model_id"))
else {
Expand Down Expand Up @@ -5827,6 +5889,14 @@ mod tests {
"modelOptions": {
"collaborationMode": "plan"
},
"modelSelection": {
"modelName": "cloud:user:local-model:mimo",
"modelType": "user",
"options": {
"collaborationMode": "plan",
"reasoningEffort": "high"
}
},
"executionRequest": serde_json::to_value(ExecutionRequest::default()).unwrap()
}
}))
Expand All @@ -5835,10 +5905,11 @@ mod tests {
assert_eq!(
response["runtimeHandle"]["modelSelection"],
json!({
"modelName": "local-model:mimo",
"modelType": "runtime",
"modelName": "cloud:user:local-model:mimo",
"modelType": "user",
"options": {
"collaborationMode": "plan"
"collaborationMode": "plan",
"reasoningEffort": "high"
}
})
);
Expand All @@ -5849,17 +5920,41 @@ mod tests {
assert_eq!(
link.runtime_handle["modelSelection"],
json!({
"modelName": "local-model:mimo",
"modelType": "runtime",
"modelName": "cloud:user:local-model:mimo",
"modelType": "user",
"options": {
"collaborationMode": "plan"
"collaborationMode": "plan",
"reasoningEffort": "high"
}
})
);

let _ = fs::remove_file(index_path);
}

#[test]
fn model_selection_falls_back_to_execution_model_for_legacy_requests() {
let mut runtime_handle = json!({});

set_runtime_handle_model_selection(
&mut runtime_handle,
&json!({
"modelId": "legacy-model",
"modelType": "runtime",
"modelOptions": {"reasoningEffort": "medium"}
}),
);

assert_eq!(
runtime_handle["modelSelection"],
json!({
"modelName": "legacy-model",
"modelType": "runtime",
"options": {"reasoningEffort": "medium"}
})
);
}

#[test]
fn active_local_task_skips_global_notification_route() {
let (event_tx, mut event_rx) = broadcast::channel(8);
Expand Down
8 changes: 8 additions & 0 deletions executor/src/runtime_work/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ pub(crate) struct RuntimeWorkspaceLink {
pub project_source: String,
pub project_roots: Vec<String>,
pub project_pinned: bool,
pub project_pinned_order: Option<usize>,
pub project_active: bool,
pub project_appearance: Option<Value>,
}

Expand All @@ -272,6 +274,8 @@ impl Default for RuntimeWorkspaceLink {
project_source: "legacy_root".to_owned(),
project_roots: Vec::new(),
project_pinned: false,
project_pinned_order: None,
project_active: false,
project_appearance: None,
}
}
Expand Down Expand Up @@ -370,6 +374,10 @@ pub(crate) fn workspace_response(
workspace_json["projectSource"] = Value::String(workspace.project_source.clone());
workspace_json["projectRoots"] = json!(workspace.project_roots);
workspace_json["projectPinned"] = Value::Bool(workspace.project_pinned);
if let Some(order) = workspace.project_pinned_order {
workspace_json["projectPinnedOrder"] = json!(order);
}
workspace_json["projectActive"] = Value::Bool(workspace.project_active);
if let Some(appearance) = workspace.project_appearance.clone() {
workspace_json["projectAppearance"] = appearance;
}
Expand Down
32 changes: 31 additions & 1 deletion executor/tests/app_runtime_work_workspace_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,25 @@ async fn runtime_sidebar_semantic_rpcs_update_codex_global_state() {
"method": "runtime.sidebar.projects.appearance",
"payload": {"projectKey": "p2", "appearance": {"color": "blue"}}
}),
json!({
"method": "runtime.sidebar.projects.sync_remote",
"payload": {
"projects": [{
"id": "remote-1",
"hostId": "remote-host-1",
"remotePath": "/srv/remote",
"label": "Remote"
}]
}
}),
json!({
"method": "runtime.sidebar.projects.activate",
"payload": {
"projectKey": "remote-1",
"workspacePath": "/srv/remote",
"remoteHostId": "remote-host-1"
}
}),
json!({
"method": "runtime.sidebar.tasks.reorder",
"payload": {"projectKey": "p2", "threadId": "t2", "beforeThreadId": "t1"}
Expand All @@ -223,9 +242,20 @@ async fn runtime_sidebar_semantic_rpcs_update_codex_global_state() {
}

let state = read_json_file(&codex_home.join(".codex-global-state.json"));
assert_eq!(state["project-order"], json!(["p2", "p1"]));
assert_eq!(state["project-order"], json!(["remote-1", "p2", "p1"]));
assert_eq!(state["pinned-project-ids"], json!(["p2", "p1"]));
assert_eq!(state["project-appearances"]["p2"], json!({"color": "blue"}));
assert_eq!(
state["remote-projects"],
json!([{
"id": "remote-1",
"hostId": "remote-host-1",
"remotePath": "/srv/remote",
"label": "Remote"
}])
);
assert_eq!(state["active-remote-project-id"], "remote-1");
assert_eq!(state["selected-remote-host-id"], "remote-host-1");
assert_eq!(
state["sidebar-project-thread-orders"]["p2"]["threadIds"],
json!(["t2", "t1"])
Expand Down
62 changes: 56 additions & 6 deletions wework/e2e/desktop/task-flow.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const MODEL_API_KEY = 'wework-e2e-test-key'
const MODEL_PROVIDER_ID = 'wework-e2e'
const MODEL_ID = 'gpt-5.4'
const MODEL_LABEL = 'GPT 5.4'
const DEFAULT_MODEL_ID = 'gpt-5.4-mini'
const DEFAULT_MODEL_LABEL = 'GPT 5.4 Mini'
const FRESH_CHAT_PROMPT = 'WEWORK_DESKTOP_E2E_FRESH_CHAT: confirm this is a new conversation.'
const FRESH_CHAT_COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_FRESH_CHAT_COMPLETE'
const ACTIVE_WORKBENCH_SELECTOR = '[data-testid="desktop-workbench-main"]'
Expand Down Expand Up @@ -171,7 +173,7 @@ async function sendPromptUntilScenarioRequest(control, selector, prompt, scenari
throw lastError
}

async function selectE2EModel(control) {
async function selectE2EModel(control, modelId = MODEL_ID, modelLabel = MODEL_LABEL) {
await control.command('waitFor', '[data-testid="model-selector-button"]', {
timeoutMs: WORKBENCH_READY_TIMEOUT_MS,
})
Expand All @@ -183,12 +185,12 @@ async function selectE2EModel(control) {
stableMs: COMPOSER_READY_STABILITY_MS,
timeoutMs: UI_TIMEOUT_MS,
})
await control.command('waitFor', `[data-testid="model-option-${MODEL_ID}"]`, {
await control.command('waitFor', `[data-testid="model-option-${modelId}"]`, {
timeoutMs: UI_TIMEOUT_MS,
})
await control.command('click', `[data-testid="model-option-${MODEL_ID}"]`)
await control.command('click', `[data-testid="model-option-${modelId}"]`)
await control.command('waitFor', '[data-testid="model-selector-button"]', {
text: MODEL_LABEL,
text: modelLabel,
timeoutMs: UI_TIMEOUT_MS,
})
}
Expand Down Expand Up @@ -480,7 +482,10 @@ class DesktopE2EServer {
if (request.method === 'GET' && (url.pathname === '/v1/models' || url.pathname === '/models')) {
json(response, 200, {
object: 'list',
data: [{ id: MODEL_ID, object: 'model', created: 0, owned_by: MODEL_PROVIDER_ID }],
data: [
{ id: MODEL_ID, object: 'model', created: 0, owned_by: MODEL_PROVIDER_ID },
{ id: DEFAULT_MODEL_ID, object: 'model', created: 0, owned_by: MODEL_PROVIDER_ID },
],
})
return
}
Expand Down Expand Up @@ -661,7 +666,7 @@ async function writeCodexConfig(codexHome, modelServerUrl) {
await mkdir(codexHome, { recursive: true })
await writeFile(
join(codexHome, 'config.toml'),
`model_provider = "${MODEL_PROVIDER_ID}"\nmodel = "${MODEL_ID}"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\n\n[model_providers.${MODEL_PROVIDER_ID}]\nname = "Wework Desktop E2E"\nbase_url = "${modelServerUrl}/v1"\nenv_key = "WEWORK_E2E_MODEL_API_KEY"\nwire_api = "responses"\n`,
`model_provider = "${MODEL_PROVIDER_ID}"\nmodel = "${DEFAULT_MODEL_ID}"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\n\n[model_providers.${MODEL_PROVIDER_ID}]\nname = "Wework Desktop E2E"\nbase_url = "${modelServerUrl}/v1"\nenv_key = "WEWORK_E2E_MODEL_API_KEY"\nwire_api = "responses"\n`,
'utf8'
)
}
Expand Down Expand Up @@ -992,6 +997,28 @@ async function main() {
control.toolOutput,
'Codex did not report its real tool execution to the model service'
)

phase = 'conversation-model-restore'
const taskSnapshot = await waitForSnapshot(
control,
snapshot => snapshot.testIds.some(testId => testId.startsWith('runtime-local-task-row-')),
'The completed task was not available for model restoration'
)
const taskRowTestId = taskSnapshot.testIds.find(testId =>
testId.startsWith('runtime-local-task-row-')
)
assert.ok(taskRowTestId, 'The completed task row was not found')
await control.command('click', '[data-testid="new-chat-button"]')
await control.command('waitFor', composerSelector, {
timeoutMs: WORKBENCH_READY_TIMEOUT_MS,
})
await selectE2EModel(control, DEFAULT_MODEL_ID, DEFAULT_MODEL_LABEL)
await control.command('click', `[data-testid="${taskRowTestId}"]`)
await control.command('waitFor', '[data-testid="model-selector-button"]', {
text: MODEL_LABEL,
timeoutMs: UI_TIMEOUT_MS,
})

phase = 'follow-up'
control.setScenario('follow_up')
await sendPrompt(control, composerSelector, FOLLOW_UP_PROMPT)
Expand Down Expand Up @@ -1089,6 +1116,29 @@ async function main() {
timeoutMs: UI_TIMEOUT_MS,
})

phase = 'standalone-new-task-state'
await control.command('click', '[data-testid="runtime-chat-section-new-chat-button"]')
const standaloneTaskSnapshot = await waitForSnapshot(
control,
snapshot =>
snapshot.testIds.includes('project-work-button') &&
(snapshot.text.includes('请选择项目') || snapshot.text.includes('Select project')),
'The task-section new-task action selected a project'
)
assert.ok(
standaloneTaskSnapshot.testIds.includes('project-work-button'),
'The standalone new task did not render the project selector'
)

await control.command('click', '[data-testid="new-chat-button"]')
await waitForSnapshot(
control,
snapshot =>
snapshot.testIds.includes('project-work-button') &&
(snapshot.text.includes('请选择项目') || snapshot.text.includes('Select project')),
'The global new-task action did not preserve the standalone project state'
)

await writeFile(
join(resultDir, 'model-requests.json'),
`${JSON.stringify(control.modelRequests, null, 2)}\n`,
Expand Down
Loading
Loading