diff --git a/docs/en/wework/developer-guide/wework-plugin-interactive-forms.md b/docs/en/wework/developer-guide/wework-plugin-interactive-forms.md new file mode 100644 index 0000000000..f08bc60909 --- /dev/null +++ b/docs/en/wework/developer-guide/wework-plugin-interactive-forms.md @@ -0,0 +1,408 @@ +--- +sidebar_position: 36 +--- + +# Plugin Interactive Forms + +Wework supports interactive forms from plugins during a running conversation. The user sees a form card in chat, selects or enters answers, and submits the response back to the running plugin. Plugin authors should trigger this through MCP elicitation. `request_user_input` is an internal render protocol between executor and Wework and should not be emitted directly by plugins. + +## When To Use It + +Use an interactive form when plugin execution must wait for a user decision, such as: + +- Choosing one execution strategy from several options. +- Confirming whether to allow a high-impact operation. +- Selecting a target environment, release scope, or data source. +- Providing a short parameter required by the plugin. + +If the plugin only needs to explain progress and does not need to block execution, use normal chat output instead. A form blocks the current run until the user submits, cancels, or stops the task. + +## Trigger + +The plugin MCP server sends an MCP elicitation request while a tool is running. Use `mode: "form"` when building plugins for Wework. The Wework executor can recognize both `form` and `openai/form` at the runtime event layer, but that does not mean the Codex MCP client connected to the plugin will allow an `openai/form` request to be sent. + +This only works when the plugin is running inside the Wework local Codex chat runtime and the MCP elicitation request reaches the Wework executor. It is not a universal feature of every MCP client. Even when the plugin is installed and running in Wework, the plugin still talks first to the Codex MCP client. If that client does not declare support for `openai/form`, the SDK or host can fail before sending the request with an error such as `The MCP client does not support openai/form requests.` In that case Wework cannot render the form because the request never entered Wework's runtime event stream. + +The plugin-side request shape is: + +```json +{ + "jsonrpc": "2.0", + "id": "example-form-1", + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Choose how to continue", + "requestedSchema": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "title": "Strategy", + "description": "How should the plugin continue?", + "oneOf": [ + { "const": "fast", "title": "Fast" }, + { "const": "safe", "title": "Safe" }, + { "const": "manual", "title": "Ask me before each step" } + ] + } + }, + "required": ["strategy"] + } + } +} +``` + +When the request reaches the Wework executor, Codex app-server forwards it as a runtime event: + +```json +{ + "method": "mcpServer/elicitation/request", + "params": { + "serverName": "example-plugin", + "mode": "form", + "message": "Choose how to continue", + "requestedSchema": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "title": "Strategy", + "description": "How should the plugin continue?", + "oneOf": [ + { "const": "fast", "title": "Fast" }, + { "const": "safe", "title": "Safe" }, + { "const": "manual", "title": "Ask me before each step" } + ] + } + }, + "required": ["strategy"] + } + } +} +``` + +Plugin code should usually not hand-write JSON-RPC. Prefer the elicitation API from the MCP SDK you are using, as long as the resulting request contains the fields above. For broader host compatibility, check MCP client capabilities before calling elicitation. If capabilities are unavailable or the client does not support form elicitation, do not send the form request. + +For Wework-targeted plugins, use `mode: "form"` first. Use `openai/form` only when the target host's MCP client capabilities explicitly support it. + +## Support Boundary + +Wework's implementation path is: + +```text +Plugin MCP server sends elicitation + -> Codex chat runtime receives mcpServer/elicitation/request + -> executor converts it to a request_user_input block + -> Wework chat renders the form + -> user submits + -> executor converts the answer back to an MCP elicitation result +``` + +Interactive forms are not limited to Plan mode. Wework does have a built-in "execute this plan?" confirmation card that comes from assistant plan blocks, but plugin forms use the MCP elicitation path. Whether a plugin form appears depends on whether the request reaches the Wework executor, whether the `mode` is supported, and whether the schema can be mapped. It does not depend on the conversation being in Plan mode. + +If the plugin receives `action: "decline"` but the Wework executor log does not contain `codex mcp elicitation request` or `mcpServer/elicitation/request`, the request was rejected inside the Codex MCP runtime before it reached Wework's UI forwarding layer. Common causes include: + +- The MCP runtime has `elicitations_auto_deny` enabled. +- The current turn/thread uses `approvalPolicy: "never"`. +- The current turn/thread uses granular approval policy with `mcp_elicitations: false`. +- The tool call is not running inside an active turn, so there is no event channel to forward the request to app-server/client. + +Wework should use an approval policy that allows MCP elicitations. The recommended shape keeps execution approvals disabled while allowing MCP forms: + +```json +{ + "approvalPolicy": { + "granular": { + "sandbox_approval": false, + "rules": false, + "skill_approval": false, + "request_permissions": false, + "mcp_elicitations": true + } + } +} +``` + +At the same time, do not make normal MCP tool calls ask for user approval every time. Wework previously used `approvalPolicy: "never"`, which auto-approved normal MCP tool approval prompts under the full-access permission profile. After switching to granular policy, Wework-injected or Wework-managed MCP server config must explicitly preserve that equivalent behavior: + +```toml +[mcp_servers.example_plugin] +default_tools_approval_mode = "approve" +``` + +Request-level, bot-level, and Wework built-in persistent MCP servers should all follow this rule. For tools from Wegent Connector Apps, Wework writes the built-in persistent MCP server `mcp_servers.wegent_apps` with the same `default_tools_approval_mode = "approve"` and refreshes existing config during connector configure or app sync. + +If a plugin tool really needs per-call approval, the plugin config can explicitly declare `default_tools_approval_mode = "prompt"`. Wework must preserve that explicit setting. + +Also keep: + +```toml +[features] +tool_call_mcp_elicitation = false +``` + +These switches control different behavior: + +- `approvalPolicy.granular.mcp_elicitations: true` allows plugin business forms to be forwarded from the MCP runtime to the Wework UI. +- `mcp_servers..default_tools_approval_mode = "approve"` makes normal MCP tool calls not require approval cards. +- `features.tool_call_mcp_elicitation: false` only means Codex should not wrap tool-call approval cards as MCP elicitation forms. If the tool approval mode still requires approval, Codex may fall back to a normal `request_user_input` approval card. + +## Implementation Boundary + +Keep these boundaries when supporting plugin forms so existing approval behavior is not changed accidentally: + +- Do not switch `approvalPolicy` to broadly allow every approval type. Wework only needs `mcp_elicitations` enabled; the other approval gates stay disabled. +- Wework-injected or Wework-managed MCP servers should default to `default_tools_approval_mode = "approve"` to preserve the previous no-prompt behavior for normal tool calls. +- If an MCP server or tool explicitly declares `default_tools_approval_mode = "prompt"`, Wework must preserve that approval requirement. +- Wework-owned built-in MCP servers, such as `wework_browser` and `wegent_apps`, follow the same default no-normal-tool-approval rule. +- `mcpServerOpenaiFormElicitation` is not required for standard `mode: "form"`. Do not advertise it in initialize capabilities unless Wework and the downstream client actually support the `openai/form` extension. +- Shell, file, sandbox, rule, skill, and request-permission approvals should not change because plugin forms are enabled. + +Code-level boundaries: + +- Codex thread/turn params use granular approval policy and only enable `mcp_elicitations`. +- `features.tool_call_mcp_elicitation=false` prevents normal MCP tool approval from being wrapped as a business form. +- Request/bot MCP config defaults to `default_tools_approval_mode = "approve"`, with explicit `prompt` taking precedence. +- `mcp_servers.wegent_apps` is the Wework Connector Apps built-in persistent server, so Wework writes it and refreshes it during configure/app sync with the same default `approve` behavior. + +Use the error location to debug: + +| Symptom | Meaning | Action | +| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `The MCP client does not support openai/form requests.` | The Codex MCP client connected to the plugin does not support `openai/form`; the request did not reach Wework | Switch to `mode: "form"`, or fall back to normal chat/tool parameters | +| Plugin receives `action: "decline"`, but logs do not contain `mcpServer/elicitation/request` | Codex MCP runtime rejected the request before Wework UI | Check `elicitations_auto_deny`, `approvalPolicy`, granular `mcp_elicitations`, and active-turn routing | +| No form appears, but logs contain `mcpServer/elicitation/request` | The request reached the runtime, but the schema may be unsupported | Check `mode` and `requestedSchema.properties` | +| Every MCP tool call shows an "Allow this MCP tool call" form | The MCP server/tool approval mode still requires approval | Set `default_tools_approval_mode="approve"` for plugin servers that do not need approval; explicitly keep `prompt` for servers/tools that do | +| The form appears but the plugin does not continue after submit | Response routing or plugin result handling is broken | Verify the plugin handles `accept`, `cancel`, and `decline` | + +## Schema Mapping + +Wework reads `requestedSchema.properties` and turns each property into one question. + +| Schema field | Wework behavior | +| ---------------- | -------------------------------------------------------- | +| property key | Question id and response key | +| `title` | Question header | +| `description` | Question text; falls back to `title` or the property key | +| `oneOf[].title` | Option label shown to the user | +| `oneOf[].const` | Stable value returned to the plugin | +| `enumNames[]` | Option label shown to the user | +| `enum[]` | Stable value returned to the plugin | +| `type: boolean` | Shows `true` and `false` options | +| no option fields | Shows a short text input | + +Current UI behavior: + +- Each question with options defaults to the first option. +- If the form has a single question, selecting an option submits immediately. +- If the form has multiple questions, the user can change options and then click Submit. +- Text inputs can be submitted empty; the plugin must validate required values. + +## Result + +After the user submits, executor converts Wework's internal answer payload back to an MCP elicitation result. The plugin receives a result like: + +```json +{ + "action": "accept", + "content": { + "strategy": "fast" + }, + "_meta": null +} +``` + +If the user cancels, the task is stopped, or no usable answer is available, the result is usually: + +```json +{ + "action": "cancel", + "content": null, + "_meta": null +} +``` + +If the request mode or schema is unsupported, executor returns: + +```json +{ + "action": "decline", + "content": null, + "_meta": null +} +``` + +Plugins must handle `accept`, `cancel`, and `decline` explicitly. Do not assume the user will always submit, and do not continue a high-impact operation after cancellation. + +## Examples + +### Single Choice + +```json +{ + "mode": "form", + "message": "Choose a deployment target", + "requestedSchema": { + "type": "object", + "properties": { + "target": { + "type": "string", + "title": "Deployment target", + "description": "Select the target environment for this deployment.", + "oneOf": [ + { "const": "staging", "title": "Staging" }, + { "const": "production", "title": "Production" } + ] + } + }, + "required": ["target"] + } +} +``` + +After the user chooses `Production`, the plugin receives: + +```json +{ + "action": "accept", + "content": { + "target": "production" + }, + "_meta": null +} +``` + +### Boolean Confirmation + +```json +{ + "mode": "form", + "message": "Continue deleting the cache?", + "requestedSchema": { + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "title": "Confirm deletion", + "description": "This operation clears the current workspace cache." + } + }, + "required": ["confirm"] + } +} +``` + +After the user chooses `true`, the plugin receives: + +```json +{ + "action": "accept", + "content": { + "confirm": true + }, + "_meta": null +} +``` + +### Text Input + +```json +{ + "mode": "form", + "message": "Enter release notes", + "requestedSchema": { + "type": "object", + "properties": { + "releaseNote": { + "type": "string", + "title": "Release note", + "description": "This text will be written to the release record." + } + }, + "required": ["releaseNote"] + } +} +``` + +After the user submits, the plugin receives: + +```json +{ + "action": "accept", + "content": { + "releaseNote": "Fix login failures and improve startup speed" + }, + "_meta": null +} +``` + +### Custom Option + +Wework does not currently support a conditional form where one option is "Custom" and selecting it expands an input field. Model this as two fields instead: one single-choice field for built-in options or `custom`, and one text field for the custom value. + +```json +{ + "mode": "form", + "message": "Choose how to handle this", + "requestedSchema": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "title": "Strategy", + "description": "Choose a handling strategy.", + "oneOf": [ + { "const": "fast", "title": "Fast" }, + { "const": "safe", "title": "Safe" }, + { "const": "custom", "title": "Custom" } + ] + }, + "customStrategy": { + "type": "string", + "title": "Custom strategy", + "description": "If you chose Custom above, describe the requested handling." + } + }, + "required": ["strategy"] + } +} +``` + +After the user chooses Custom and enters text, the plugin receives: + +```json +{ + "action": "accept", + "content": { + "strategy": "custom", + "customStrategy": "Inspect the diff first, then only update tests" + }, + "_meta": null +} +``` + +The plugin must validate this itself: when `strategy` is `custom`, require `customStrategy` to be non-empty; otherwise ignore `customStrategy`. + +## Constraints + +- Use `mode: "form"` for Wework-targeted plugins. `openai/form` is only a compatibility mode after executor receives an event; do not use it as the default Wework plugin mode. +- `requestedSchema.properties` must exist; missing or unsupported shapes are declined. +- Prefer `oneOf` for single choice fields because it provides both display labels and stable machine values. +- `enum` currently supports string values only; provide `enumNames` when display labels differ from values. +- `oneOf[].const` is currently treated as a string value; do not depend on object, number, or boolean const values. +- `array` is converted to an array result, but the current UI is not a true multi-select control. Do not use it for real multi-select choices. +- Number and integer fields are parsed after submission. If parsing fails, the field is omitted, so plugins must validate results. +- `required` is primarily plugin-side semantics. Wework does not currently block empty text submissions. +- To provide a stable default choice, place that option first. +- A single-question option form submits as soon as the user clicks an option. For a strict "choose, then confirm" flow, split the request into a multi-question form or wait for Wework to add an explicit control. +- Conditional controls are not available yet. For "custom option plus input", model it as a single-choice field plus a text field and validate it in the plugin. + +## Development Checklist + +- The form request uses MCP elicitation, not plain Markdown or internal `request_user_input` JSON. +- Every field has a stable property key, and plugin logic depends only on returned `content` values. +- Every option has a user-facing `title` and a machine-facing `const`. +- The plugin handles `cancel` and `decline` by stopping or rolling back pending work. +- The plugin validates required fields, numeric ranges, environment names, and other business rules. +- High-impact operations use clear copy that explains scope and consequences. diff --git a/docs/zh/wework/developer-guide/wework-plugin-interactive-forms.md b/docs/zh/wework/developer-guide/wework-plugin-interactive-forms.md new file mode 100644 index 0000000000..070e379392 --- /dev/null +++ b/docs/zh/wework/developer-guide/wework-plugin-interactive-forms.md @@ -0,0 +1,408 @@ +--- +sidebar_position: 36 +--- + +# 插件交互式表单 + +Wework 支持插件在对话运行中向用户发起交互式表单,让用户在聊天界面中选择或输入答案并提交。插件开发者应通过 MCP elicitation 发起表单请求;`request_user_input` 是 Wework 和 executor 内部使用的渲染协议,不应由插件直接构造。 + +## 适用场景 + +使用交互式表单处理必须由用户决定的分支,例如: + +- 从多个执行策略中选择一个。 +- 确认是否允许某个高影响操作。 +- 选择目标环境、发布范围或数据源。 +- 补充插件执行所需的短文本参数。 + +如果只是普通说明或不需要阻塞插件执行,继续使用普通对话文本即可。表单会阻塞当前运行,直到用户提交、取消,或任务被停止。 + +## 触发方式 + +插件的 MCP server 在工具执行过程中发送 MCP elicitation 请求。面向 Wework 开发插件时使用 `mode: "form"`。Wework executor 在运行时事件层可以兼容识别 `form` 和 `openai/form`,但这不代表插件侧连接到的 Codex MCP client 一定允许发出 `openai/form` 请求。 + +这个能力只在插件运行于 Wework 本地 Codex 对话运行时,并且 MCP elicitation 请求能够到达 Wework executor 时生效。它不是所有 MCP client 的通用能力。即使插件安装并运行在 Wework 里,插件直接面对的仍然是 Codex MCP client;如果该 client 未声明支持 `openai/form`,SDK 或宿主会在请求发出前返回类似 `The MCP client does not support openai/form requests.` 的错误。此时 Wework 无法渲染表单,因为请求没有进入 Wework 的运行时事件流。 + +插件侧请求形态如下: + +```json +{ + "jsonrpc": "2.0", + "id": "example-form-1", + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "请选择一种处理方式", + "requestedSchema": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "title": "处理方式", + "description": "你希望插件怎么继续?", + "oneOf": [ + { "const": "fast", "title": "快速处理" }, + { "const": "safe", "title": "稳妥处理" }, + { "const": "manual", "title": "让我确认每一步" } + ] + } + }, + "required": ["strategy"] + } + } +} +``` + +请求到达 Wework executor 时,Codex app-server 会把它转发成运行时事件,形态如下: + +```json +{ + "method": "mcpServer/elicitation/request", + "params": { + "serverName": "example-plugin", + "mode": "form", + "message": "请选择一种处理方式", + "requestedSchema": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "title": "处理方式", + "description": "你希望插件怎么继续?", + "oneOf": [ + { "const": "fast", "title": "快速处理" }, + { "const": "safe", "title": "稳妥处理" }, + { "const": "manual", "title": "让我确认每一步" } + ] + } + }, + "required": ["strategy"] + } + } +} +``` + +具体插件代码不需要手写 JSON-RPC 消息。优先使用所选 MCP SDK 提供的 elicitation API,只要最终发出的请求符合上述字段即可。为了兼容更多宿主,插件应在调用 elicitation 前检查 MCP client capabilities;无法确认 capabilities 或 client 不支持 form elicitation 时,不要发送表单请求。 + +面向 Wework 的插件必须优先使用 `mode: "form"`;只有确认目标宿主的 MCP client capabilities 明确支持 `openai/form` 时,才使用 `openai/form`。 + +## 支持边界 + +Wework 的实现链路如下: + +```text +插件 MCP server 发起 elicitation + -> Codex 对话运行时收到 mcpServer/elicitation/request + -> executor 转成 request_user_input block + -> Wework 聊天界面渲染表单 + -> 用户提交 + -> executor 把答案转回 MCP elicitation result +``` + +交互式表单不只限于 Plan 模式。Wework 里确实有一类内置的“执行此计划?”确认卡,它来自 assistant 的 plan block;但插件表单走的是 MCP elicitation 链路,是否弹出取决于请求是否到达 Wework executor、`mode` 是否支持、schema 是否可映射,而不是当前对话是否处于 Plan 模式。 + +如果插件收到 `action: "decline"`,但 Wework executor 日志里没有 `codex mcp elicitation request` 或 `mcpServer/elicitation/request`,说明请求在 Codex MCP runtime 里已经被提前拒绝,还没有进入 Wework 的 UI 转发层。常见原因包括: + +- MCP runtime 开启了 `elicitations_auto_deny`。 +- 当前 turn/thread 的 `approvalPolicy` 是 `never`。 +- 当前使用 granular approval policy,但 `mcp_elicitations` 为 `false`。 +- 这次工具调用不在 active turn 内,缺少可转发到 app-server/client 的事件通道。 + +Wework 侧需要使用允许 MCP elicitation 的 approval policy。推荐保持执行审批关闭,但单独打开 MCP 表单: + +```json +{ + "approvalPolicy": { + "granular": { + "sandbox_approval": false, + "rules": false, + "skill_approval": false, + "request_permissions": false, + "mcp_elicitations": true + } + } +} +``` + +同时不要让普通 MCP tool call 每次都触发用户授权卡。Wework 原本使用 `approvalPolicy: "never"`,在 full-access 权限配置下普通 MCP tool approval 会自动通过;切换到 granular policy 后,需要在 Wework 注入或托管的 MCP server config 中显式保持等价行为: + +```toml +[mcp_servers.example_plugin] +default_tools_approval_mode = "approve" +``` + +request-level、bot-level 以及 Wework 内置持久 MCP server 都应遵循这个规则。`mcp_servers.wegent_apps` 是 Wegent Connector Apps 的内置持久 server,Wework 会写成同样的 `default_tools_approval_mode = "approve"`,并在 Connector 配置或应用同步时刷新旧配置。 + +如某个插件工具确实需要每次授权,插件配置可以显式声明 `default_tools_approval_mode = "prompt"`,Wework 必须保留该显式配置。 + +另外,Wework runtime 应保持: + +```toml +[features] +tool_call_mcp_elicitation = false +``` + +这些开关控制的不是同一件事: + +- `approvalPolicy.granular.mcp_elicitations: true` 允许插件业务表单从 MCP runtime 转发到 Wework UI。 +- `mcp_servers..default_tools_approval_mode = "approve"` 让普通 MCP tool call 不需要授权卡。 +- `features.tool_call_mcp_elicitation: false` 只表示不要把 Codex tool-call 授权卡包装成 MCP elicitation 表单;如果 tool approval mode 仍然要求审批,Codex 还可能退回普通 `request_user_input` 授权卡。 + +## 实现边界 + +支持插件表单时必须保持下面的边界,避免影响原有授权逻辑: + +- 不要把 `approvalPolicy` 简单改成允许所有审批。Wework 只需要把 `mcp_elicitations` 打开,其它审批项继续关闭。 +- Wework 注入或托管的 MCP server 应默认写 `default_tools_approval_mode = "approve"`,以保持旧的普通 tool call 不弹授权行为。 +- 如果 MCP server 或 tool 显式声明 `default_tools_approval_mode = "prompt"`,Wework 必须保留显式授权要求。 +- Wework 自己生成并托管的内置 MCP server,例如 `wework_browser` 和 `wegent_apps`,也遵循同一条默认免普通 tool approval 规则。 +- `mcpServerOpenaiFormElicitation` 不是标准 `mode: "form"` 的必要 capability。除非 Wework 和下游 client 都真正支持 `openai/form` extension,否则不要在 initialize capability 中声明它。 +- 普通 shell、文件、sandbox、规则、技能或 request permission 的审批不应因为表单能力而改变。 + +对应代码边界: + +- Codex thread/turn 参数使用 granular approval policy,只打开 `mcp_elicitations`。 +- `features.tool_call_mcp_elicitation=false` 防止普通 MCP tool approval 被包装成业务表单。 +- request/bot MCP config 默认补 `default_tools_approval_mode = "approve"`,显式 `prompt` 优先。 +- `mcp_servers.wegent_apps` 是 Wework Connector Apps 的内置持久 server,由 Wework 写入并在 configure/app sync 时刷新,同样默认 `approve`。 + +因此,排查问题时可以按错误位置判断: + +| 现象 | 含义 | 处理 | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `The MCP client does not support openai/form requests.` | 插件连接到的 Codex MCP client 不支持 `openai/form`,请求没有进入 Wework | 改用 `mode: "form"`,或走普通对话/tool 参数降级 | +| 插件收到 `action: "decline"`,但运行日志没有 `mcpServer/elicitation/request` | Codex MCP runtime 在 Wework UI 之前提前拒绝 | 检查 `elicitations_auto_deny`、`approvalPolicy`、granular `mcp_elicitations`、是否 active turn | +| 表单没有出现,但运行日志里有 `mcpServer/elicitation/request` | 请求到达运行时,可能 schema 不受支持 | 检查 `mode` 和 `requestedSchema.properties` | +| 每次 MCP tool call 都弹出“Allow this MCP tool call”表单 | 该 MCP server/tool 的 tool approval mode 仍然要求审批 | 对不需要授权的插件 server 设置 `default_tools_approval_mode="approve"`;确实需要授权的 server/tool 显式保留 `prompt` | +| 表单出现但提交后插件没有继续 | 响应路由或插件侧结果处理有问题 | 检查插件是否处理 `accept`、`cancel`、`decline` | + +## Schema 到界面的映射 + +Wework 会读取 `requestedSchema.properties`,每个 property 转成一个问题。 + +| Schema 字段 | Wework 表现 | +| --------------- | -------------------------------------------- | +| property key | 问题 id,提交答案时使用同一个 key | +| `title` | 问题标题 | +| `description` | 问题说明;缺失时使用 `title` 或 property key | +| `oneOf[].title` | 选项展示文案 | +| `oneOf[].const` | 插件收到的真实值 | +| `enumNames[]` | 选项展示文案 | +| `enum[]` | 插件收到的真实值 | +| `type: boolean` | 自动显示 `true` / `false` 两个选项 | +| 无选项字段 | 显示短文本输入框 | + +当前界面的默认行为: + +- 每个有选项的问题默认选中第一个选项。 +- 如果表单只有一个问题,用户点击选项会立即提交。 +- 如果表单有多个问题,用户可以修改选项后点击“提交”。 +- 文本输入允许为空提交;插件需要自行校验必填参数。 + +## 返回结果 + +用户提交后,executor 会把 Wework 的内部答案转换回 MCP elicitation result。插件收到的结果类似: + +```json +{ + "action": "accept", + "content": { + "strategy": "fast" + }, + "_meta": null +} +``` + +如果用户取消、任务停止,或没有可用答案,结果通常是: + +```json +{ + "action": "cancel", + "content": null, + "_meta": null +} +``` + +如果请求模式或 schema 不受支持,executor 会返回: + +```json +{ + "action": "decline", + "content": null, + "_meta": null +} +``` + +插件必须显式处理 `accept`、`cancel` 和 `decline`。不要假设用户一定提交,也不要在取消后继续执行高影响操作。 + +## 示例 + +### 单选 + +```json +{ + "mode": "form", + "message": "请选择发布目标", + "requestedSchema": { + "type": "object", + "properties": { + "target": { + "type": "string", + "title": "发布目标", + "description": "选择本次发布的目标环境。", + "oneOf": [ + { "const": "staging", "title": "Staging" }, + { "const": "production", "title": "Production" } + ] + } + }, + "required": ["target"] + } +} +``` + +用户选择 `Production` 后,插件收到: + +```json +{ + "action": "accept", + "content": { + "target": "production" + }, + "_meta": null +} +``` + +### 布尔确认 + +```json +{ + "mode": "form", + "message": "是否继续删除缓存?", + "requestedSchema": { + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "title": "确认删除", + "description": "此操作会清空当前工作区缓存。" + } + }, + "required": ["confirm"] + } +} +``` + +用户选择 `true` 后,插件收到: + +```json +{ + "action": "accept", + "content": { + "confirm": true + }, + "_meta": null +} +``` + +### 文本输入 + +```json +{ + "mode": "form", + "message": "请输入发布说明", + "requestedSchema": { + "type": "object", + "properties": { + "releaseNote": { + "type": "string", + "title": "发布说明", + "description": "这段内容会写入发布记录。" + } + }, + "required": ["releaseNote"] + } +} +``` + +用户提交后,插件收到: + +```json +{ + "action": "accept", + "content": { + "releaseNote": "修复登录失败并优化启动速度" + }, + "_meta": null +} +``` + +### 自定义选项 + +当前不支持“某个选项是自定义,选择后再展开输入框”的条件式表单。推荐把它建模为两个字段:一个单选字段选择内置选项或 `custom`,另一个文本字段填写自定义内容。 + +```json +{ + "mode": "form", + "message": "请选择处理方式", + "requestedSchema": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "title": "处理方式", + "description": "选择一个处理方式。", + "oneOf": [ + { "const": "fast", "title": "快速处理" }, + { "const": "safe", "title": "稳妥处理" }, + { "const": "custom", "title": "自定义" } + ] + }, + "customStrategy": { + "type": "string", + "title": "自定义处理方式", + "description": "如果上面选择了自定义,请填写具体要求。" + } + }, + "required": ["strategy"] + } +} +``` + +用户选择自定义并填写内容后,插件收到: + +```json +{ + "action": "accept", + "content": { + "strategy": "custom", + "customStrategy": "先检查 diff,再只改测试" + }, + "_meta": null +} +``` + +插件需要自行校验:当 `strategy` 是 `custom` 时要求 `customStrategy` 非空;当 `strategy` 不是 `custom` 时忽略 `customStrategy`。 + +## 约束和注意事项 + +- 面向 Wework 的插件使用 `mode: "form"`。`openai/form` 只作为 executor 收到事件后的兼容模式,不应作为 Wework 插件默认模式。 +- `requestedSchema.properties` 必须存在;缺失或不支持会被拒绝。 +- 推荐使用 `oneOf` 表达单选,因为它能同时提供展示文案和稳定机器值。 +- `enum` 目前只支持字符串值;需要展示名时同时提供 `enumNames`。 +- `oneOf[].const` 当前按字符串值处理;不要依赖对象、数字或布尔 const。 +- `array` 会被转换成数组结果,但当前 UI 不是多选控件;不要用它表达真正的多选。 +- 数字和整数会在提交后尝试解析;解析失败时字段会被省略,插件需要校验。 +- `required` 主要用于插件侧语义,Wework 当前不会阻止空文本提交。 +- 需要稳定默认值时,把默认选项放在选项列表第一位。 +- 单问题选项卡片点击后会立即提交;需要“先选择再确认”的交互时,把请求拆成多问题表单或等待 Wework 增加显式开关。 +- 当前没有条件显示控件;“自定义选项 + 输入框”请用单选字段加文本字段建模,并在插件侧校验。 + +## 开发检查清单 + +- 表单请求使用 MCP elicitation,而不是直接输出普通 Markdown 或内部 `request_user_input` JSON。 +- 每个字段都有稳定的 property key,插件逻辑只依赖返回的 `content` 值。 +- 每个选项都有面向用户的 `title` 和面向程序的 `const`。 +- 插件处理 `cancel` 和 `decline`,并停止或回滚待执行动作。 +- 插件对必填、数字范围、环境名称等业务规则做二次校验。 +- 对高影响操作,表单文案清楚说明影响范围和后果。 diff --git a/executor/src/agents/codex.rs b/executor/src/agents/codex.rs index 0a3627624d..cf62e36664 100644 --- a/executor/src/agents/codex.rs +++ b/executor/src/agents/codex.rs @@ -63,6 +63,8 @@ const CODEX_APPLY_PATCH_STREAMING_EVENTS_OVERRIDE: &str = const CODEX_APPLY_PATCH_FREEFORM_OVERRIDE: &str = "features.apply_patch_freeform=true"; const CODEX_SUPPRESS_UNSTABLE_FEATURES_WARNING_OVERRIDE: &str = "suppress_unstable_features_warning=true"; +const CODEX_DISABLE_TOOL_CALL_MCP_ELICITATION_OVERRIDE: &str = + "features.tool_call_mcp_elicitation=false"; const DEFAULT_EXECUTOR_SERVER_PORT: u16 = 10001; const SIDE_BOUNDARY_PROMPT: &str = r#"Side conversation boundary. @@ -712,7 +714,7 @@ fn persistent_codex_app_server_launch_config( ]); launch_config .config_overrides - .extend(codex_streaming_patch_config_overrides()); + .extend(codex_runtime_default_config_overrides()); launch_config } @@ -1952,7 +1954,7 @@ fn build_codex_launch_config(request: &ExecutionRequest) -> CodexLaunchConfig { .push(shell_path_config_override()); launch_config .config_overrides - .extend(codex_streaming_patch_config_overrides()); + .extend(codex_runtime_default_config_overrides()); launch_config .config_overrides .extend(codex_model_config_overrides(&request.model_config)); @@ -2081,6 +2083,12 @@ fn codex_streaming_patch_config_overrides() -> Vec { ] } +fn codex_runtime_default_config_overrides() -> Vec { + let mut overrides = codex_streaming_patch_config_overrides(); + overrides.push(CODEX_DISABLE_TOOL_CALL_MCP_ELICITATION_OVERRIDE.to_owned()); + overrides +} + fn codex_model_config_overrides(model_config: &Value) -> Vec { const DEFAULT_CODEX_MODEL_CONTEXT_WINDOW: i64 = 262_144; @@ -3315,6 +3323,18 @@ fn resolve_codex_binary(value: &str) -> String { const CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE: &str = ":danger-full-access"; +pub(crate) fn codex_runtime_approval_policy() -> Value { + json!({ + "granular": { + "sandbox_approval": false, + "rules": false, + "skill_approval": false, + "request_permissions": false, + "mcp_elicitations": true, + } + }) +} + fn insert_codex_runtime_permissions(params: &mut serde_json::Map) { params.insert( "permissions".to_owned(), @@ -3406,10 +3426,7 @@ fn thread_start_params(request: &ExecutionRequest, launch_config: &CodexLaunchCo params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } insert_runtime_workspace_roots(&mut params, request); - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); + params.insert("approvalPolicy".to_owned(), codex_runtime_approval_policy()); insert_codex_runtime_permissions(&mut params); if request.ephemeral { params.insert("ephemeral".to_owned(), Value::Bool(true)); @@ -3437,10 +3454,7 @@ fn thread_fork_params( params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } insert_runtime_workspace_roots(&mut params, request); - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); + params.insert("approvalPolicy".to_owned(), codex_runtime_approval_policy()); insert_codex_runtime_permissions(&mut params); if request.ephemeral { params.insert("ephemeral".to_owned(), Value::Bool(true)); @@ -3501,10 +3515,7 @@ fn thread_resume_params( params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); } insert_runtime_workspace_roots(&mut params, request); - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); + params.insert("approvalPolicy".to_owned(), codex_runtime_approval_policy()); insert_codex_runtime_permissions(&mut params); Value::Object(params) } @@ -3552,10 +3563,7 @@ fn turn_start_params( Value::String(client_user_message_id.to_owned()), ); } - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); + params.insert("approvalPolicy".to_owned(), codex_runtime_approval_policy()); insert_codex_runtime_permissions(&mut params); if let Some(cwd) = request.cwd() { params.insert("cwd".to_owned(), Value::String(cwd.to_owned())); diff --git a/executor/src/agents/codex/tests.rs b/executor/src/agents/codex/tests.rs index 1a455926f8..5226d95be2 100644 --- a/executor/src/agents/codex/tests.rs +++ b/executor/src/agents/codex/tests.rs @@ -117,6 +117,56 @@ fn persistent_app_server_enables_deferred_mcp_tool_search() { assert!(!config .config_overrides .contains(&"features.tool_search_always_defer_mcp_tools=false".to_owned())); + assert!(config + .config_overrides + .contains(&CODEX_DISABLE_TOOL_CALL_MCP_ELICITATION_OVERRIDE.to_owned())); +} + +#[test] +fn initialize_params_does_not_advertise_openai_form_elicitation_extension() { + let params = initialize_params(); + + assert_eq!(params["capabilities"]["experimentalApi"], true); + assert!(params["capabilities"] + .get("mcpServerOpenaiFormElicitation") + .is_none()); +} + +#[test] +fn mcp_form_elicitation_maps_enum_names_to_request_user_input_options() { + let params = json!({ + "serverName": "wegent-sites", + "mode": "form", + "message": "请选择内网访问范围。", + "requestedSchema": { + "type": "object", + "properties": { + "audience": { + "type": "string", + "title": "访问范围", + "description": "请选择站点发布到内网后的访问范围。", + "enum": ["all", "owner", "custom"], + "enumNames": ["所有人", "仅自己", "指定人"] + } + }, + "required": ["audience"] + } + }); + + let payload = mcp_server_elicitation_request_user_input_params(¶ms) + .expect("enum + enumNames form should map to request_user_input payload"); + + assert_eq!(payload["itemId"], "mcp_server_elicitation"); + assert_eq!(payload["questions"][0]["id"], "audience"); + assert_eq!(payload["questions"][0]["header"], "访问范围"); + assert_eq!( + payload["questions"][0]["options"], + json!([ + {"label": "所有人", "description": "all"}, + {"label": "仅自己", "description": "owner"}, + {"label": "指定人", "description": "custom"} + ]) + ); } #[test] @@ -274,6 +324,9 @@ fn codex_launch_config_enables_streaming_patch_updates() { assert!(launch_config .config_overrides .contains(&CODEX_SUPPRESS_UNSTABLE_FEATURES_WARNING_OVERRIDE.to_owned())); + assert!(launch_config + .config_overrides + .contains(&CODEX_DISABLE_TOOL_CALL_MCP_ELICITATION_OVERRIDE.to_owned())); } #[test] @@ -1254,11 +1307,36 @@ fn codex_permission_profile_is_applied_to_thread_and_turn_requests() { params["permissions"], CODEX_DANGER_FULL_ACCESS_PERMISSION_PROFILE ); + assert_eq!(params["approvalPolicy"], codex_runtime_approval_policy()); assert!(params.get("sandboxPolicy").is_none()); assert!(params.get("sandbox").is_none()); } } +#[test] +fn codex_thread_launch_disables_tool_call_mcp_elicitation() { + let request = ExecutionRequest::default(); + let mut launch_config = CodexLaunchConfig::default(); + launch_config + .config_overrides + .push(CODEX_DISABLE_TOOL_CALL_MCP_ELICITATION_OVERRIDE.to_owned()); + + let thread_start = thread_start_params(&request, &launch_config); + let thread_resume = thread_resume_params("thread-1", &request, &launch_config); + let thread_fork = thread_fork_params("thread-1", None, &request, &launch_config); + + for params in [thread_start, thread_resume, thread_fork] { + assert_eq!( + params["config"]["features.tool_call_mcp_elicitation"], + false + ); + assert_eq!( + params["approvalPolicy"]["granular"]["mcp_elicitations"], + true + ); + } +} + #[test] fn codex_runtime_workspace_roots_are_applied_to_thread_and_turn_requests() { let request = ExecutionRequest { diff --git a/executor/src/agents/mod.rs b/executor/src/agents/mod.rs index 306dfa38de..6e9fc5d680 100644 --- a/executor/src/agents/mod.rs +++ b/executor/src/agents/mod.rs @@ -36,8 +36,9 @@ use claude_code::{ }; pub use claude_options::{extract_claude_options, ClaudeOptions}; pub(crate) use codex::{ - combined_codex_developer_instructions, configured_inference_model_provider, - mcp_server_elicitation_request_user_input_params, strip_wework_browser_instructions, + codex_runtime_approval_policy, combined_codex_developer_instructions, + configured_inference_model_provider, mcp_server_elicitation_request_user_input_params, + strip_wework_browser_instructions, }; pub use codex::{ run_codex_app_server_turn, run_codex_app_server_turn_with_cancel, CodexActiveTurnCallback, diff --git a/executor/src/agents/runtime_capabilities.rs b/executor/src/agents/runtime_capabilities.rs index fae471233e..8ffb2355f8 100644 --- a/executor/src/agents/runtime_capabilities.rs +++ b/executor/src/agents/runtime_capabilities.rs @@ -1511,7 +1511,75 @@ fn ensure_object<'a>(object: &'a mut Map, key: &str) -> &'a mut M } fn collect_request_mcp_servers(request: &ExecutionRequest) -> BTreeMap { - extract_claude_options(request, &BTreeMap::new()).mcp_servers + let mut servers = extract_claude_options(request, &BTreeMap::new()).mcp_servers; + preserve_explicit_mcp_approval_modes(request, &mut servers); + servers +} + +fn preserve_explicit_mcp_approval_modes( + request: &ExecutionRequest, + servers: &mut BTreeMap, +) { + if request_mode(request).as_deref() == Some("coordinate") { + if let Some(bots) = request.bot.as_array() { + for bot in bots { + preserve_explicit_mcp_approval_modes_from_value( + bot_mcp_servers_value(bot), + servers, + ); + } + } + } else if let Some(bot) = primary_bot(request) { + preserve_explicit_mcp_approval_modes_from_value(bot_mcp_servers_value(bot), servers); + } + + preserve_explicit_mcp_approval_modes_from_value( + Some(&Value::Array(request.mcp_servers.clone())), + servers, + ); +} + +fn bot_mcp_servers_value(bot: &Value) -> Option<&Value> { + bot.get("mcp_servers").or_else(|| bot.get("mcpServers")) +} + +fn preserve_explicit_mcp_approval_modes_from_value( + value: Option<&Value>, + servers: &mut BTreeMap, +) { + match value { + Some(Value::Object(object)) => { + for (name, server) in object { + preserve_explicit_mcp_approval_mode(name, server, servers); + } + } + Some(Value::Array(values)) => { + for server in values { + if let Some(name) = server.get("name").and_then(Value::as_str) { + preserve_explicit_mcp_approval_mode(name, server, servers); + } + } + } + _ => {} + } +} + +fn preserve_explicit_mcp_approval_mode( + name: &str, + source: &Value, + servers: &mut BTreeMap, +) { + let Some(mode) = source + .get("default_tools_approval_mode") + .or_else(|| source.get("defaultToolsApprovalMode")) + .cloned() + else { + return; + }; + let Some(server) = servers.get_mut(name).and_then(Value::as_object_mut) else { + return; + }; + server.insert("default_tools_approval_mode".to_owned(), mode); } fn mcp_server_headers_summary(servers: &BTreeMap) -> String { @@ -1652,6 +1720,7 @@ fn codex_mcp_server_overrides(name: &str, server: &Value) -> Vec { } } } + append_codex_mcp_server_approval_override(&key, object, &mut overrides); return overrides; } let Some(url) = object @@ -1687,9 +1756,28 @@ fn codex_mcp_server_overrides(name: &str, server: &Value) -> Vec { overrides.push(format!("{key}.{target_key}={}", toml_value(value))); } } + append_codex_mcp_server_approval_override(&key, object, &mut overrides); overrides } +fn append_codex_mcp_server_approval_override( + key: &str, + object: &Map, + overrides: &mut Vec, +) { + let approval_mode = object + .get("default_tools_approval_mode") + .or_else(|| object.get("defaultToolsApprovalMode")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("approve"); + overrides.push(format!( + "{key}.default_tools_approval_mode={}", + toml_value(approval_mode) + )); +} + fn load_global_mcp_records() -> BTreeMap { let path = executor_home().join("capabilities/manifest.json"); let Ok(content) = fs::read_to_string(path) else { diff --git a/executor/src/runtime_work/connectors.rs b/executor/src/runtime_work/connectors.rs index b5b19c3f98..51080d2f11 100644 --- a/executor/src/runtime_work/connectors.rs +++ b/executor/src/runtime_work/connectors.rs @@ -25,6 +25,8 @@ use crate::{ use super::util::{now_ms, string_field}; +const WEGENT_CONNECTOR_MCP_TOOL_APPROVAL_MODE: &str = "approve"; + #[derive(Clone)] pub(super) struct ConnectorRuntime { codex_app_server: CodexAppServerClient, @@ -88,12 +90,26 @@ impl ConnectorRuntime { persist_connector_gateway_config(&next_config) .map_err(|error| AppIpcError::new("connector_authorization_write_failed", error))?; *self.cloud.write().await = Some(next_config); - if previous.is_none() { - if let Err(error) = self.write_mcp_config(true).await { - *self.cloud.write().await = previous; - let _ = clear_connector_gateway_config(); - return Err(error); + if let Err(error) = self.write_mcp_config(true).await { + let rollback_result = match &previous { + Some(previous_config) => persist_connector_gateway_config(previous_config), + None => clear_connector_gateway_config(), + }; + match rollback_result { + Ok(()) => { + *self.cloud.write().await = previous; + } + Err(rollback_error) => { + return Err(AppIpcError::new( + "connector_authorization_rollback_failed", + format!( + "Failed to write MCP config after updating connector authorization: {}; failed to roll back connector authorization on disk: {rollback_error}", + error.message + ), + )); + } } + return Err(error); } Ok(json!({ "configured": true, "expiresAtMs": expires_at_ms })) } @@ -162,6 +178,7 @@ impl ConnectorRuntime { "Connect Wework to Wegent cloud before synchronizing apps", )); } + self.write_mcp_config(true).await?; let result = materialize_skills(&skills_root(), apps)?; *self.synced_apps.write().await = result .get("apps") @@ -227,6 +244,7 @@ fn connector_mcp_server_config(command: &Path, executor_home: Option>()) + .unwrap_or_default(); + log_executor_event( + "runtime task fork rejected", + &[ + ("reason", "turn_not_found".to_owned()), + ("local_task_id", source.local_task_id.clone()), + ("requested_turn_id", requested_turn_id.clone()), + ("mapping_keys", mapping_keys.join(",")), + ], + ); return Ok(json!({ "success": false, "accepted": false, @@ -515,10 +530,7 @@ impl RuntimeWorkRpcHandler { ) -> Result { let mut params = Map::new(); params.insert("threadId".to_owned(), Value::String(thread_id.to_owned())); - params.insert( - "approvalPolicy".to_owned(), - Value::String("never".to_owned()), - ); + params.insert("approvalPolicy".to_owned(), codex_runtime_approval_policy()); params.insert("excludeTurns".to_owned(), Value::Bool(true)); if !link.workspace_path.trim().is_empty() { params.insert("cwd".to_owned(), Value::String(link.workspace_path.clone())); @@ -721,5 +733,23 @@ pub(super) fn resolve_codex_turn_id( { return Some(requested_turn_id.to_owned()); } + if let Some(turn_id) = synthetic_transcript_turn_id_base(requested_turn_id) { + if mappings.is_some_and(|mappings| { + mappings + .values() + .any(|mapped_turn_id| mapped_turn_id.as_str() == Some(turn_id)) + }) || is_codex_thread_id(turn_id) + { + return Some(turn_id.to_owned()); + } + } None } + +fn synthetic_transcript_turn_id_base(value: &str) -> Option<&str> { + let (turn_id, segment) = value.rsplit_once('-')?; + if segment.parse::().is_err() || !is_codex_thread_id(turn_id) { + return None; + } + Some(turn_id) +} diff --git a/executor/src/runtime_work/handler/tests.rs b/executor/src/runtime_work/handler/tests.rs index 504db2d54c..5ee28b9253 100644 --- a/executor/src/runtime_work/handler/tests.rs +++ b/executor/src/runtime_work/handler/tests.rs @@ -252,27 +252,29 @@ fn runtime_turn_ids_are_persisted_by_subtask() { "Task".to_owned(), )); - handler.record_runtime_turn_id("task-1", "subtask-1", "turn-1"); + let codex_turn_id = "019f933f-bf0d-72e3-b366-a6539ab00bcf"; + handler.record_runtime_turn_id("task-1", "subtask-1", codex_turn_id); let link = handler .local_task_link("task-1") .expect("task should exist"); assert_eq!( tasks::runtime_turn_id_from_link(&link, "subtask-1").as_deref(), - Some("turn-1") + Some(codex_turn_id) ); assert_eq!( tasks::resolve_codex_turn_id(&link, "subtask-1").as_deref(), - Some("turn-1") + Some(codex_turn_id) ); assert_eq!( - tasks::resolve_codex_turn_id(&link, "turn-1").as_deref(), - Some("turn-1") + tasks::resolve_codex_turn_id(&link, codex_turn_id).as_deref(), + Some(codex_turn_id) ); assert_eq!( - tasks::resolve_codex_turn_id(&link, "019f933f-bf0d-72e3-b366-a6539ab00bcf").as_deref(), - Some("019f933f-bf0d-72e3-b366-a6539ab00bcf") + tasks::resolve_codex_turn_id(&link, &format!("{codex_turn_id}-1")).as_deref(), + Some(codex_turn_id) ); + assert_eq!(tasks::resolve_codex_turn_id(&link, "turn-1-1"), None); assert_eq!(tasks::resolve_codex_turn_id(&link, "missing-turn"), None); let _ = fs::remove_file(index_path); } diff --git a/executor/tests/codex_app_server_contract.rs b/executor/tests/codex_app_server_contract.rs index 2d9257f2b8..f520f5d564 100644 --- a/executor/tests/codex_app_server_contract.rs +++ b/executor/tests/codex_app_server_contract.rs @@ -486,16 +486,30 @@ async fn codex_app_server_engine_injects_request_mcp_config_overrides() { "command": "uvx", "args": ["bot-tool"], "env": {"BOT_ENV": "1"} + }, + "sensitive-shell": { + "type": "stdio", + "command": "node", + "args": ["sensitive-tool"], + "default_tools_approval_mode": "prompt" } } }]), - mcp_servers: vec![json!({ - "name": "request-docs", - "type": "streamable-http", - "url": "https://mcp.example.com/request-docs", - "bearer_token_env_var": "REQUEST_DOCS_TOKEN", - "headers": {"Authorization": "Bearer task-token"} - })], + mcp_servers: vec![ + json!({ + "name": "request-docs", + "type": "streamable-http", + "url": "https://mcp.example.com/request-docs", + "bearer_token_env_var": "REQUEST_DOCS_TOKEN", + "headers": {"Authorization": "Bearer task-token"} + }), + json!({ + "name": "request-sensitive", + "type": "streamable-http", + "url": "https://mcp.example.com/request-sensitive", + "defaultToolsApprovalMode": "prompt" + }), + ], model_config: json!({ "model": "openai", "model_id": "gpt-5.5", @@ -532,6 +546,23 @@ async fn codex_app_server_engine_injects_request_mcp_config_overrides() { assert_config_arg(args, "mcp_servers.bot-shell.command=\"uvx\""); assert_config_arg(args, "mcp_servers.bot-shell.args=[\"bot-tool\"]"); assert_config_arg(args, "mcp_servers.bot-shell.env.BOT_ENV=\"1\""); + assert_config_arg( + args, + "mcp_servers.bot-shell.default_tools_approval_mode=\"approve\"", + ); + assert_config_arg(args, "mcp_servers.sensitive-shell.command=\"node\""); + assert_config_arg( + args, + "mcp_servers.sensitive-shell.default_tools_approval_mode=\"prompt\"", + ); + assert_config_arg( + args, + "mcp_servers.request-docs.default_tools_approval_mode=\"approve\"", + ); + assert_config_arg( + args, + "mcp_servers.request-sensitive.default_tools_approval_mode=\"prompt\"", + ); } #[tokio::test] diff --git a/wework/e2e/desktop/scenarios/streaming-text.scenario.mjs b/wework/e2e/desktop/scenarios/streaming-text.scenario.mjs index 417b6c97a5..0df8e31a95 100644 --- a/wework/e2e/desktop/scenarios/streaming-text.scenario.mjs +++ b/wework/e2e/desktop/scenarios/streaming-text.scenario.mjs @@ -12,17 +12,19 @@ const INITIAL_COMPLETION = 'WEWORK_DESKTOP_E2E_STREAMING_TEXT_INITIAL_COMPLETE' const PROMPT = 'WEWORK_DESKTOP_E2E_STREAMING_TEXT: keep the partial response active until released.' const MARKER = 'WEWORK_DESKTOP_E2E_STREAMING_TEXT_PARTIAL' const VIEWPORT_MARKER = 'WEWORK_DESKTOP_E2E_STREAMING_TEXT_VIEWPORT_ANCHOR' -const VIEWPORT_MARKER_URL = 'https://wework-e2e.invalid/streaming-viewport-anchor' const APPEND_MARKER = 'WEWORK_DESKTOP_E2E_STREAMING_TEXT_APPENDED' const ATTACHMENT_FILENAME = 'streaming-turn-navigation.png' const ATTACHMENT_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAAEklEQVR4nGP4z8CAB+GTG8HSALfKY52fTcuYAAAAAElFTkSuQmCC' const TURN_NAVIGATION_MARKER_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-turn-navigation-marker"]` const SCROLLER_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="desktop-workbench-content"]` -const VIEWPORT_ANCHOR_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="process-text-block"] p[data-scroll-anchor]:has(a[href="${VIEWPORT_MARKER_URL}"])` +const VIEWPORT_ANCHOR_TEXT = `${VIEWPORT_MARKER}: this paragraph must remain fixed after the user scrolls upward.` +const VIEWPORT_ANCHOR_E2E_ID = 'streaming-text-viewport-anchor' +const VIEWPORT_ANCHOR_SCOPE_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="process-text-block"] [data-scroll-anchor]` +const VIEWPORT_ANCHOR_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-e2e-anchor-id="${VIEWPORT_ANCHOR_E2E_ID}"]` const INITIAL_PARAGRAPHS = Array.from({ length: 28 }, (_, index) => { if (index === 11) { - return `[${VIEWPORT_MARKER}](${VIEWPORT_MARKER_URL}): this paragraph must remain fixed after the user scrolls upward.` + return VIEWPORT_ANCHOR_TEXT } return `Initial streaming paragraph ${index + 1}: enough text keeps the response taller than the desktop chat viewport.` }) @@ -616,13 +618,19 @@ export function createDesktopScenario({ ) await capture(control, 'streaming-text-02-thinking-below-partial-response.png') - await control.command('waitFor', VIEWPORT_ANCHOR_SELECTOR, { - text: VIEWPORT_MARKER, + await control.command('waitFor', VIEWPORT_ANCHOR_SCOPE_SELECTOR, { + text: VIEWPORT_ANCHOR_TEXT, + stableMs: 750, + timeoutMs: uiTimeoutMs, + }) + await control.command('markElementWithText', VIEWPORT_ANCHOR_SCOPE_SELECTOR, { + text: VIEWPORT_ANCHOR_TEXT, + value: VIEWPORT_ANCHOR_E2E_ID, timeoutMs: uiTimeoutMs, }) assert.equal( await control.command('getText', VIEWPORT_ANCHOR_SELECTOR), - `${VIEWPORT_MARKER}: this paragraph must remain fixed after the user scrolls upward.`, + VIEWPORT_ANCHOR_TEXT, 'The viewport anchor paragraph was not rendered at the expected position' ) await control.command('scrollToRatioAsUser', SCROLLER_SELECTOR, { value: '0.35' }) diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index 279341dcd9..f89a45b838 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -121,6 +121,8 @@ const MEMORY_SAMPLE_WINDOW_SIZE = 3 const ARTIFACT_NAME = 'wework-e2e-result.txt' const ARTIFACT_CONTENT = 'CODEX_EXECUTED_REAL_TOOL' const IMAGE_ARTIFACT_NAME = 'wework-e2e-image.png' +const VIEW_IMAGE_PROMPT = 'WEWORK_DESKTOP_E2E_VIEW_IMAGE: inspect the verification image.' +const VIEW_IMAGE_COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_VIEW_IMAGE_COMPLETE' const IMAGE_ARTIFACT_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAAEklEQVR4nGP4z8CAB+GTG8HSALfKY52fTcuYAAAAAElFTkSuQmCC' const GIT_SEED_NAME = 'README.md' @@ -663,6 +665,34 @@ async function verifyBackgroundGuidanceNavigation({ await captureVerificationScreenshot(control, 'guidance-background-03-applied.png') } +async function verifyStandaloneViewImageTask({ composerSelector, control, projectRowSelector }) { + control.setScenario('view_image') + await control.command( + 'clickWhenEnabled', + `${projectRowSelector} [data-testid="project-new-conversation-button"]`, + { timeoutMs: DEFAULT_STEP_TIMEOUT_MS } + ) + await control.command('waitFor', composerSelector, { + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, + }) + await selectE2EModel(control) + await sendPrompt(control, composerSelector, VIEW_IMAGE_PROMPT) + await withTimeout( + control.awaitScenarioRequest('view_image'), + DEFAULT_STEP_TIMEOUT_MS, + 'The model service did not receive the standalone view_image request' + ) + await control.command( + 'waitFor', + `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-assistant"]`, + { + text: VIEW_IMAGE_COMPLETION_TEXT, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + } + ) + await verifyViewImageProcessingBlock(control) +} + async function startPausedQueueCase({ composerSelector, control, initialPrompt, queuedPrompts }) { await control.command('click', '[data-testid="new-chat-button"]') await control.command('waitFor', composerSelector, { @@ -1084,6 +1114,108 @@ async function waitForTopMetrics(control, selector, description, timeoutMs = 3_0 ) } +async function waitForProcessingBlock( + control, + selector, + description, + timeoutMs = DEFAULT_STEP_TIMEOUT_MS +) { + const startedAt = Date.now() + let diagnostics = null + + while (Date.now() - startedAt < timeoutMs) { + await control.command('expandProcessingSummaries', 'body') + const targetCount = Number(await control.command('getElementCount', selector)) + diagnostics = { + targetCount, + finalProcessingExpandedCount: Number( + await control.command( + 'getElementCount', + '[data-testid="final-processing-toggle"][aria-expanded="true"]' + ) + ), + finalProcessingCollapsedCount: Number( + await control.command( + 'getElementCount', + '[data-testid="final-processing-toggle"][aria-expanded="false"]' + ) + ), + processingSummaryExpandedCount: Number( + await control.command( + 'getElementCount', + '[data-testid="processing-summary-toggle"][aria-expanded="true"]' + ) + ), + processingSummaryCollapsedCount: Number( + await control.command( + 'getElementCount', + '[data-testid="processing-summary-toggle"][aria-expanded="false"]' + ) + ), + processingBlockCount: Number( + await control.command('getElementCount', '[data-processing-block-id]') + ), + processingLivePreviewCount: Number( + await control.command('getElementCount', '[data-testid="processing-live-preview"]') + ), + } + if (targetCount > 0) return diagnostics + await new Promise(resolvePromise => setTimeout(resolvePromise, 250)) + } + + const snapshot = await control.command('snapshot', 'body') + await writeFile( + join(resultDir, 'processing-block-timeout-diagnostics.json'), + `${JSON.stringify({ description, selector, diagnostics, snapshot: JSON.parse(snapshot) }, null, 2)}\n`, + 'utf8' + ) + throw new Error( + `${description} did not render ${selector}; diagnostics: ${JSON.stringify(diagnostics)}` + ) +} + +async function verifyViewImageProcessingBlock(control) { + const viewImageBlockSelector = '[data-processing-block-id="wework-e2e-view-image"]' + await waitForProcessingBlock(control, viewImageBlockSelector, 'The view_image processing block') + await control.command('scrollIntoView', '[data-testid="processing-live-preview"]') + await control.command( + 'waitFor', + '[data-processing-block-id="wework-e2e-view-image"] [data-tool-detail-toggle][aria-expanded="false"]', + { visible: true, stableMs: 300, timeoutMs: DEFAULT_STEP_TIMEOUT_MS } + ) + await new Promise(resolvePromise => setTimeout(resolvePromise, 500)) + await captureVerificationScreenshot( + control, + '03-view-image-collapsed.png', + '[data-testid="processing-live-preview"]' + ) + await control.command( + 'click', + '[data-processing-block-id="wework-e2e-view-image"] [data-tool-detail-toggle]' + ) + await control.command('waitFor', '[data-testid="image-view-preview"]', { + stableMs: 500, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command( + 'waitFor', + '[data-processing-block-id="wework-e2e-view-image"] [data-tool-detail-toggle][aria-expanded="true"]', + { stableMs: 500, timeoutMs: DEFAULT_STEP_TIMEOUT_MS } + ) + await control.command('scrollIntoView', '[data-testid="processing-live-preview"]') + await control.command('waitFor', '[data-testid="image-view-preview"]', { + visible: true, + stableMs: 500, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await new Promise(resolvePromise => setTimeout(resolvePromise, 500)) + await captureVerificationScreenshot( + control, + '04-view-image-expanded.png', + '[data-testid="processing-live-preview"]' + ) +} + function distanceFromBottom(metrics) { return Math.max(0, metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop) } @@ -1232,11 +1364,20 @@ async function verifyRunningFollowUpFork({ await control.command('waitFor', '[data-testid="pause-response-button"]', { timeoutMs: DEFAULT_STEP_TIMEOUT_MS, }) - await control.command('scrollIntoView', '[data-testid="fork-message-button"]') + const firstTurnForkButtonSelector = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-assistant"] [data-testid="fork-message-button"]` + await control.command('scrollIntoView', firstTurnForkButtonSelector) await captureVerificationScreenshot(control, 'running-follow-up-fork-01-streaming.png') try { - await control.command('clickWhenEnabled', '[data-testid="fork-message-button"]') + await control.command( + 'clickDescendantInElementWithText', + `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-assistant"]`, + { + target: '[data-testid="fork-message-button"]', + text: COMPLETION_TEXT, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + } + ) const forkTaskRowTestId = await waitForNewTaskRow(control, taskRowsBeforeFork, '', 15_000) assert.notEqual( forkTaskRowTestId, @@ -1307,13 +1448,22 @@ async function verifyCompletedTurnFork({ testId.startsWith('runtime-local-task-row-') ) ) - await control.command('scrollIntoView', '[data-testid="fork-message-button"]') - await control.command('waitFor', '[data-testid="fork-message-button"]', { + const firstTurnForkButtonSelector = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-assistant"] [data-testid="fork-message-button"]` + await control.command('scrollIntoView', firstTurnForkButtonSelector) + await control.command('waitFor', firstTurnForkButtonSelector, { visible: true, timeoutMs: DEFAULT_STEP_TIMEOUT_MS, }) await captureVerificationScreenshot(control, 'completed-turn-fork-01-source-ready.png') - await control.command('clickWhenEnabled', '[data-testid="fork-message-button"]') + await control.command( + 'clickDescendantInElementWithText', + `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="message-assistant"]`, + { + target: '[data-testid="fork-message-button"]', + text: COMPLETION_TEXT, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + } + ) const forkTaskRowTestId = await waitForNewTaskRow(control, taskRowsBeforeFork, '') assert.notEqual( forkTaskRowTestId, @@ -2223,19 +2373,30 @@ async function verifyProviderBoundaryRestriction(control, composerSelector) { await control.command('waitFor', '[data-testid="model-selector-menu"]', { timeoutMs: DEFAULT_STEP_TIMEOUT_MS, }) + await ensureModelOptionVisible(control, `model-option-${PROVIDER_SWITCH_SOL_OPTION_ID}`) + const targetModelSelector = `[data-testid="model-option-${PROVIDER_SWITCH_SOL_OPTION_ID}"]` + await control.command('waitFor', targetModelSelector, { + text: PROVIDER_SWITCH_SOL_LABEL, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + const targetModelText = await control.command('getText', targetModelSelector) + assert.ok( + targetModelText.includes(PROVIDER_SWITCH_SOL_LABEL), + 'The target model option did not display the expected model label' + ) await ensureModelOptionVisible(control, `model-option-${PROVIDER_SWITCH_OFFICIAL_OPTION_ID}`) const officialModelSelector = `[data-testid="model-option-${PROVIDER_SWITCH_OFFICIAL_OPTION_ID}"]` await control.command('waitFor', officialModelSelector, { text: PROVIDER_SWITCH_OFFICIAL_LABEL, timeoutMs: DEFAULT_STEP_TIMEOUT_MS, }) - const disabledModelText = await control.command('getText', officialModelSelector) + const officialModelText = await control.command('getText', officialModelSelector) assert.ok( - disabledModelText.includes(PROVIDER_SWITCH_OFFICIAL_LABEL), + officialModelText.includes(PROVIDER_SWITCH_OFFICIAL_LABEL), 'The target model option did not display the expected model label' ) assert.doesNotMatch( - disabledModelText, + officialModelText, /官方 Codex|Official Codex/, 'The target model option displayed the provider restriction inline' ) @@ -2506,8 +2667,13 @@ async function verifyBackgroundTaskWindowLifecycle({ '[data-testid="desktop-workbench-content"]', 'The middle-position conversation scroll container before switching' ) + const middleDistanceBeforeSwitch = distanceFromBottom(middlePositionBeforeSwitch) assert.ok( - distanceFromBottom(middlePositionBeforeSwitch) > 100, + middlePositionBeforeSwitch.scrollTop > 100, + 'The long conversation did not leave the top before testing position restoration' + ) + assert.ok( + middleDistanceBeforeSwitch > 100, 'The long conversation did not leave the bottom before testing position restoration' ) await captureVerificationScreenshot( @@ -2573,6 +2739,15 @@ async function verifyBackgroundTaskWindowLifecycle({ '[data-testid="desktop-workbench-content"]', 'The middle-position conversation scroll container after switching back' ) + const middleDistanceAfterSwitch = distanceFromBottom(middlePositionAfterSwitch) + assert.ok( + middlePositionAfterSwitch.scrollTop > 100, + 'The restored long conversation unexpectedly returned to the top' + ) + assert.ok( + middleDistanceAfterSwitch > 100, + 'The restored long conversation unexpectedly returned to the bottom' + ) await captureVerificationScreenshot( control, lifecycleScreenshotName('08-task-middle-position-after-switch-back.png') @@ -3452,9 +3627,19 @@ function cors(response) { response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') } -function requestContainsToolOutput(request) { - const input = JSON.stringify(request.input ?? []) - return input.includes('function_call_output') || input.includes('custom_tool_call_output') +function requestContainsToolOutput(request, callId) { + const containsOutput = value => { + if (Array.isArray(value)) return value.some(containsOutput) + if (!value || typeof value !== 'object') return false + + const type = value.type + const isToolOutput = type === 'function_call_output' || type === 'custom_tool_call_output' + if (isToolOutput && (!callId || value.call_id === callId)) return true + + return Object.values(value).some(containsOutput) + } + + return containsOutput(request.input ?? []) } function requestAdvertisesShellTool(request) { @@ -3462,6 +3647,11 @@ function requestAdvertisesShellTool(request) { return tools.some(tool => tool?.name === 'exec_command' || tool?.name === 'shell_command') } +function requestAdvertisesViewImageTool(request) { + const tools = Array.isArray(request.tools) ? request.tools : [] + return tools.some(tool => tool?.name === 'view_image') +} + function selectTool(request, name, argumentsValue) { const tools = Array.isArray(request.tools) ? request.tools : [] const names = new Set(tools.map(tool => tool?.name).filter(Boolean)) @@ -3529,6 +3719,13 @@ function selectViewImageTool(request, workspacePath) { }) } +function snapshotHasAssistantActivity(snapshot) { + return ( + snapshot.testIds.includes('thinking-indicator') || + snapshot.testIds.includes('process-text-block') + ) +} + async function verifyActiveGoalIdleUnreadLifecycle({ composerSelector, control }) { control.setScenario('goal_idle') const taskRowsBeforeGoal = new Set( @@ -3560,7 +3757,7 @@ async function verifyActiveGoalIdleUnreadLifecycle({ composerSelector, control } snapshot => snapshot.testIds.includes(goalRunningTestId) && snapshot.testIds.includes('pause-response-button') && - snapshot.testIds.includes('thinking-indicator') && + snapshotHasAssistantActivity(snapshot) && !snapshot.testIds.includes('send-message-button') && !snapshot.testIds.includes(goalUnreadTestId), 'The running Goal turn did not render a consistent sidebar, composer, and message state' @@ -3601,6 +3798,7 @@ async function verifyActiveGoalIdleUnreadLifecycle({ composerSelector, control } snapshot.testIds.includes('goal-status-bar') && snapshot.testIds.includes(goalRunningTestId) && snapshot.testIds.includes('pause-response-button') && + snapshotHasAssistantActivity(snapshot) && !snapshot.testIds.includes('send-message-button') && !snapshot.testIds.includes(goalUnreadTestId) && snapshot.text.includes(GOAL_IDLE_PROMPT) && @@ -3746,6 +3944,7 @@ async function verifyGoalRestartRecoveryLifecycle({ snapshot.testIds.includes(goalRunningTestId) && snapshot.testIds.includes('goal-status-bar') && snapshot.testIds.includes('pause-response-button') && + snapshotHasAssistantActivity(snapshot) && !snapshot.testIds.includes('send-message-button') && !snapshot.testIds.includes(goalUnreadTestId) && snapshot.text.includes(GOAL_RESTART_INITIAL_TEXT), @@ -4150,6 +4349,7 @@ class DesktopE2EServer { this.failedCloudModelWaiter = null this.scenario = 'initial' this.modelStage = 'initial' + this.viewImageStage = 'initial' this.memoryStage = 'initial' this.concurrentMemoryResponses = [] this.concurrentMemoryTaskNumbers = new Set() @@ -4157,6 +4357,7 @@ class DesktopE2EServer { this.matrixCase = null this.matrixState = null this.toolLessPrewarmHandled = false + this.viewImageToolLessPrewarmHandled = false this.memoryToolLessPrewarmHandled = false this.cloudToolLessPrewarmHandled = false this.toolOutput = null @@ -4372,6 +4573,7 @@ class DesktopE2EServer { 'cloud_follow_up', 'model_protocol_matrix', 'provider_switch_retry', + 'view_image', ].includes(scenario), `Unknown desktop E2E scenario: ${scenario}` ) @@ -4792,6 +4994,17 @@ class DesktopE2EServer { return } + if ( + this.scenario === 'view_image' && + this.viewImageStage === 'initial' && + !this.viewImageToolLessPrewarmHandled && + !requestAdvertisesViewImageTool(body) + ) { + this.viewImageToolLessPrewarmHandled = true + this.writeSse(response, [responseCreated(responseId), responseCompleted(responseId)]) + return + } + if ( this.scenario === 'cloud_initial' && this.cloudModelStage === 'initial' && @@ -4843,9 +5056,9 @@ class DesktopE2EServer { ) this.recordScenarioRequest('initial', modelRequest) assert.equal( - requestContainsToolOutput(body), + requestContainsToolOutput(body, 'wework-e2e-view-image'), true, - 'The real Codex request did not report its tool output to the model service' + 'The real Codex request did not report the view_image tool output to the model service' ) this.toolOutput = JSON.stringify(body.input) this.modelStage = 'complete' @@ -4861,6 +5074,44 @@ class DesktopE2EServer { return } + if (this.scenario === 'view_image' && this.viewImageStage === 'initial') { + this.recordScenarioRequest('view_image', modelRequest) + assert.ok( + JSON.stringify(body).includes(VIEW_IMAGE_PROMPT), + 'The real Codex request did not contain the view_image prompt' + ) + const image = selectViewImageTool(body, this.workspacePath) + this.viewImageStage = 'awaiting_tool_output' + this.writeSse(response, [ + responseCreated(responseId), + ...functionCall('wework-e2e-view-image', image.name, image.arguments), + responseCompleted(responseId), + ]) + return + } + + if (this.scenario === 'view_image') { + assert.equal( + this.viewImageStage, + 'awaiting_tool_output', + `Unexpected desktop E2E view_image stage: ${this.viewImageStage}` + ) + this.recordScenarioRequest('view_image', modelRequest) + assert.equal( + requestContainsToolOutput(body, 'wework-e2e-view-image'), + true, + 'The real Codex request did not report the view_image tool output to the model service' + ) + this.viewImageStage = 'complete' + await new Promise(resolvePromise => setTimeout(resolvePromise, 250)) + this.writeSse(response, [ + responseCreated(responseId), + assistantMessage(VIEW_IMAGE_COMPLETION_TEXT), + responseCompleted(responseId), + ]) + return + } + if (this.scenario === 'concurrent_memory') { const promptMatch = JSON.stringify(body).match(/WEWORK_DESKTOP_E2E_CONCURRENT_MEMORY_(\d+)/) assert.ok(promptMatch, 'Concurrent memory request did not contain a task UI prompt') @@ -8134,47 +8385,7 @@ async function main() { Buffer.from(processingSummaryScreenshot.replace(/^data:image\/png;base64,/, ''), 'base64') ) } - await control.command('click', '[data-testid="processing-summary-toggle"]') - await control.command('waitFor', '[data-processing-block-id="wework-e2e-view-image"]', { - timeoutMs: DEFAULT_STEP_TIMEOUT_MS, - }) - await control.command('scrollIntoView', '[data-testid="processing-live-preview"]') - await control.command( - 'waitFor', - '[data-processing-block-id="wework-e2e-view-image"] [data-tool-detail-toggle][aria-expanded="false"]', - { visible: true, stableMs: 300, timeoutMs: DEFAULT_STEP_TIMEOUT_MS } - ) - await new Promise(resolvePromise => setTimeout(resolvePromise, 500)) - await captureVerificationScreenshot( - control, - '03-view-image-collapsed.png', - '[data-testid="processing-live-preview"]' - ) - await control.command( - 'click', - '[data-processing-block-id="wework-e2e-view-image"] [data-tool-detail-toggle]' - ) - await control.command('waitFor', '[data-testid="image-view-preview"]', { - stableMs: 500, - timeoutMs: DEFAULT_STEP_TIMEOUT_MS, - }) - await control.command( - 'waitFor', - '[data-processing-block-id="wework-e2e-view-image"] [data-tool-detail-toggle][aria-expanded="true"]', - { stableMs: 500, timeoutMs: DEFAULT_STEP_TIMEOUT_MS } - ) - await control.command('scrollIntoView', '[data-testid="processing-live-preview"]') - await control.command('waitFor', '[data-testid="image-view-preview"]', { - visible: true, - stableMs: 500, - timeoutMs: DEFAULT_STEP_TIMEOUT_MS, - }) - await new Promise(resolvePromise => setTimeout(resolvePromise, 500)) - await captureVerificationScreenshot( - control, - '04-view-image-expanded.png', - '[data-testid="processing-live-preview"]' - ) + await verifyViewImageProcessingBlock(control) await control.command('click', '[data-testid="processing-summary-toggle"]') await control.command('waitFor', '[data-testid="file-change-stats-label"]', { text: '+1', @@ -8998,6 +9209,9 @@ async function main() { workspacePath, }) + phase = 'standalone-view-image' + await verifyStandaloneViewImageTask({ composerSelector, control, projectRowSelector }) + if (desktopScenario) { phase = 'desktop-extension-scenario' await desktopScenario.verify(control) diff --git a/wework/src/components/chat/RequestUserInputCard.test.tsx b/wework/src/components/chat/RequestUserInputCard.test.tsx index 185a46090e..5402d13568 100644 --- a/wework/src/components/chat/RequestUserInputCard.test.tsx +++ b/wework/src/components/chat/RequestUserInputCard.test.tsx @@ -72,7 +72,7 @@ describe('RequestUserInputCard', () => { expect(card).toHaveClass('max-h-[min(60dvh,36rem)]', 'flex', 'flex-col') expect(questionsContainer).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto') - expect(option).toHaveClass('min-h-9', 'items-start', 'py-2') + expect(option).toHaveClass('min-h-9', 'items-start', 'py-1.5') expect(option.querySelector('span.min-w-0')).toHaveClass('whitespace-normal', 'break-words') expect(option).toHaveTextContent(longLabel) expect(option).toHaveTextContent(longDescription) diff --git a/wework/src/components/chat/RequestUserInputCard.tsx b/wework/src/components/chat/RequestUserInputCard.tsx index ce074e6c3f..9bb0d66e49 100644 --- a/wework/src/components/chat/RequestUserInputCard.tsx +++ b/wework/src/components/chat/RequestUserInputCard.tsx @@ -140,10 +140,10 @@ export function RequestUserInputCard({ data-testid="request-user-input-ignore-button" disabled={isDisabled} onClick={onIgnore} - className="inline-flex h-9 min-w-[44px] shrink-0 items-center gap-1.5 rounded-lg px-2 text-sm font-semibold text-text-muted hover:bg-surface hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60" + className="inline-flex h-11 min-w-[44px] shrink-0 items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-text-muted hover:bg-muted hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60 md:h-8" > {t('request_user_input.ignore')} - + ESC @@ -152,10 +152,10 @@ export function RequestUserInputCard({ data-testid="request-user-input-submit-button" disabled={isDisabled || questions.length === 0} onClick={() => handleSubmit()} - className="inline-flex h-9 min-w-[72px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#2f9bff] px-4 text-sm font-semibold text-white shadow-sm hover:bg-[#1d8af0] disabled:cursor-not-allowed disabled:opacity-60" + className="inline-flex h-11 min-w-[68px] shrink-0 items-center justify-center gap-1.5 rounded-lg bg-text-primary px-3 text-sm font-medium text-background shadow-sm hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 md:h-8" > {t('request_user_input.submit')} - + @@ -169,7 +169,7 @@ export function RequestUserInputCard({ onSubmit={handleFormSubmit} onKeyDown={handleKeyDown} tabIndex={-1} - className="flex max-h-[min(60dvh,36rem)] w-full flex-col rounded-[1.5rem] border border-border bg-background px-4 py-2.5 shadow-[0_18px_42px_rgba(15,23,42,0.10)]" + className="mx-auto flex max-h-[min(60dvh,36rem)] w-full max-w-2xl flex-col rounded-2xl border border-border/70 bg-base px-3 py-3 shadow-sm" >
(
{question.header ? ( -
+
{question.header}
) : null} {!customQuestionIds.has(question.id) ? ( -
+
{question.question}
) : null} @@ -202,8 +202,8 @@ export function RequestUserInputCard({ disabled={isDisabled} onClick={() => selectOption(question, option)} className={cn( - 'flex min-h-9 w-full min-w-0 items-start gap-2.5 rounded-2xl px-3 py-2 text-left transition-colors', - isSelected ? 'bg-surface' : 'hover:bg-surface', + 'flex min-h-9 w-full min-w-0 items-start gap-2.5 rounded-xl px-2.5 py-1.5 text-left transition-colors', + isSelected ? 'bg-muted' : 'hover:bg-muted/70', isDisabled && 'cursor-not-allowed opacity-60' )} > @@ -212,13 +212,13 @@ export function RequestUserInputCard({ 'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold leading-none', isSelected ? 'bg-text-primary text-background' - : 'bg-surface text-text-muted' + : 'bg-muted text-text-muted' )} > {index + 1} - + {option.label} {option.description ? ( @@ -233,7 +233,7 @@ export function RequestUserInputCard({
) : null} {question.allowCustom ? ( -
+
@@ -248,7 +248,7 @@ export function RequestUserInputCard({ [question.id]: event.target.value, })) }} - className="h-8 min-w-0 flex-1 rounded-lg border-0 bg-transparent px-0 text-sm font-semibold leading-5 text-text-primary outline-none placeholder:text-text-muted disabled:cursor-not-allowed disabled:opacity-60" + className="h-8 min-w-0 flex-1 rounded-lg border-0 bg-transparent px-0 text-sm font-medium leading-5 text-text-primary outline-none placeholder:text-text-muted disabled:cursor-not-allowed disabled:opacity-60" placeholder={question.question || t('request_user_input.custom_placeholder')} />
diff --git a/wework/src/e2e/automation.ts b/wework/src/e2e/automation.ts index e7ad53bd3b..81f38a9484 100644 --- a/wework/src/e2e/automation.ts +++ b/wework/src/e2e/automation.ts @@ -417,6 +417,24 @@ function desktopControlElementVisible(element: HTMLElement): boolean { ) } +async function expandDesktopProcessingSummaries(): Promise { + const clickCollapsed = (selector: string) => { + const buttons = findDesktopControlElements(selector).filter(desktopControlElementEnabled) + buttons.forEach(button => button.click()) + return buttons.length + } + + const finalCount = clickCollapsed( + '[data-testid="final-processing-toggle"][aria-expanded="false"]' + ) + await waitForDesktopControlTick() + const summaryCount = clickCollapsed( + '[data-testid="processing-summary-toggle"][aria-expanded="false"]' + ) + await waitForDesktopControlTick() + return JSON.stringify({ finalCount, summaryCount }) +} + async function waitForDesktopControlTick(): Promise { const url = desktopControlUrl() if (!url) throw new Error('Desktop E2E control URL is not configured') @@ -765,6 +783,8 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro element.scrollIntoView({ block: 'center', inline: 'nearest' }) return element.textContent?.trim() ?? '' } + case 'expandProcessingSummaries': + return expandDesktopProcessingSummaries() case 'scrollIntoViewAsUser': { const element = findDesktopControlElements(command.selector)[0] if (!element) throw new Error(`Unable to find selector "${command.selector}"`) @@ -853,6 +873,52 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro element.click() return element.textContent?.trim() ?? '' } + case 'clickDescendantInElementWithText': { + const text = command.text ?? '' + const targetSelector = command.target?.trim() + if (!text) throw new Error('clickDescendantInElementWithText requires text') + if (!targetSelector) throw new Error('clickDescendantInElementWithText requires target') + const timeoutMs = command.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS + const startedAt = Date.now() + let lastFailure = `Unable to find selector "${command.selector}" containing "${text}"` + while (Date.now() - startedAt < timeoutMs) { + const container = findDesktopControlElements(command.selector).find(element => + (element.textContent ?? '').includes(text) + ) + const target = container?.querySelector(targetSelector) + if (target && desktopControlElementEnabled(target)) { + target.scrollIntoView({ block: 'center', inline: 'nearest' }) + target.click() + return target.textContent?.trim() ?? '' + } + if (container && !target) { + lastFailure = `Unable to find descendant "${targetSelector}" inside "${command.selector}"` + } else if (target && !desktopControlElementEnabled(target)) { + lastFailure = `Descendant "${targetSelector}" inside "${command.selector}" is disabled` + } + await waitForDesktopControlTick() + } + throw new Error(lastFailure) + } + case 'markElementWithText': { + const text = command.text ?? '' + const value = command.value?.trim() + if (!text) throw new Error('markElementWithText requires text') + if (!value) throw new Error('markElementWithText requires value') + const timeoutMs = command.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const element = findDesktopControlElements(command.selector).find(candidate => + (candidate.textContent ?? '').includes(text) + ) + if (element) { + element.dataset.e2eAnchorId = value + return element.textContent?.trim() ?? '' + } + await waitForDesktopControlTick() + } + throw new Error(`Unable to find selector "${command.selector}" containing "${text}"`) + } case 'fill': { const element = findDesktopControlElements(command.selector)[0] if (!element) throw new Error(`Unable to find selector "${command.selector}"`)