diff --git a/.env.example b/.env.example index c7007a1..40f7aa2 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,17 @@ AGENTS_API_PROXY_TOKEN_FILE=~/.parsar/agents-api/web-token # are configured. This flag is presentation policy, not capability discovery. # AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 +# Public, non-secret opt-in for the operator-qualified basic Codex +# openai_hosted Session profile. Leave unset unless Core was started with a +# qualified managed Runtime provider. This flag does not probe runtime readiness. +# AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 + +# Public, non-secret opt-in for the complete Environment Files profile. Leave +# unset for older Core revisions. Enable only after the connected Core has been +# qualified for Files.list and managed Files.create; self_hosted reads use its +# exact workspace_directory root. +# AGENTS_CORE_WEB_ENVIRONMENT_FILES=1 + # Optional local-only Docker connection recipe. This renders a copyable command; # it never gives the browser Docker access or reads the credential file. Every # value below is compiled into the browser bundle, so values must be non-secret. diff --git a/README.md b/README.md index 6a517cc..dab1370 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,17 @@ persistence, scheduling, and execution; this project does not embed or reimpleme ## What you can do - Create, view, edit, and delete reusable Agent configurations. -- Start durable Sessions and inspect their saved Items. +- Start durable Sessions with optional text input, metadata, and bounded + Session-only Agent overrides, then inspect their saved Items. +- Show Sessions for all Agents or use a server-side root-Agent filter. - Optionally create a Codex `self_hosted` Session and follow the connection state of an operator-managed Linux executor. +- Optionally create the basic Codex `openai_hosted` profile through an + operator-qualified managed Runtime, inspect its exact Environment state, and + list or explicitly add bounded `/workspace` files. +- Use Dashboard for the last Agent/Session results successfully traversed to + Core's page-chain end marker, and System for live API reachability, pinned + contract coverage, Source Files, and ownership boundaries. - Follow live progress over SSE and recover persisted output after reconnecting. - Cancel active work and return function results or errors. - Use the same Web client with Parsar Core or another proven-compatible Core. @@ -25,7 +33,7 @@ persistence, scheduling, and execution; this project does not embed or reimpleme | Core or interface | Can Web connect? | Notes | | --- | --- | --- | -| [Parsar Agents API Core](https://github.com/MiniMax-AI-Dev/parsar/tree/main/services/agents-api) | Yes | Primary tested integration | +| [Parsar Agents API Core at `dadf64a7`](https://github.com/MiniMax-AI-Dev/parsar/tree/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api) | Yes | Primary tested integration and immutable capability baseline | | Another Core implementing the tested `/v1/agents/**` HTTP/SSE subset | Yes | It must match the resources and behavior in [protocol coverage](docs/protocol-coverage.md) | | OpenAI's hosted Agents API | Not claimed | This project does not promise complete hosted-API compatibility | | OpenAI Agents SDK, Responses API, or Parsar daemon WebSocket | No | They are an SDK interface, a model API, and an internal execution interface—not directly connectable Core protocols | @@ -36,6 +44,10 @@ JSON requests and authenticated SSE under `/v1/agents/**`, with `OpenAI-Beta: agents=v1`. Compatibility means this documented and tested subset, not merely accepting the header or sharing similar names. +The capability statements below are audited against immutable Parsar revision +[`dadf64a7`](https://github.com/MiniMax-AI-Dev/parsar/commit/dadf64a76bde58255281f3b6c3e939f8b556be09), +not a moving upstream branch. + ## Start Web with an existing Core You need Node.js 22.12+, pnpm 10.30.3, a running compatible Agent Core, and a @@ -82,28 +94,86 @@ Session creation form; it does not probe Core capabilities or prove that an executor, native runtime, model, or provider is ready. Restart `pnpm dev` after changing it. Never place an executor key or any other credential in this variable. +Basic managed hosted Session creation is a separate default-off presentation +policy. Enable it only after the Core operator has installed and qualified the +pinned Codex Runtime image, configured a stable default managed provider, and +accepted its Docker isolation and model-provider boundary: + +```dotenv +AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 +``` + +This flag likewise does not discover Core configuration or prove Runtime, native +harness, model, provider, Function, or tool readiness. The Core may still reject +creation when no qualified provider is configured. + Keep credentials server-side. A direct Core URL in the connection dialog is only for a compatible Core that explicitly allows the Web origin, methods, and headers through CORS. Do not have a Core running yet? Use the immutable -[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service). -The repository's [legacy Web connection runbook](docs/core-connection.md) is pinned -to the older revision stated at its top; revalidate its PostgreSQL, caller-key, -device, daemon, native-harness, `CODEX_HOME`, verification, and shutdown steps before -applying them to a newer Core. +[current Parsar setup guide](https://github.com/MiniMax-AI-Dev/parsar/blob/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api/README.md#standalone-http-service). +The repository's [Web connection runbook](docs/core-connection.md) is pinned to the +same revision and separates ordinary daemon/self-hosted setup from the managed +Docker-hosted operator profile. ## First use 1. Open **Agents** and create an Agent with a name, instructions, and model ID. -2. Review, edit, or delete the saved Agent, or start a Session from it. The default +2. Review, edit, or delete the saved Agent, or open **Start Session**. The default uses no Environment. -3. Open **Sessions**, select the Session, and send a message. -4. Follow live Items, cancel active work, or return a requested function result. +3. Optionally set a title, enter the first text message, or + configure the bounded whole-field Agent overrides used only by this Session. +4. In **Sessions**, choose **All Agents** or one root Agent, select a Session, and + continue the conversation. +5. Follow live Items, cancel active work, or return a requested function result. +6. Open **System → Source Files** to upload, retrieve, download, or delete one + project-owned `user_data` file by its Core ID. Core has no Source Files list, so + retain the returned ID; Web does not persist it across page reloads. + +**Start Session** accepts an exact non-empty text string or an ordered array of user +messages containing `input_text` parts only. Message order and grouping are preserved; +images, attachments, non-user roles, and other content parts are not supported. +Meaningful input is sent by streaming `POST /v1/agents/sessions` with `stream:true`; Web consumes +that creation SSE while running durable reconciliation for Session, Item, and +eligible Environment state and starting the paginated Turn read independently. After +the POST settles, Web hands live updates off to `GET .../events`. Empty or +whitespace-only input is omitted and uses the JSON `stream:false` create path for +`none` and `self_hosted`, so the Session starts idle. Web deliberately uses creation +SSE for `openai_hosted`, including idle creation, so it can reconcile provisioning +events before handing off to `GET .../events`. The optional title becomes +`metadata.title`; Start Session does not expose additional metadata. Additional +string metadata remains editable from the actions for an existing Session and must +stay within the pinned Core limits. Never put credentials or secrets there. + +Session-only Agent overrides are deliberately finite and whole-field based. Web can +replace `model`, set or clear `instructions`, replace the plain-text configuration, +reset saved-only `multi_agent`, `reasoning`, or `service_tier` values to Core +defaults, and inherit, clear, or fully replace `tools` through the same bounded +Function/HTTP MCP editor. Untouched fields are omitted. There is no arbitrary Agent +JSON editor or patch-style partial Tool update. Environment choices are no Environment, +separately enabled +`self_hosted`, and separately enabled basic `openai_hosted`; the managed choice is +blocked when the effective Agent contains MCP because hosted MCP is not qualified. + +After a failed create, only an explicit unchanged retry reuses the in-memory +idempotency key. The stable request fingerprint covers `agent_id`, the finite `agent` +override when present, `environment`, exact optional `input`, normalized `metadata`, +`stream`, and sorted derived `vault_ids`; any change to that projected request +rotates the key. The Sessions root-Agent picker is also server-side: every +continuation request carries the same `agent_id`, while **All Agents** omits the +parameter instead of filtering an already loaded page in the browser. + +If an HTTP MCP server needs a static bearer, open **Vaults**, create a Vault and a +write-only Credential for the exact HTTPS endpoint, then select it under +**Agent → HTTP MCP → Authentication**. When the Session starts, Web attaches the +owning Vault and never reads the token back. The option is shown only after the +connected Core successfully exposes the Vault catalog; catalog metadata alone is +not proof that the MCP server can be reached at runtime. When the operator opt-in is enabled, **Start Session** also offers **Self-hosted**. Its absolute Workspace path is on the executor host, not in the browser, Web server, -or `parsar-daemon` container. After Core creates the idle Session, Web can show a +or `parsar-daemon` container. After Core creates the Session, Web can show a launcher template built from that Session's Environment ID and executor origin. The operator-issued executor credential file stays outside Web, and the launcher itself runs on caller-managed Linux executor compute. See @@ -117,6 +187,23 @@ offers a copyable Docker command alongside the native launcher. Web still never reads the credential file or talks to Docker, and running the command can release already queued paid input. +The Source Files surface is independent of Session Environment choice. For a complete +basic managed Session, Core provisions the `openai_hosted` Runtime automatically; +Web shows its managed ID, enabled/disabled network policy, empty startup-install +metadata, and durable/live status. Workspace file controls are independently +default-off; set `AGENTS_CORE_WEB_ENVIRONMENT_FILES=1` only after qualifying the +connected Core Files.list and managed Files.create APIs. Managed reads use +`/workspace`; self-hosted reads use the +exact Session `workspace_directory`. Web never shows a +self-hosted launcher or caller connection action for that profile. Inline writes in +the Session panel and Source-ID copies in System appear only after an exact current +`openai_hosted` resource read confirms a non-terminal status and empty +files/plugins/skills metadata. Missing write responses are never replayed. Templates, +restricted domains, populated startup installs, hosted MCP, readiness discovery, and +other hosted engines remain unavailable. `self_hosted` Workspace files remain +read-only. Docker is Core's private managed Runtime adapter, not another public +Environment discriminator. + The model ID must be supported by the connected execution runtime. Core currently has no model-catalog endpoint, so Web suggestions are editable hints rather than availability guarantees. Successfully saving an Agent proves configuration storage, @@ -151,16 +238,17 @@ See [Architecture](docs/architecture.md) for the full component and trust bounda | `503 execution_unavailable` / `Execution is not enabled` | Core rejected execution; inspect its safe error plus runtime and ownership state. A worker, executor, or daemon may be unconfigured or disconnected, or an execution lease may have been lost | | Agent saves but its model fails | Use a model ID and provider credential supported by the connected runtime | | Self-hosted option is hidden | Set the non-secret `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1` operator flag and restart/rebuild Web only after the connected Codex Core and executor path have been reviewed | +| Managed hosted option is hidden | Set `AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1` and restart/rebuild Web only after the pinned Core managed Runtime provider has been qualified | +| Workspace files are hidden | Set `AGENTS_CORE_WEB_ENVIRONMENT_FILES=1` and restart/rebuild Web only after the connected Core Files.list plus managed Files.create routes and Environment profiles have been qualified | `/healthz` proves HTTP liveness only, not chat readiness. Check durable Core state and -the current pinned Parsar guide before retrying an uncertain request; the -[legacy 043 troubleshooting snapshot](docs/core-connection.md#troubleshooting) is -historical context only. +the current pinned Parsar guide before retrying an uncertain request; see the +[connection troubleshooting runbook](docs/core-connection.md#troubleshooting). ## Documentation -- [Current Parsar Core setup](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service) — immutable current upstream guide -- [Legacy Web connection runbook](docs/core-connection.md) — historical `0438880` snapshot; revalidate before use +- [Current Parsar Core setup](https://github.com/MiniMax-AI-Dev/parsar/blob/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api/README.md#standalone-http-service) — immutable current upstream guide +- [Web connection runbook](docs/core-connection.md) — matching `dadf64a7` operator and browser boundary - [Protocol coverage](docs/protocol-coverage.md) — exact supported API surface - [Architecture](docs/architecture.md) — ownership, runtime, and trust boundaries - [Roadmap](docs/roadmap.md) — planned Web and Core integrations diff --git a/README.zh-CN.md b/README.zh-CN.md index 264e278..8ded618 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,8 +14,14 @@ TypeScript 客户端。鉴权、持久化、调度和执行仍由 Core 负责; ## 能做什么 - 创建、查看、编辑和删除可复用的 Agent 配置。 -- 启动持久化 Session,并查看其中保存的 Item。 +- 启动可带初始文本、可选标题和有限 Session 级 Agent 覆盖的持久化 Session, + 并查看其中保存的 Item。 +- 查看所有 Agent 的 Session,或使用服务端 root-Agent 筛选。 - 可选创建 Codex `self_hosted` Session,并查看运维方管理的 Linux executor 连接状态。 +- 可选通过运维方已验收的 managed Runtime 创建基础 Codex `openai_hosted` + Session,查看精确 Environment 状态,并显式列出或添加有界 `/workspace` 文件。 +- 通过 Dashboard 查看最近一次成功遍历到 Core 分页结束标记的 Agent/Session + 加载结果,通过 System 查看实时 API 可达性、固定契约范围、Source Files 和所有权边界。 - 通过 SSE 查看实时进度,并在重连后恢复已持久化的输出。 - 取消正在执行的任务,并回传函数执行结果或错误。 - 使用同一个 Web 客户端连接 Parsar Core 或其他经验证兼容的 Core。 @@ -24,7 +30,7 @@ TypeScript 客户端。鉴权、持久化、调度和执行仍由 Core 负责; | Core 或接口 | Web 能否连接? | 说明 | | --- | --- | --- | -| [Parsar Agents API Core](https://github.com/MiniMax-AI-Dev/parsar/tree/main/services/agents-api) | 可以 | 主要且经过测试的集成 | +| [`dadf64a7` 的 Parsar Agents API Core](https://github.com/MiniMax-AI-Dev/parsar/tree/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api) | 可以 | 主要且经过测试的集成,也是不可变能力基线 | | 实现了 `/v1/agents/**` 已测试 HTTP/SSE 子集的其他 Core | 可以 | 必须符合[协议覆盖范围](docs/protocol-coverage.md)记录的资源和行为 | | OpenAI 托管的 Agents API | 不作承诺 | 本项目不承诺与托管 API 完全兼容 | | OpenAI Agents SDK、Responses API 或 Parsar daemon WebSocket | 不可以 | 它们分别是 SDK 接口、模型 API 和内部执行接口,不是可直接连接的 Core 协议 | @@ -35,6 +41,10 @@ beta HTTP 资源形态中经过测试的子集: `OpenAI-Beta: agents=v1`。这里的兼容是指已记录、已测试的子集, 仅仅接受该请求头或名称相似,并不代表兼容。 +下文所有能力说明都以不可变 Parsar revision +[`dadf64a7`](https://github.com/MiniMax-AI-Dev/parsar/commit/dadf64a76bde58255281f3b6c3e939f8b556be09) +为审计基线,而不是跟随变化的上游分支。 + ## 已有 Core 时启动 Web 你需要 Node.js 22.12+、pnpm 10.30.3、一个正在运行的兼容 Agent Core, @@ -78,21 +88,69 @@ AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1 该开关只暴露已支持的表单,不会探测 Core 能力,也不能证明 executor、原生运行时、 模型或提供商已就绪。不得在其中放入 executor key 或其他凭据。 +基础 managed hosted Session 创建使用另一个默认关闭的展示开关。只有 Core 运维方已 +安装并验收固定 Codex Runtime 镜像、配置稳定的默认 managed provider,并接受 Docker +隔离和模型提供商边界后才启用: + +```dotenv +AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1 +``` + +该开关同样不会发现 Core 配置,也不能证明 Runtime、原生 harness、模型、提供商、 +Function 或 Tool 已就绪。Core 未配置合格 provider 时仍会拒绝创建。 + 修改后重启 `pnpm dev`。凭据应保留在服务端。只有兼容 Core 通过 CORS 明确允许 Web 的源、方法和请求头时,才能在连接对话框中使用 Core 直连 URL。 还没有运行中的 Core?请使用不可变的 -[当前 Parsar 配置指南](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service)。 -仓库内的[旧版 Web 连接手册](docs/core-connection.md)固定在文首标注的旧 revision; -将其中 PostgreSQL、调用方凭据、执行设备、daemon、原生执行适配层(harness)、 -`CODEX_HOME`、验证和停止流程用于更新版 Core 前必须重新核对。 +[当前 Parsar 配置指南](https://github.com/MiniMax-AI-Dev/parsar/blob/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api/README.md#standalone-http-service)。 +仓库内的 [Web 连接手册](docs/core-connection.md)固定在同一 revision,并区分普通 +daemon/self-hosted 配置与 managed Docker-hosted 运维 profile。 ## 第一次使用 1. 打开 **Agents**,使用名称、指令(instructions)和 model ID 创建 Agent。 -2. 查看、编辑或删除已保存的 Agent,也可以从该 Agent 启动 Session。 -3. 打开 **Sessions**,选择 Session 并发送消息。 -4. 查看实时 Item、取消正在执行的任务,或回传请求的函数结果。 +2. 查看、编辑或删除已保存的 Agent,或打开 **Start Session**;默认不使用 + Environment。 +3. 可以设置标题、输入第一条文本消息,或配置只作用于该 Session + 的有限 whole-field Agent 覆盖。 +4. 在 **Sessions** 中选择 **All Agents** 或一个 root Agent,再选择 Session 并继续对话。 +5. 查看实时 Item、取消正在执行的任务,或回传请求的函数结果。 +6. 打开 **System → Source Files**,按 Core 返回的 ID 上传、查询、下载或删除一个 + project-owned `user_data` 文件。Core 没有 Source Files 列表,Web 也不会在刷新后 + 持久保存该 ID。 + +**Start Session** 接受精确保留的非空文本字符串,或按原顺序排列、仅包含 +`input_text` part 的 user message 数组;不支持图片、附件、非 user role 或其他 +content part。有效输入通过带 `stream:true` 的流式 `POST /v1/agents/sessions` 发送;Web 一边消费创建 SSE,一边对 +Session、Item 和合格 Environment 的持久状态执行协调,并独立启动 Turn 分页读取。 +POST 结束后,Web 把实时更新交接给 `GET .../events`。对 `none` 和 `self_hosted`, +空输入或纯空白输入会被省略并走 JSON `stream:false` 创建路径,因此 Session 以 idle +状态开始。Web 对 `openai_hosted`(包括 idle 创建)固定使用创建 SSE,以便在交接到 +`GET .../events` 前协调 provisioning 事件。可选标题写入 `metadata.title`; +Start Session 不再暴露额外 metadata。额外字符串 metadata 仍可在已有 Session 的 +Actions 中编辑,并须符合固定 Core 限制;其中绝不能放入凭据或秘密。 + +Session 级 Agent 覆盖是有限且按 whole-field 生效的集合:Web 可以替换 `model`、 +设置或清空 `instructions`、替换纯文本配置,将 saved-only 的 `multi_agent`、 +`reasoning` 或 `service_tier` 重置为 Core 默认值,并通过同一套受限的 +Function/HTTP MCP 编辑器继承、清空或完整替换 `tools`。未操作的字段不会发送; +这里没有任意 Agent JSON 编辑器,也不提供 patch 风格的局部 Tool 更新。 +Environment 选项包括无 Environment、单独启用的 `self_hosted` 和单独启用的基础 +`openai_hosted`;effective Agent 含 MCP 时 managed +选项会被阻止,因为 hosted MCP 尚未验收。 + +创建失败后,只有显式发起且请求未变化的重试才会复用内存中的幂等 key。稳定 +request fingerprint 完整覆盖 `agent_id`、存在时的有限 `agent` 覆盖、 +`environment`、精确可选 `input`、规范化 `metadata`、`stream` 和排序后的派生 +`vault_ids`;投影请求发生任何变化都会换 key。Sessions 的 root-Agent 选择器同样在 +服务端生效:每个分页请求都携带相同 `agent_id`,而 **All Agents** 会省略该参数, +不是在浏览器中只筛选已经加载的一页。 + +HTTP MCP 需要静态 Bearer 时,先在 **Vaults** 中为精确 HTTPS 地址创建 Vault 和 +只写 Credential,再在 **Agent → HTTP MCP → Authentication** 中选择它。创建 Session +时 Web 会附加所属 Vault,且永不读回 token。只有连接的 Core 成功暴露完整 Vault +catalog 后才会显示这项能力;catalog 元数据本身不能证明运行时可访问 MCP 服务。 启用运维开关后,**Start Session** 还会提供 **Self-hosted**。Workspace 是 executor 主机或容器中的绝对路径,不是浏览器、Web 服务或 daemon 容器的目录。Web 只展示 @@ -105,6 +163,16 @@ Core 返回的 Environment ID、executor origin、连接状态和安全 launcher 参数。连接面板会在原生 launcher 之外提供可复制的 Docker 命令;Web 仍不会读取 credential 文件或访问 Docker。若 Session 已有排队输入,运行命令可能立即触发付费调用。 +Source Files 与 Session 是否使用 Environment 无关。对于完整的基础 managed Session, +Core 会自动配置 `openai_hosted` Runtime;Web 展示 managed ID、enabled/disabled 网络 +策略、空 startup-install 元数据、durable/live 状态和显式 `/workspace` 文件列表,绝不 +展示 self-hosted launcher 或调用方连接动作。Session 内联写入和 System 中的 Source-ID +复制只有在精确查询当前 `openai_hosted` resource、确认非 terminal 状态且 +files/plugins/skills 元数据为空后才显示;写入响应丢失时不会重放。Templates、受限域名、 +非空启动安装、hosted MCP、readiness discovery 和其他 hosted engine 仍不可用。 +`self_hosted` Workspace Files 继续只读。Docker 是 Core 的私有 managed Runtime adapter, +不是另一个公开 Environment discriminator。 + model ID 必须由已连接的执行运行时支持。Core 当前没有模型目录接口, 因此 Web 建议项只是可编辑提示,不代表模型一定可用。成功保存 Agent 只能证明 配置已持久化,不能证明 daemon、模型或提供商凭据能够实际执行它。 @@ -135,15 +203,17 @@ WebSocket 当作 API URL。 | `503 execution_unavailable` / `Execution is not enabled` | Core 拒绝执行;请检查其安全错误、运行时和 ownership 状态。worker、executor 或 daemon 可能未配置或已断连,也可能丢失了执行 lease | | Agent 保存成功但模型运行失败 | 使用已连接运行时支持的 model ID 和提供商凭据 | | Self-hosted 选项未显示 | 只有核对兼容 Codex Core 与 executor 链路后,设置非秘密 `AGENTS_CORE_WEB_SELF_HOSTED_SESSIONS=1` 并重启或重建 Web | +| Managed hosted 选项未显示 | 只有固定 Core 的 managed Runtime provider 已验收后,设置 `AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS=1` 并重启或重建 Web | +| Workspace files 未显示 | 只有已验收当前 Core 的 Files.list、managed Files.create 和 Environment profile 后,设置 `AGENTS_CORE_WEB_ENVIRONMENT_FILES=1` 并重启或重建 Web;self-hosted 使用 Session 返回的实际 `workspace_directory` | `/healthz` 只能证明 HTTP 存活,不能证明聊天已就绪。重试结果不确定的请求前, -请先核对 Core 持久状态和当前固定版本的 Parsar 指南; -[旧版 043 故障排查快照](docs/core-connection.md#troubleshooting)仅供历史参考。 +请先核对 Core 持久状态和当前固定版本的 Parsar 指南;参见 +[连接故障排查手册](docs/core-connection.md#troubleshooting)。 ## 文档入口 -- [当前 Parsar Core 配置](https://github.com/MiniMax-AI-Dev/parsar/blob/2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e/services/agents-api/README.md#standalone-http-service) — 不可变的当前上游指南 -- [旧版 Web 连接手册](docs/core-connection.md) — 历史 `0438880` 快照,使用前必须重新核对 +- [当前 Parsar Core 配置](https://github.com/MiniMax-AI-Dev/parsar/blob/dadf64a76bde58255281f3b6c3e939f8b556be09/services/agents-api/README.md#standalone-http-service) — 不可变的当前上游指南 +- [Web 连接手册](docs/core-connection.md) — 对齐 `dadf64a7` 的运维和浏览器边界 - [协议覆盖范围](docs/protocol-coverage.md) — 准确的已支持 API 范围 - [架构说明](docs/architecture.md) — 所有权、运行时和信任边界 - [路线图](docs/roadmap.md) — 计划中的 Web 和 Core 集成 diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 7c504a5..2cab6c2 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -25,7 +25,7 @@ async function controlFixture(request: APIRequestContext, control: Record }>; - aborts: { sessionReads: number; itemReads: number; turnReads: number; streams: number }; + aborts: { sessionListReads: number; sessionReads: number; itemReads: number; turnReads: number; streams: number; environmentFileReads: number }; openStreams: string[]; } @@ -65,6 +65,23 @@ async function fixtureRequests(request: APIRequestContext): Promise; } +async function createFixtureSession(request: APIRequestContext, label: string) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { + headers: { + "OpenAI-Beta": "agents=v1", + "Idempotency-Key": `fixture-filter-${label}`, + }, + data: { + agent_id: "agent_b", + environment: { type: "none" }, + metadata: { filter_fixture: label }, + stream: false, + vault_ids: [], + }, + }); + expect(response.status()).toBe(201); +} + async function expectSelectedDeleteAbortsSessionRead( page: Page, request: APIRequestContext, @@ -105,16 +122,23 @@ async function openAgents(page: Page, request: APIRequestContext) { await resetFixture(request); await page.goto("/"); await page.getByRole("button", { name: "Agents" }).click(); - await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); } async function startSessionWithSecondAgent(page: Page) { - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Create Session" }).click(); } +async function openAdvancedSessionSettings(dialog: Locator) { + const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); + if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(dialog.getByRole("region", { name: "Advanced Session settings" })).toBeVisible(); +} + function environmentTrigger(page: Page) { return page.getByRole("button", { name: /Environment|Connect environment/i }); } @@ -153,7 +177,7 @@ async function attachElementScreenshot(locator: Locator, testInfo: TestInfo, nam }); } -test("retrieves latest details and reuses a validated create/edit form", async ({ page, request }, testInfo) => { +test("retrieves the latest Agent and opens its validated edit setup directly", async ({ page, request }, testInfo) => { const browserErrors: string[] = []; page.on("console", (message) => { if (message.type() === "error") browserErrors.push(message.text()); @@ -164,22 +188,23 @@ test("retrieves latest details and reuses a validated create/edit form", async ( await expect(page.getByText("Lifecycle Agent · stale list", { exact: true })).toBeVisible(); await controlFixture(request, { retrieveDelayMs: 250 }); - await page.getByRole("button", { name: /Open details for Lifecycle Agent/ }).click(); - await expect(page.getByText("Retrieving the latest saved Agent…")).toBeVisible(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); - await expect(dialog).not.toContainText("stale list"); - await expect(dialog).toContainText("agent_a"); - await expect(dialog).toContainText("Advanced configuration · read only"); - await expect(dialog).toContainText("known Core Session profile cannot start it"); - await expect(dialog.locator("input, textarea, select")).toHaveCount(0); - await attachScreenshot(page, testInfo, "desktop-light-agent-details"); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(setup).not.toContainText("stale list"); + await expect(setup).toContainText("agent_a"); + await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); + await expect(setup).toContainText("Start Session is unavailable. Current Core Session admission requires"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(setup.getByRole("button", { name: "Save changes" })).toBeInViewport(); + expect(await setup.locator(".agent-setup-actions").evaluate((element) => getComputedStyle(element).position)).toBe("sticky"); + await attachScreenshot(page, testInfo, "desktop-light-agent-edit-setup"); - await page.getByRole("button", { name: "Edit" }).click(); const name = page.getByLabel("Name"); await expect(name).toBeFocused(); - await expect(page.getByRole("dialog")).toContainText("Web-side suggestions, not a discovered Core catalog"); - await expect(page.getByRole("dialog")).toContainText("Never store secrets in Agent metadata"); + await expect(setup).toContainText("Web-side suggestions, not a discovered Core catalog"); + await expect(setup).toContainText("Never store secrets in Agent metadata"); await page.getByLabel("Metadata").fill('{"retries":3}'); await page.getByRole("button", { name: "Save changes" }).click(); @@ -201,7 +226,8 @@ test("retrieves latest details and reuses a validated create/edit form", async ( await page.getByLabel("Reasoning effort").selectOption(""); await page.getByLabel("Reasoning summary").selectOption(""); await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByRole("button", { name: "Edit" })).toBeFocused(); + await expect(page.getByRole("status")).toContainText("Agent definition updated"); + await expect(name).toBeFocused(); requests = await fixtureRequests(request); const updates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a"); @@ -212,9 +238,140 @@ test("retrieves latest details and reuses a validated create/edit form", async ( metadata: { team: "acceptance" }, reasoning: { effort: null, summary: null }, }); + expect(updates[0]?.body).not.toHaveProperty("tools"); expect(browserErrors).toEqual([]); }); +test("fails closed when the latest Agent response has a different identity", async ({ page, request }) => { + await openAgents(page, request); + await page.route("**/v1/agents/agent_a", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const upstream = await route.fetch(); + const payload = await upstream.json() as Record; + await route.fulfill({ response: upstream, json: { ...payload, id: "agent_other" } }); + }); + + const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await editTrigger.click(); + await expect(page.getByRole("alert")).toContainText("returned a different Agent than the one requested"); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(editTrigger).toBeFocused(); + + const reads = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/agent_a" + )); + expect(reads).toHaveLength(1); +}); + +test("creates, previews, edits, and removes bounded Function and anonymous HTTP MCP tools", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Tool Agent"); + await page.getByLabel("Instructions").fill("Use only the configured tools."); + await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); + await page.getByLabel("Custom model ID").fill("fixture/tool-model"); + + await page.getByRole("button", { name: "Add Function" }).click(); + const createFunction = page.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); + await createFunction.getByLabel("Name", { exact: true }).fill("lookup_customer"); + await createFunction.getByLabel("Description", { exact: true }).fill("Look up a customer."); + await createFunction.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); + + await page.getByRole("button", { name: "Add HTTP MCP" }).click(); + const createMcp = page.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); + await createMcp.getByLabel("Server label").fill("docs"); + await createMcp.getByLabel("Server URL").fill("https://mcp.example/tools"); + await createMcp.getByLabel("Only the listed tools").check(); + await createMcp.getByLabel("Allowed tools for docs").fill("search\nread_document"); + await createMcp.getByLabel("Require this server for Core execution").check(); + + const previewBody = page.getByRole("region", { name: "Request preview" }) + .locator(".agent-preview-block") + .filter({ has: page.getByText("agent.json", { exact: true }) }) + .locator("pre"); + await expect(previewBody).toContainText('"type": "function"'); + await expect(previewBody).toContainText('"name": "lookup_customer"'); + await expect(previewBody).toContainText('"type": "mcp"'); + await expect(previewBody).toContainText('"required": true'); + + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + let requests = await fixtureRequests(request); + const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + model: "fixture/tool-model", + name: "Tool Agent", + instructions: "Use only the configured tools.", + metadata: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [ + { + type: "function", + name: "lookup_customer", + description: "Look up a customer.", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }, + { + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools" }, + allowed_tools: ["search", "read_document"], + connection_origin: "service", + required: true, + }, + ], + }); + + await page.getByRole("button", { name: "Back to Agents" }).click(); + await page.getByRole("button", { name: /^Edit Tool Agent/ }).click(); + const setup = page.locator(".agent-setup-page"); + await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const editFunction = setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); + await editFunction.getByLabel("Name", { exact: true }).fill("lookup_customer_v2"); + const editMcp = setup.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); + await editMcp.getByRole("button", { name: "Remove" }).click(); + await setup.getByRole("button", { name: "Save changes" }).click(); + await expect(setup.getByRole("status")).toContainText("Agent definition updated"); + + requests = await fixtureRequests(request); + let updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); + expect(updates).toHaveLength(1); + expect(updates[0]?.body?.tools).toEqual([ + { + type: "function", + name: "lookup_customer_v2", + description: "Look up a customer.", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }, + ]); + + await setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first().getByRole("button", { name: "Remove" }).click(); + await setup.getByRole("button", { name: "Save changes" }).click(); + await expect(setup.getByRole("status")).toContainText("Agent definition updated"); + + requests = await fixtureRequests(request); + updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); + expect(updates).toHaveLength(2); + expect(updates[1]?.body?.tools).toEqual([]); +}); + +test("keeps Source Files controls out of the System status page", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await page.getByRole("button", { name: "System", exact: true }).click(); + await expect(page.getByRole("region", { name: "Source Files" })).toHaveCount(0); + await expect(page.locator(".system-page")).not.toContainText("Source Files"); +}); + test("supports global Create keyboard navigation and consumes setup requests once", async ({ page, request }) => { await openAgents(page, request); const sidebar = page.locator(".app-sidebar"); @@ -242,25 +399,29 @@ test("supports global Create keyboard navigation and consumes setup requests onc await page.getByRole("button", { name: "Sessions", exact: true }).click(); await page.getByRole("button", { name: "Agents", exact: true }).click(); - await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); - const detailTrigger = page.getByRole("button", { name: /Open details for Lifecycle Agent/ }); - await detailTrigger.focus(); - await detailTrigger.evaluate((button) => button.click()); - await expect(page.getByRole("dialog").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); - await page.keyboard.press("Escape"); + const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await editTrigger.focus(); + await editTrigger.click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(detailTrigger).toBeFocused(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(editTrigger).toBeFocused(); await expect(page.locator(".modal-backdrop")).toHaveCount(0); await createMenu.click(); await startSessionItem.click(); - const sessionDialog = page.getByRole("dialog", { name: "Start an idle Session" }); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent")).toHaveValue("agent_b"); - await expect(sessionDialog.locator('option[value="agent_a"]')).toHaveAttribute("disabled", ""); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toHaveAttribute("disabled", ""); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_a"); + await expect(sessionDialog.locator('option[value="agent_a"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_a"]')).toContainText("Lifecycle Agent"); + await expect(sessionDialog.locator('option[value="agent_a"]')).not.toContainText("Session requires changes"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toBeEnabled(); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toContainText("Saved-only Tool Agent"); + await expect(sessionDialog.locator('option[value="agent_tool_only"]')).not.toContainText("Session requires changes"); const sessionPostsBeforeCancel = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" )).length; @@ -271,7 +432,7 @@ test("supports global Create keyboard navigation and consumes setup requests onc await expect(createMenu).toBeFocused(); await page.getByRole("button", { name: "Agents", exact: true }).click(); await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await expect(page.getByRole("dialog", { name: "Start an idle Session" })).toHaveCount(0); + await expect(page.getByRole("dialog", { name: "Create a Session" })).toHaveCount(0); await page.getByRole("button", { name: "Agents", exact: true }).click(); await createMenu.click(); @@ -294,10 +455,10 @@ test("supports global Create keyboard navigation and consumes setup requests onc await page.getByRole("button", { name: "Back to Agents" }).click(); await expect(createMenu).toBeFocused(); - const ledgerCreate = page.getByRole("button", { name: "New Agent" }); - await ledgerCreate.click(); + const catalogCreate = page.getByRole("button", { name: /^Create agent/ }); + await catalogCreate.click(); await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(ledgerCreate).toBeFocused(); + await expect(catalogCreate).toBeFocused(); const requests = await fixtureRequests(request); const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); @@ -314,7 +475,7 @@ test("supports global Create keyboard navigation and consumes setup requests onc test("continues from a default Agent definition into an admitted idle Session", async ({ page, request }) => { await openAgents(page, request); - await page.getByRole("button", { name: "New Agent" }).click(); + await page.getByRole("button", { name: /^Create agent/ }).click(); await page.getByLabel("Name").fill("Session-safe Agent"); await page.getByRole("button", { name: "Save Agent definition" }).click(); await expect(page.getByRole("status")).toContainText("Agent definition saved as"); @@ -330,9 +491,11 @@ test("continues from a default Agent definition into an admitted idle Session", await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); await page.getByRole("button", { name: "Start Session" }).click(); - const sessionDialog = page.getByRole("dialog", { name: "Start an idle Session" }); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent")).toHaveValue(/^agent_created_/); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/); + await expect(sessionDialog.getByRole("button", { name: /Advanced settings/ })).toHaveAttribute("aria-expanded", "false"); + await expect(sessionDialog.getByRole("textbox", { name: /^Additional metadata\b/u })).toHaveCount(0); await expect(sessionDialog.getByRole("radio", { name: /No environment/ })).toBeChecked(); await sessionDialog.getByRole("button", { name: "Create Session" }).click(); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); @@ -353,10 +516,10 @@ test("continues from a default Agent definition into an admitted idle Session", }); }); -test("rechecks the saved response before offering the setup-page Session continuation", async ({ page, request }) => { +test("opens repair setup for a saved response while blocking an invalid Session write", async ({ page, request }) => { await openAgents(page, request); await controlFixture(request, { createAgentResponseVariant: "reasoning" }); - await page.getByRole("button", { name: "New Agent" }).click(); + await page.getByRole("button", { name: /^Create agent/ }).click(); await page.getByLabel("Name").fill("Core-adjusted Agent"); await page.getByRole("button", { name: "Save Agent definition" }).click(); @@ -365,8 +528,8 @@ test("rechecks the saved response before offering the setup-page Session continu const startSession = page.getByRole("button", { name: "Start Session" }); await expect(startSession).toBeDisabled(); - // Bypass the setup view's disabled control to prove App's final admission - // guard independently blocks the write if a caller reaches it anyway. + // Bypass the setup view's disabled control to inspect the explicit repair + // surface. The dialog remains fail-closed until the saved-only field is reset. await startSession.evaluate((button) => { const propsKey = Object.getOwnPropertyNames(button).find((key) => key.startsWith("__reactProps$")); if (!propsKey) throw new Error("React event props were not found on the Start Session button."); @@ -374,7 +537,11 @@ test("rechecks the saved response before offering the setup-page Session continu if (!props?.onClick) throw new Error("Start Session does not have an onClick handler."); props.onClick(); }); - await expect(page.getByText(/Session was not created\./)).toBeVisible(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/u); + await expect(sessionDialog.getByRole("alert")).toContainText("explicit reasoning options are saved-only"); + await expect(sessionDialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); expect((await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" ))).toHaveLength(0); @@ -382,9 +549,10 @@ test("rechecks the saved response before offering the setup-page Session continu test("starts only Agents that pass known Session admission", async ({ page, request }) => { await openAgents(page, request); - await expect(page.getByRole("columnheader", { name: "Session" })).toBeVisible(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); - const blockedStart = page.getByRole("button", { name: /Start a Session with Lifecycle Agent/ }); + const blockedStart = page.getByRole("button", { name: /^Start a Session with Lifecycle Agent/ }); await expect(blockedStart).toContainText("Unavailable"); await expect(blockedStart).toHaveAttribute("aria-disabled", "true"); await blockedStart.focus(); @@ -397,7 +565,7 @@ test("starts only Agents that pass known Session admission", async ({ page, requ entry.method === "POST" && entry.path === "/v1/agents/sessions" ))).toHaveLength(blockedSessionCount); - const toolOnlyStart = page.getByRole("button", { name: "Start a Session with Saved-only Tool Agent" }); + const toolOnlyStart = page.getByRole("button", { name: /^Start a Session with Saved-only Tool Agent/ }); await expect(toolOnlyStart).toHaveAttribute("aria-disabled", "true"); await toolOnlyStart.focus(); await expect(toolOnlyStart.locator("xpath=..").getByRole("tooltip")).toContainText("tool_search is saved-only"); @@ -405,16 +573,16 @@ test("starts only Agents that pass known Session admission", async ({ page, requ expect((await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" ))).toHaveLength(blockedSessionCount); - await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toBeEnabled(); - await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toContainText("Start Session"); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toBeEnabled(); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); const before = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" )).length; - await page.getByRole("button", { name: "Start a Session with Second Agent" }).click(); - const sessionDialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent")).toHaveValue("agent_b"); + await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); await sessionDialog.getByRole("button", { name: "Create Session" }).click(); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); @@ -429,16 +597,234 @@ test("starts only Agents that pass known Session admission", async ({ page, requ }); }); +test("serializes complete saved-Agent overrides while keeping idle creation unstreamed", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "New Session" }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_a"); + await expect(dialog.getByRole("alert")).toContainText("multi-agent execution is not supported"); + + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); + await dialog.getByRole("checkbox", { name: "Replace model" }).check(); + await dialog.getByLabel("Session model").fill("fixture/session-model"); + await dialog.getByRole("checkbox", { name: "Replace instructions" }).check(); + await dialog.getByLabel("Session instructions").fill(" "); + await dialog.getByRole("checkbox", { name: /Replace text configuration/ }).check(); + await dialog.getByLabel("Text verbosity").selectOption("low"); + await dialog.getByRole("checkbox", { name: "Reset multi-agent to disabled" }).check(); + await dialog.getByRole("checkbox", { name: "Reset reasoning to Core defaults" }).check(); + await dialog.getByRole("checkbox", { name: "Reset service tier to auto" }).check(); + await dialog.getByRole("radio", { name: "Clear all" }).check(); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "New Session" }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); + await openAdvancedSessionSettings(dialog); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(2); + expect(creates[0]?.body).toEqual({ + agent_id: "agent_a", + agent: { + model: "fixture/session-model", + instructions: null, + multi_agent: null, + reasoning: null, + service_tier: null, + text: { format: { type: "text" }, verbosity: "low" }, + tools: [], + }, + environment: { type: "none" }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(creates[1]?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + metadata: {}, + stream: false, + vault_ids: [], + }); + expect(creates[1]?.body).not.toHaveProperty("agent"); + expect(creates[1]?.body).not.toHaveProperty("input"); +}); + +test("derives manual Vault attachments for anonymous and explicit MCP Credentials", async ({ page, request }) => { + await resetFixture(request); + const serverURL = "https://mcp.vault-fixture.test/tools"; + const createVault = async (name: string) => { + const response = await request.post(`${fixtureBaseUrl}/v1/vaults`, { data: { name, metadata: {} } }); + expect(response.ok()).toBe(true); + return response.json() as Promise<{ id: string }>; + }; + const createCredential = async (vaultId: string, name: string) => { + const response = await request.post(`${fixtureBaseUrl}/v1/vaults/${vaultId}/credentials`, { data: { + name, + auth: { type: "static_bearer", mcp_server_url: serverURL, token: "fixture-secret-never-rendered" }, + } }); + expect(response.ok()).toBe(true); + return response.json() as Promise<{ id: string }>; + }; + const vaultAlpha = await createVault("Vault Alpha"); + const vaultBeta = await createVault("Vault Beta"); + const credentialAlpha = await createCredential(vaultAlpha.id, "Credential Alpha"); + await createCredential(vaultBeta.id, "Credential Beta"); + const mcpTool = (credentialId: string | null) => ({ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: serverURL, headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: credentialId, + request_metadata: {}, + required: false, + }); + for (const [name, credentialId] of [ + ["Anonymous MCP Agent", null], + ["Explicit MCP Agent", credentialAlpha.id], + ] as const) { + const response = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp", + name, + tools: [mcpTool(credentialId)], + } }); + expect(response.status()).toBe(201); + } + + await page.goto("/"); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await expect(dialog).toContainText("Anonymous for this Session"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); + await expect(dialog).toContainText("Implicit unique match · Credential Alpha · Vault Alpha"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); + await dialog.getByRole("checkbox", { name: "Vault Beta" }).check(); + await expect(dialog.getByRole("alert")).toContainText("matches multiple Credentials"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + const requiredVault = dialog.getByRole("checkbox", { name: /Vault Alpha · attached automatically/ }); + await expect(requiredVault).toBeChecked(); + await expect(requiredVault).toBeDisabled(); + await expect(dialog).toContainText("Explicit · Credential Alpha · Vault Alpha"); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + + let creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(3); + expect(creates.map((entry) => entry.body?.vault_ids)).toEqual([ + [], + [vaultAlpha.id], + [vaultAlpha.id], + ]); + const createsBeforeCredentialDelete = creates.length; + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + const deleted = await request.delete(`${fixtureBaseUrl}/v1/vaults/${vaultAlpha.id}/credentials/${credentialAlpha.id}`); + expect(deleted.ok()).toBe(true); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog.getByRole("alert")).toContainText("explicit MCP Credential is unavailable"); + creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates.length - createsBeforeCredentialDelete).toBeLessThanOrEqual(1); +}); + +test("clears manual Vault attachments when overrides remove HTTP MCP tools", async ({ page, request }) => { + await resetFixture(request); + const serverURL = "https://mcp.vault-clear.test/tools"; + const vaultResponse = await request.post(`${fixtureBaseUrl}/v1/vaults`, { + data: { name: "Vault to clear", metadata: {} }, + }); + expect(vaultResponse.ok()).toBe(true); + const vault = await vaultResponse.json() as { id: string }; + const agentResponse = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp-clear", + name: "MCP Clear Agent", + tools: [{ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: serverURL, headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }], + } }); + expect(agentResponse.status()).toBe(201); + + await page.goto("/"); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await dialog.getByRole("checkbox", { name: "Vault to clear" }).check(); + await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); + await dialog.getByRole("radio", { name: "Clear all" }).check(); + + await expect(dialog.getByRole("heading", { name: "Tools & Vaults" })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toMatchObject({ + agent: { tools: [] }, + vault_ids: [], + }); + expect(creates[0]?.body?.vault_ids).not.toContain(vault.id); +}); + test("creates the bounded self-hosted profile and renders a secret-free connection guide", async ({ page, request }) => { await openAgents(page, request); const sessionPostsBefore = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" )).length; - await page.getByRole("button", { name: "Start a Session with Second Agent" }).click(); - const dialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); await expect(dialog).toBeVisible(); - await expect(dialog.getByLabel("Saved Agent")).toHaveValue("agent_b"); + await expect(dialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); await dialog.getByRole("radio", { name: /Self-hosted/ }).check(); const workspace = dialog.getByLabel("Workspace directory"); await workspace.fill("relative/workspace"); @@ -459,6 +845,9 @@ test("creates the bounded self-hosted profile and renders a secret-free connecti await expect(environmentPanel).toContainText("https://executor.example.test"); await expect(environmentPanel).toContainText("/executor/workspace"); await expect(environmentPanel).toContainText("Pending"); + const files = environmentPanel.getByRole("region", { name: "Workspace files" }); + await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); + await expect(files).toContainText("Workspace root"); await expect(environmentPanel.locator("details.environment-launcher-guide")).toHaveAttribute("open", ""); await expect(environmentPanel.getByRole("button", { name: "Copy native command" })).toBeVisible(); @@ -482,57 +871,333 @@ test("creates the bounded self-hosted profile and renders a secret-free connecti workspace_directory: "/executor/workspace", capability_directories: [], }, + metadata: {}, stream: false, + vault_ids: [], }); expect(requests.filter((entry) => entry.method === "POST" && entry.path.endsWith("/events"))).toHaveLength(0); }); -test("keeps the New Session reason keyboard-accessible when every loaded Agent is incompatible", async ({ page, request }) => { +test("creates managed hosted default, enabled and disabled profiles through POST SSE", async ({ page, request }) => { await openAgents(page, request); - const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); - expect(deleted.ok()).toBe(true); - await page.getByRole("button", { name: "Refresh Agents" }).click(); - await expect(page.getByRole("button", { name: "Open details for Second Agent" })).toHaveCount(0); + const profiles = [ + { label: "default", option: "default", environment: { type: "openai_hosted" }, input: undefined }, + { label: "enabled", option: "enabled", environment: { type: "openai_hosted", network: { access: "enabled" } }, input: "Managed initial input" }, + { label: "disabled", option: "disabled", environment: { type: "openai_hosted", network: { access: "disabled" } }, input: undefined }, + ] as const; + + for (const [index, profile] of profiles.entries()) { + if (index > 0) await page.getByRole("button", { name: "Agents", exact: true }).click(); + if (index === 0) await controlFixture(request, { environmentEventStatus: 3, environmentEventCount: 1 }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + const network = dialog.getByLabel("Managed Environment network access"); + await network.selectOption(profile.option); + if (profile.input) await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(profile.input); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - const newSession = page.getByRole("button", { name: "New Session" }); - await expect(newSession).toHaveAttribute("aria-disabled", "true"); - await newSession.focus(); - await expect(newSession.locator("xpath=..").getByRole("tooltip")).toContainText("No loaded Agent matches"); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + const latest = creates.at(-1)?.body; + expect(latest).toMatchObject({ + agent_id: "agent_b", + environment: profile.environment, + stream: true, + vault_ids: [], + }); + expect(latest?.environment).toEqual(profile.environment); + if (profile.input) expect(latest?.input).toBe(profile.input); + else expect(latest).not.toHaveProperty("input"); + + if (index === 0) { + const { dialog: environmentDialog, panel } = await openEnvironmentDialog(page); + await expect(panel).toContainText("Managed hosted Environment"); + await expect(panel).toContainText("7a263c51-6bf0-4d53-8518-c792eb1f0d21"); + await expect(panel).toContainText("Network access"); + await expect(panel).toContainText("Enabled"); + await expect(panel).toContainText("/workspace"); + await expect(panel).toContainText("None installed by the basic profile"); + await expect(panel).toContainText("Workspace files"); + await expect(panel.getByText("Add inline Workspace file", { exact: true })).toBeVisible(); + await expect(panel).not.toContainText("Connect Environment"); + await expect(panel).not.toContainText("Remote URL"); + + await panel.getByLabel("Local file").setInputFiles({ + name: "managed.txt", + mimeType: "text/plain", + buffer: Buffer.from("managed bytes", "utf8"), + }); + await panel.getByLabel("Destination path").fill("/workspace/managed.txt"); + await panel.getByRole("button", { name: "Write selected file" }).click(); + await expect(panel).toContainText("Write confirmed:"); + await expect(panel).toContainText("/workspace/managed.txt"); + let writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + )); + expect(writes.at(-1)?.body).toEqual({ + type: "inline", + data: Buffer.from("managed bytes", "utf8").toString("base64"), + path: "/workspace/managed.txt", + }); + + await panel.getByLabel("Local file").setInputFiles({ + name: "unknown.txt", + mimeType: "text/plain", + buffer: Buffer.from("uncertain bytes", "utf8"), + }); + await panel.getByLabel("Destination path").fill("/workspace/unknown.txt"); + await controlFixture(request, { environmentFileCreateResponseLoss: 1 }); + const writesBeforeUnknown = writes.length; + await panel.getByRole("button", { name: "Write selected file" }).click(); + await expect(panel.getByRole("alert")).toContainText("Web did not retry the request"); + writes = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + )); + expect(writes).toHaveLength(writesBeforeUnknown + 1); + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") + ))).toHaveLength(writesBeforeUnknown + 1); + await environmentDialog.getByRole("button", { name: "Done" }).click(); + } + } + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); +}); + +test("blocks hosted MCP before persistence while allowing a Function-only managed Session", async ({ page, request }) => { + await resetFixture(request); + const mcp = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-mcp", + name: "Hosted MCP Agent", + tools: [{ + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools", headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }], + } }); + expect(mcp.status()).toBe(201); + const fn = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { + model: "fixture/model-function", + name: "Hosted Function Agent", + tools: [{ type: "function", name: "lookup", description: "", parameters: {}, defer_loading: false }], + } }); + expect(fn.status()).toBe(201); + + await page.goto("/"); + await page.getByRole("button", { name: "Agents", exact: true }).click(); const before = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" )).length; - await page.keyboard.press("Enter"); - await expect(page.getByRole("dialog", { name: "Start an idle Session" })).toHaveCount(0); + await page.getByRole("button", { name: /^Start a Session with Hosted MCP Agent/ }).click(); + let dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(dialog.getByRole("alert")).toContainText("Managed hosted Sessions do not yet support MCP tools"); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); expect((await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" ))).toHaveLength(before); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Start a Session with Hosted Function Agent/ }).click(); + dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); + await dialog.getByRole("button", { name: "Create Session" }).click(); + await expect(dialog).toHaveCount(0); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(before + 1); + expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: true }); +}); + +test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 8, environmentResourceStatus: "expired" }); + await page.goto("/"); + let trigger = environmentTrigger(page); + await expect(trigger).toContainText("Managed Environment expired"); + let opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment expired"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + await opened.dialog.getByRole("button", { name: "Done" }).click(); + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await controlFixture(request, { environmentResourceStatus: "pending", environmentResourceVariant: "wrong_type" }); + await page.getByRole("button", { name: "Recover durable state" }).click(); + trigger = environmentTrigger(page); + await expect(trigger).toContainText("Managed Environment unavailable"); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Durable managed Environment status is unavailable"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await opened.dialog.getByRole("button", { name: "Done" }).click(); + + await controlFixture(request, { + environmentResourceVariant: "valid", + environmentEventStatus: 5, + environmentEventCount: 1, + }); + await page.reload(); + await expect(environmentTrigger(page)).toContainText("Managed Environment failed"); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + + await opened.dialog.getByRole("button", { name: "Done" }).click(); + await controlFixture(request, { + environmentScenario: 9, + environmentResourceStatus: "failed", + environmentResourceVariant: "valid", + environmentEventCount: 0, + }); + await page.reload(); + await expect(page.getByText("Session failed", { exact: true })).toBeVisible(); + await expect(page.getByText("The environment is no longer available for this input.", { exact: true })).toBeVisible(); + opened = await openEnvironmentDialog(page); + await expect(opened.panel).toContainText("Managed Environment failed"); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); + + await opened.dialog.getByRole("button", { name: "Done" }).click(); + await controlFixture(request, { + environmentScenario: 8, + environmentResourceStatus: "pending", + environmentResourceVariant: "populated", + }); + await page.reload(); + opened = await openEnvironmentDialog(page); + await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); + await expect(opened.panel).not.toContainText("Connect Environment"); +}); + +test("creates an inline Session without saved Agents and preserves ordered user-message input", async ({ page, request }) => { + await openAgents(page, request); + for (const agentId of ["agent_a", "agent_b", "agent_tool_only"]) { + const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/${agentId}`); + expect(deleted.ok()).toBe(true); + } + await page.getByRole("button", { name: "Refresh Agents" }).click(); + await expect(page.getByRole("heading", { name: "No saved Agents" })).toBeVisible(); + + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + const newSession = page.getByRole("button", { name: "New Session" }); + await expect(newSession).toBeEnabled(); + await newSession.click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(dialog); + await expect(dialog.getByRole("radio", { name: /Inline Agent/ })).toBeChecked(); + await expect(dialog.getByRole("radio", { name: /Saved Agent/ })).toBeDisabled(); + await dialog.getByLabel("Inline model").fill("fixture/inline-model"); + await dialog.getByLabel("Inline instructions").fill("Keep exact input structure."); + const tools = dialog.getByRole("region", { name: "Inline tools" }); + await tools.getByRole("button", { name: "Add Function" }).click(); + await tools.getByLabel("Name", { exact: true }).fill("lookup_fixture"); + await tools.getByLabel("Description", { exact: true }).fill("Look up fixture data"); + await tools.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); + + await dialog.getByRole("radio", { name: "Message array" }).check(); + await expect(dialog.getByRole("alert").filter({ hasText: "User message 1 needs nonblank text across its parts." })).toHaveCount(1); + const advancedToggle = dialog.getByRole("button", { name: /Advanced settings/ }); + await advancedToggle.click(); + await expect(dialog.getByRole("button", { name: "Edit messages", exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "Edit messages", exact: true }).click(); + await expect(advancedToggle).toHaveAttribute("aria-expanded", "true"); + let messages = dialog.locator(".session-initial-message"); + await messages.nth(0).locator(".session-initial-part textarea").nth(0).fill("first-message-part-one"); + await dialog.getByRole("button", { name: "Add text part" }).click(); + await messages.nth(0).locator(".session-initial-part textarea").nth(1).fill("first-message-part-two"); + await dialog.getByRole("button", { name: "Add message" }).click(); + messages = dialog.locator(".session-initial-message"); + await messages.nth(1).locator(".session-initial-part textarea").fill("second-message"); + await dialog.getByRole("button", { name: "Move user message 2 up" }).click(); + await dialog.getByRole("button", { name: "Move text part 2 of user message 2 up" }).click(); + await expect(dialog).not.toContainText("Creation events stream automatically"); + await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + await expect(dialog).toHaveCount(0); + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByText("second-message", { exact: true })).toBeVisible(); + await expect(conversation).toContainText("first-message-part-two"); + await expect(conversation).toContainText("first-message-part-one"); + const creates = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(creates).toHaveLength(1); + expect(creates[0]?.body).toEqual({ + agent: { + model: "fixture/inline-model", + instructions: "Keep exact input structure.", + tools: [{ + type: "function", + name: "lookup_fixture", + description: "Look up fixture data", + parameters: { type: "object", properties: { id: { type: "string" } } }, + defer_loading: false, + }], + }, + environment: { type: "none" }, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "second-message" }] }, + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "first-message-part-two" }, + { type: "input_text", text: "first-message-part-one" }, + ], + }, + ], + metadata: {}, + stream: true, + vault_ids: [], + }); + expect(creates[0]?.body).not.toHaveProperty("agent_id"); }); test("keeps failures visible, rejects stale async continuations, and never retries writes", async ({ page, request }) => { await openAgents(page, request); await controlFixture(request, { retrieveStatus: 500 }); - await page.getByRole("button", { name: /Open details for Lifecycle Agent/ }).click(); - let dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("heading", { name: "Lifecycle Agent · stale list", exact: true })).toBeVisible(); - await expect(dialog.getByRole("alert")).toContainText("Fixture retrieve failed."); - await expect(dialog.getByRole("button", { name: "Edit" })).toBeDisabled(); - await dialog.getByRole("button", { name: "Retry latest Agent" }).click(); - await expect(dialog.getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); - await expect(dialog).not.toContainText("stale list"); - await page.keyboard.press("Escape"); + const failedEditTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); + await failedEditTrigger.click(); + const openError = page.getByRole("alert"); + await expect(openError).toContainText("Fixture retrieve failed."); + await expect(failedEditTrigger).toBeEnabled(); + await expect(failedEditTrigger).toBeFocused(); + await openError.getByRole("button", { name: "Retry" }).click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); + await expect(page.locator(".agent-setup-page")).not.toContainText("stale list"); + await page.getByRole("button", { name: "Back to Agents" }).click(); await controlFixture(request, { retrieveDelayMs: 400 }); - await page.getByRole("button", { name: /Open details for Lifecycle Agent/ }).click(); - await expect(page.getByRole("dialog")).toBeVisible(); - await expect(page.getByText("Retrieving the latest saved Agent…")).toBeVisible(); - await page.keyboard.press("Escape"); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); await page.waitForTimeout(500); - await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); - await page.getByRole("button", { name: /Open details for Lifecycle Agent/ }).click(); - await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await expect(page.locator(".agent-setup-page")).toBeVisible(); await page.getByLabel("Name").fill("Preserved after failure"); await controlFixture(request, { updateStatus: 500 }); await page.getByRole("button", { name: "Save changes" }).click(); @@ -544,12 +1209,12 @@ test("keeps failures visible, rejects stale async continuations, and never retri await controlFixture(request, { updateDelayMs: 600 }); await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Back to Agents" })).toBeDisabled(); await page.waitForTimeout(100); - await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); await page.waitForTimeout(700); - await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.locator(".agent-setup-page")).toHaveCount(0); requests = await fixtureRequests(request); expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); @@ -557,29 +1222,33 @@ test("keeps failures visible, rejects stale async continuations, and never retri test("requires delete confirmation, preserves failures, and keeps Session snapshots", async ({ page, request }) => { await openAgents(page, request); - await page.getByRole("button", { name: /Open details for Lifecycle Agent/ }).click(); - await page.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByRole("dialog")).toContainText("Existing Sessions keep their durable Agent snapshots"); - await page.getByRole("button", { name: "Cancel" }).click(); - await expect(page.getByRole("button", { name: "Edit" })).toBeFocused(); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + await page.getByRole("button", { name: "Delete Agent" }).click(); + let dialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await expect(dialog).toContainText("Existing Sessions keep their durable Agent snapshots"); + await expect(dialog).toContainText("agent_a"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("button", { name: "Delete Agent" })).toBeFocused(); let requests = await fixtureRequests(request); expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); - await page.getByRole("button", { name: "Delete" }).click(); - await controlFixture(request, { deleteStatus: 500 }); await page.getByRole("button", { name: "Delete Agent" }).click(); - await expect(page.getByRole("alert")).toContainText("Fixture delete failed."); - await expect(page.getByRole("dialog")).toContainText("Lifecycle Agent"); + dialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await controlFixture(request, { deleteStatus: 500 }); + await dialog.getByRole("button", { name: "Delete Agent" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Fixture delete failed."); + await expect(dialog).toContainText("Lifecycle Agent"); requests = await fixtureRequests(request); expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); await controlFixture(request, { deleteDelayMs: 300 }); - await page.getByRole("button", { name: "Delete Agent" }).click(); - await expect(page.getByRole("button", { name: "Cancel" })).toBeDisabled(); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByRole("button", { name: /Open details for Lifecycle Agent/ })).toHaveCount(0); + await dialog.getByRole("button", { name: "Delete Agent" }).click(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toHaveCount(0); await page.getByRole("button", { name: "Sessions" }).click(); await expect(page.getByRole("button", { name: /idle Lifecycle Agent/ })).toBeVisible(); @@ -587,22 +1256,40 @@ test("requires delete confirmation, preserves failures, and keeps Session snapsh expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); }); -test("keeps the Agent ledger and dialogs usable at 390 px in light and dark modes", async ({ page, request }, testInfo) => { +test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px", async ({ page, request }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await openAgents(page, request); + const agentList = page.getByRole("list", { name: "Agents", exact: true }); + await expect(agentList.getByRole("listitem")).toHaveCount(2); + await expect(agentList.getByRole("listitem").first()).toContainText("Create agent"); + await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Edit Second Agent/ })).toHaveCount(0); + const showMore = page.getByRole("button", { name: "Show 2 more" }); + await expect(showMore).toHaveAttribute("aria-expanded", "false"); + await showMore.click(); + await expect(page.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true"); + await expect(agentList.getByRole("listitem")).toHaveCount(4); + const secondAgent = page.getByRole("button", { name: /^Edit Second Agent/ }); + await expect(secondAgent).toBeVisible(); + await secondAgent.click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Second Agent", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Back to Agents" }).click(); + await expect(secondAgent).toBeFocused(); + await expect(page.getByRole("button", { name: "Show less" })).toBeVisible(); + const metrics = await page.evaluate(() => { const main = document.querySelector(".app-main")?.getBoundingClientRect(); - const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("New Agent"))?.getBoundingClientRect(); - const ledger = document.querySelector(".agent-ledger")?.getBoundingClientRect(); - const sessionTrigger = document.querySelector('button[aria-label="Start a Session with Second Agent"]')?.getBoundingClientRect(); + const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Create agent"))?.getBoundingClientRect(); + const grid = document.querySelector(".agent-card-grid")?.getBoundingClientRect(); + const sessionTrigger = document.querySelector('button[aria-label^="Start a Session with Second Agent ("]')?.getBoundingClientRect(); return { innerWidth, documentScrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, main: main && { left: main.left, right: main.right, width: main.width }, trigger: trigger && { left: trigger.left, right: trigger.right, width: trigger.width }, - ledger: ledger && { left: ledger.left, right: ledger.right, width: ledger.width }, + grid: grid && { left: grid.left, right: grid.right, width: grid.width }, sessionTrigger: sessionTrigger && { left: sessionTrigger.left, right: sessionTrigger.right, width: sessionTrigger.width }, }; }); @@ -612,12 +1299,12 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode expect(metrics.main?.right).toBeLessThanOrEqual(390); expect(metrics.trigger?.left).toBeGreaterThanOrEqual(0); expect(metrics.trigger?.right).toBeLessThanOrEqual(390); - expect(metrics.ledger?.left).toBeGreaterThanOrEqual(0); - expect(metrics.ledger?.right).toBeLessThanOrEqual(390); - expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.ledger?.left ?? 0); - expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.ledger?.right ?? 390); - await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toContainText("Start Session"); - await attachScreenshot(page, testInfo, "narrow-light-agent-ledger"); + expect(metrics.grid?.left).toBeGreaterThanOrEqual(0); + expect(metrics.grid?.right).toBeLessThanOrEqual(390); + expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.grid?.left ?? 0); + expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.grid?.right ?? 390); + await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); + await attachScreenshot(page, testInfo, "narrow-light-agent-card-grid"); await expect(page.getByRole("button", { name: "Environments", exact: true })).toHaveCount(0); const sessionsNavigation = page.getByRole("button", { name: "Sessions", exact: true }); @@ -649,40 +1336,50 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode await expect(page.getByRole("button", { name: "Save Agent definition" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); await expect(page.getByLabel("Text format")).toHaveValue("Text"); + await expect(page.getByLabel("Reasoning effort")).toBeDisabled(); + await expect(setup).toContainText("Creation is locked to the current Session-compatible profile"); await attachScreenshot(page, testInfo, "narrow-light-agent-setup"); await page.getByRole("button", { name: "Back to Agents" }).click(); await expect(globalCreate).toBeFocused(); await page.getByRole("button", { name: "Dark theme" }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - await page.getByRole("button", { name: /Open details for Lifecycle Agent/ }).click(); - const detailDialog = page.getByRole("dialog"); - await expect(detailDialog).toBeVisible(); - const deleteAction = detailDialog.getByRole("button", { name: "Delete", exact: true }); - const editAction = detailDialog.getByRole("button", { name: "Edit", exact: true }); - await expect(deleteAction).toBeInViewport(); - await expect(editAction).toBeInViewport(); - const detailLayout = await detailDialog.evaluate((card) => { - const body = card.querySelector(".modal-body"); - const footer = card.querySelector(".modal-footer"); - const cardBox = card.getBoundingClientRect(); - const footerBox = footer?.getBoundingClientRect(); + await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); + const editSetup = page.locator(".agent-setup-page"); + await expect(editSetup).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const editLayout = await editSetup.evaluate((element) => { + const box = element.getBoundingClientRect(); return { - bodyClientHeight: body?.clientHeight ?? 0, - bodyScrollHeight: body?.scrollHeight ?? 0, - cardBottom: cardBox.bottom, - footerBottom: footerBox?.bottom ?? Number.POSITIVE_INFINITY, + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + left: box.left, + right: box.right, }; }); - expect(detailLayout.bodyScrollHeight).toBeGreaterThan(detailLayout.bodyClientHeight); - expect(detailLayout.footerBottom).toBeLessThanOrEqual(detailLayout.cardBottom); - expect(detailLayout.cardBottom).toBeLessThanOrEqual(844); - await attachScreenshot(page, testInfo, "narrow-dark-agent-details"); - await detailDialog.locator(".modal-body").evaluate((body) => { - body.scrollTop = body.scrollHeight; - }); + expect(editLayout.documentScrollWidth).toBeLessThanOrEqual(390); + expect(editLayout.bodyScrollWidth).toBeLessThanOrEqual(390); + expect(editLayout.left).toBeGreaterThanOrEqual(0); + expect(editLayout.right).toBeLessThanOrEqual(390.5); + await attachScreenshot(page, testInfo, "narrow-dark-agent-edit-setup"); + + const deleteAction = editSetup.getByRole("button", { name: "Delete Agent" }); + await deleteAction.scrollIntoViewIfNeeded(); await expect(deleteAction).toBeInViewport(); - await expect(editAction).toBeInViewport(); + await deleteAction.click(); + const deleteDialog = page.getByRole("dialog", { name: "Delete Agent?" }); + await expect(deleteDialog).toBeVisible(); + await expect(deleteDialog).toContainText("agent_a"); + await expect(deleteDialog.getByRole("button", { name: "Cancel" })).toBeInViewport(); + await expect(deleteDialog.getByRole("button", { name: "Delete Agent" })).toBeInViewport(); + const dialogBox = await deleteDialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((dialogBox?.x ?? 0) + (dialogBox?.width ?? 0)).toBeLessThanOrEqual(390.5); + await attachScreenshot(page, testInfo, "narrow-dark-agent-delete-confirmation"); + await deleteDialog.getByRole("button", { name: "Cancel" }).click(); + await expect(editSetup).toBeVisible(); + await expect(editSetup.getByRole("button", { name: "Delete Agent" })).toBeFocused(); }); test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { @@ -701,10 +1398,194 @@ test("starts one Session with an idempotency key and without browser authorizati } }); +test("filters every Session page by Agent and aborts a stale filter read", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { sessionListPageSize: 1 }); + + await page.goto("/"); + const filter = page.getByLabel("Filter Sessions by Agent"); + await expect(filter).toBeVisible(); + await expect(page.locator(".session-row")).toHaveCount(1); + + // These Sessions are deliberately created after the global snapshot loads. + // The Agent-scoped list must be able to select them without inflating Dashboard totals. + await createFixtureSession(request, "first"); + await createFixtureSession(request, "second"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".session-row")).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const filteredReads = (await fixtureRequests(request)).filter((entry) => { + if (entry.method !== "GET" || entry.path !== "/v1/agents/sessions") return false; + return new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b"; + }); + expect(filteredReads).toHaveLength(2); + expect(filteredReads.every((entry) => ( + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b" + ))).toBe(true); + expect(filteredReads.some((entry) => ( + new URLSearchParams(entry.query ?? "").has("after") + ))).toBe(true); + + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Sessions" })).toContainText("1"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await expect(filter).toHaveValue("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + const before = await fixtureState(request); + const agentAReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && + entry.path === "/v1/agents/sessions" && + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" + )).length; + await controlFixture(request, { sessionListDelayMs: 500 }); + await filter.selectOption("agent_a"); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && + entry.path === "/v1/agents/sessions" && + new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" + )).length).toBeGreaterThan(agentAReadsBefore); + + await filter.selectOption("agent_b"); + await expect.poll(async () => (await fixtureState(request)).aborts.sessionListReads) + .toBeGreaterThan(before.aborts.sessionListReads); + await expect(page.locator(".session-row")).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + await page.waitForTimeout(550); + await expect(filter).toHaveValue("agent_b"); + await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + + await filter.selectOption(""); + await expect(filter).toHaveValue(""); + await expect(page.locator(".session-row")).toHaveCount(1); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const lifecycleRow = page.getByRole("table", { name: "Recent Sessions" }) + .getByRole("row") + .filter({ hasText: "Lifecycle Agent" }); + await lifecycleRow.getByRole("button").click(); + await expect(filter).toHaveValue(""); + await expect(page.locator(".session-row")).toHaveCount(1); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); +}); + +test("fences the filtered workspace across loading, errors, unavailable Agents, and deletes", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + await createFixtureSession(request, "delete-first"); + await createFixtureSession(request, "delete-second"); + + const filter = page.getByLabel("Filter Sessions by Agent"); + await controlFixture(request, { sessionListDelayMs: 500 }); + await filter.selectOption("agent_b"); + await expect(page.getByLabel("Loading Session workspace")).toBeVisible(); + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + await controlFixture(request, { sessionListStatus: 503 }); + await filter.selectOption("agent_a"); + await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await page.locator(".workspace-error").getByRole("button", { name: "Retry" }).click(); + await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); + + await filter.selectOption("agent_b"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + const deletedAgent = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); + expect(deletedAgent.ok()).toBe(true); + await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(filter).toHaveValue("agent_b"); + await expect(filter.locator("option:checked")).toHaveText("Unavailable Agent (not loaded)"); + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + + for (const remainingRows of [1, 0]) { + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await dialog.getByRole("button", { name: "Delete Session" }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".session-row")).toHaveCount(remainingRows); + await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); + if (remainingRows) { + await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); + } + } + await expect(page.locator(".conversation-panel")).toHaveCount(0); + await expect(page.locator(".workspace-empty")).toContainText("Select or create a Session"); +}); + +test("streams initial Session creation, captures early events, then hands off to one GET stream", async ({ page, request }) => { + await openAgents(page, request); + await controlFixture(request, { sessionCreateStreamCloseDelayMs: 1_500 }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByRole("textbox", { name: /^Title\b/u }).fill("Streamed creation"); + const exactInput = " Initial streamed input \n"; + await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(exactInput); + await dialog.getByRole("button", { name: "Create Session" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.locator(".conversation-header h2")).toHaveText("Streamed creation"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByText("Initial streamed input", { exact: true }), + ).toBeVisible(); + + await expect.poll(async () => (await fixtureState(request)).sessions[0]?.id).toMatch(/^session_created_/u); + const createdSessionId = (await fixtureState(request)).sessions[0]?.id; + if (!createdSessionId) throw new Error("Fixture did not retain the streamed Session."); + const createRequests = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions" + )); + expect(createRequests).toHaveLength(1); + expect(createRequests[0]?.body).toMatchObject({ + agent_id: "agent_b", + environment: { type: "none" }, + input: exactInput, + metadata: { title: "Streamed creation" }, + stream: true, + vault_ids: [], + }); + + await page.waitForTimeout(250); + expect((await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + ))).toHaveLength(0); + + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + )).length).toBe(1); + const handoffRequests = await fixtureRequests(request); + const getStreamIndex = handoffRequests.findIndex((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` + )); + expect(getStreamIndex).toBeGreaterThan(-1); + expect(handoffRequests.slice(0, getStreamIndex).filter((entry) => ( + entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}` + )).length).toBeGreaterThanOrEqual(2); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect( + page.getByRole("tabpanel", { name: "Conversation" }) + .getByText("Initial streamed input", { exact: true }), + ).toBeVisible(); +}); + test("keeps one Session create attempt across response loss and an unchanged manual retry", async ({ page, request }) => { await openAgents(page, request); - await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Start an idle Session" }); + await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); const create = dialog.getByRole("button", { name: "Create Session" }); await controlFixture(request, { sessionCreateDelayMs: 1_500, sessionCreateResponseLoss: 1 }); @@ -713,7 +1594,7 @@ test("keeps one Session create attempt across response loss and an unchanged man button.click(); }); await expect(dialog.getByRole("alert")).toContainText("Agent core request failed (502)."); - await expect(dialog.getByText("Retrying this unchanged form reuses the original idempotency key.")).toBeVisible(); + await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible(); let creates = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" @@ -900,7 +1781,7 @@ test("rejects wrong-id and deep-malformed Session reads before writes or delete await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); await page.locator(".conversation-session-action").click(); const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("alert")).toContainText("invalid Session retrieval response"); + await expect(dialog.getByRole("alert")).toContainText("invalid Session resource"); await expect(dialog).toContainText("session_snapshot"); await expect(dialog).not.toContainText("another_session"); @@ -1177,9 +2058,15 @@ test("deletes the selected Session while its SSE is still connecting", async ({ await expect(connectedLiveEvents(page)).toBeVisible(); await controlFixture(request, { streamOpenDelayMs: 3_000 }); + const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length; await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); await expect(page.getByText("Connecting events…", { exact: true })).toBeVisible(); + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length).toBeGreaterThan(streamReadsBefore); const before = await fixtureState(request); await page.locator(".conversation-session-action").click(); @@ -1232,6 +2119,220 @@ test("keeps Session actions accessible and contained at 390 px in dark mode", as await expect(manage).toBeFocused(); }); +test("presents Dashboard page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { + await resetFixture(request); + await page.goto("/"); + await expect(connectedLiveEvents(page)).toBeVisible(); + await expect(page.getByText("Session is ready", { exact: true })).toBeVisible(); + const initialDetailPaths = [ + "/v1/agents/sessions/session_snapshot", + "/v1/agents/sessions/session_snapshot/items", + "/v1/agents/sessions/session_snapshot/turns", + ]; + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return initialDetailPaths.every((path) => entries.filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length >= 2); + }).toBe(true); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.getByRole("heading", { name: /Dashboard/ })).toBeVisible(); + await expect(dashboard).toContainText("last successfully traversed Agent and Session page-chain results"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Sessions" })).toContainText("1"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Reported aggregate tokens" })).toContainText("Unknown"); + await expect( + dashboard.getByRole("list", { name: "Loaded Session status counts" }).getByRole("listitem").filter({ hasText: "Idle" }), + ).toContainText("1"); + await expect(dashboard).toContainText("No Sessions in the loaded result require attention."); + + const before = await fixtureRequests(request); + const count = (entries: FixtureRequest[], path: string) => entries.filter((entry) => ( + entry.method === "GET" && entry.path === path + )).length; + const detailPaths = [ + "/v1/agents/sessions/session_snapshot", + "/v1/agents/sessions/session_snapshot/items", + "/v1/agents/sessions/session_snapshot/turns", + ]; + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await refresh.click(); + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; + }).toEqual([count(before, "/v1/agents") + 1, count(before, "/v1/agents/sessions") + 1]); + const after = await fixtureRequests(request); + expect(count(after, "/v1/agents")).toBe(count(before, "/v1/agents") + 1); + expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 1); + for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); + await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); + + await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); + await expect(page.locator(".session-page")).toBeVisible(); + await expect(page.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); + + await page.getByRole("button", { name: "System", exact: true }).click(); + const system = page.locator(".system-page"); + await expect(system.getByRole("listitem").filter({ hasText: "Core API" })).toContainText("Available"); + await expect(system.getByRole("listitem").filter({ hasText: "Vaults" })).toContainText("Available"); + await expect(system.getByRole("listitem").filter({ hasText: "Self-hosted" })).toContainText("Enabled"); + await expect(system.getByRole("listitem").filter({ hasText: "Runtime status" })).toContainText("Cannot be pre-checked"); + await expect(system.getByRole("listitem")).toHaveCount(4); + await expect(system).not.toContainText("Source Files"); + await expect(system).not.toContainText("Public capability surface"); + + const beforeSystemRefresh = await fixtureRequests(request); + const systemRefresh = system.getByRole("button", { name: "Refresh System status" }); + await systemRefresh.click(); + await expect.poll(async () => { + const entries = await fixtureRequests(request); + return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; + }).toEqual([ + count(beforeSystemRefresh, "/v1/agents") + 1, + count(beforeSystemRefresh, "/v1/agents/sessions") + 1, + ]); + const afterSystemRefresh = await fixtureRequests(request); + expect(count(afterSystemRefresh, "/v1/agents")).toBe(count(beforeSystemRefresh, "/v1/agents") + 1); + expect(count(afterSystemRefresh, "/v1/agents/sessions")).toBe(count(beforeSystemRefresh, "/v1/agents/sessions") + 1); + for (const path of detailPaths) expect(count(afterSystemRefresh, path)).toBe(count(beforeSystemRefresh, path)); + await attachScreenshot(page, testInfo, "desktop-system-contract-boundary"); + + await page.setViewportSize({ width: 390, height: 844 }); + const systemBounds = await system.evaluate((element) => { + const rows = [...element.querySelectorAll(".system-summary-cell")].map((row) => row.getBoundingClientRect()); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + rowBounds: rows.map((row) => ({ top: row.top, bottom: row.bottom, height: row.height })), + }; + }); + expect(systemBounds.documentWidth).toBeLessThanOrEqual(systemBounds.viewportWidth); + for (let index = 1; index < systemBounds.rowBounds.length; index += 1) { + expect(systemBounds.rowBounds[index]!.top).toBeGreaterThanOrEqual(systemBounds.rowBounds[index - 1]!.bottom); + } + expect(systemBounds.rowBounds.every((row) => row.height >= 36)).toBe(true); + await attachScreenshot(page, testInfo, "narrow-system-contract-boundary"); +}); + +test("publishes Dashboard counts only after every top-level Agent and Session page loads", async ({ page, request }) => { + await resetFixture(request); + const agentAfters: Array = []; + const sessionAfters: Array = []; + let sessionTemplate: Record | null = null; + + await page.route("**/v1/agents**", async (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET" || !["/v1/agents", "/v1/agents/sessions"].includes(url.pathname)) { + await route.continue(); + return; + } + + const upstream = await route.fetch(); + const payload = await upstream.json() as { + object: string; + data: Array>; + }; + const after = url.searchParams.get("after"); + expect(url.searchParams.get("limit")).toBe("100"); + expect(url.searchParams.get("order")).toBe("desc"); + + if (url.pathname === "/v1/agents") { + agentAfters.push(after); + const data = after === null ? payload.data.slice(0, 2) : payload.data.slice(2); + await route.fulfill({ + response: upstream, + json: { + object: "list", + data, + has_more: after === null, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }, + }); + return; + } + + sessionAfters.push(after); + if (after === null) sessionTemplate = payload.data[0] ?? null; + if (sessionTemplate === null) throw new Error("The fixture did not provide a Session pagination template."); + const original = sessionTemplate; + const second = { + ...original, + id: "session_second_page", + metadata: { fixture: "second-page" }, + created_at: Number(original?.created_at ?? 0) - 1, + last_active_at: Number(original?.last_active_at ?? 0) - 1, + }; + const data = after === null ? [original] : [second]; + await route.fulfill({ + response: upstream, + json: { + object: "list", + data, + has_more: after === null, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }, + }); + }); + + await page.goto("/"); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + const dashboard = page.locator(".dashboard-page"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Sessions" })).toContainText("2"); + expect(agentAfters).toEqual([null, "agent_b"]); + expect(sessionAfters).toEqual([null, "session_snapshot"]); +}); + +test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + + const dashboard = page.locator(".dashboard-page"); + const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Agents" }); + await expect(loadedAgents).toContainText("3"); + + let template: Record | null = null; + let reads = 0; + await page.route("**/v1/agents**", async (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET" || url.pathname !== "/v1/agents") { + await route.continue(); + return; + } + reads += 1; + if (template === null) { + const upstream = await route.fetch(); + const payload = await upstream.json() as { data: Array> }; + template = payload.data[0] ?? null; + } + expect(template).not.toBeNull(); + const id = `agent_safety_page_${reads}`; + await route.fulfill({ + json: { + object: "list", + data: [{ ...template, id, name: `Safety page ${reads}` }], + has_more: true, + first_id: id, + last_id: id, + }, + }); + }); + + const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); + await refresh.click(); + await expect.poll(() => reads).toBe(100); + await expect(refresh).toBeEnabled(); + await expect(dashboard).toContainText("Refresh failed · last loaded result remains visible"); + await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); + await expect(loadedAgents).toContainText("3"); + expect(reads).toBe(100); +}); + test("renders self-hosted Environment and Workspace state safely across reconnect and narrow themes", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { @@ -1255,10 +2356,7 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await expect(panel.getByRole("link", { name: "Core setup" })).toBeVisible(); await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeVisible(); await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Function result required" })).toBeVisible(); - await expect(page.getByLabel("Function result or error")).toBeEnabled(); - await expect(page.getByRole("button", { name: "Return error" })).toBeEnabled(); - await expect(page.getByRole("button", { name: "Submit result" })).toBeDisabled(); + await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); await expect(page.locator("body")).not.toContainText("launcher:private"); await expect(page.locator("body")).not.toContainText("executor_token=secret"); @@ -1311,9 +2409,64 @@ test("renders self-hosted Environment and Workspace state safely across reconnec await controlFixture(request, { environmentScenario: 3, environmentEventStatus: 0 }); await page.reload(); - const { panel: missing } = await openEnvironmentDialog(page); - await expect(missing).toContainText("ID unavailable"); - await expect(missing).toContainText("unsafe or malformed URL"); + await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); + await expect(page.locator(".workspace-error")).toContainText("invalid Session resource"); + await expect(environmentTrigger(page)).toHaveCount(0); +}); + +test("lists Workspace file metadata explicitly, paginates, fails closed, and fences Environment changes", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 7 }); + await page.goto("/"); + + const { panel } = await openEnvironmentDialog(page); + const files = panel.getByRole("region", { name: "Workspace files" }); + await expect(files).toBeVisible(); + await expect(panel).toContainText("/executor/workspace"); + await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); + await expect(files).toContainText("Files are loaded only when requested"); + expect((await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files"))).toHaveLength(0); + + await files.getByRole("button", { name: "List files" }).click(); + const table = files.getByRole("table", { name: "Workspace file metadata" }); + await expect(table).toContainText("/executor/workspace/file-01.txt"); + await expect(table).toContainText("/executor/workspace/file-20.txt"); + await expect(table).not.toContainText("/executor/workspace/file-21.txt"); + await expect(files.getByRole("button", { name: "Load more" })).toBeVisible(); + + let reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); + expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc"); + expect(reads.at(-1)?.beta).toBe("agents=v1"); + await files.getByRole("button", { name: "Load more" }).click(); + await expect(table).toContainText("/executor/workspace/file-21.txt"); + await expect(files.getByRole("button", { name: "Load more" })).toHaveCount(0); + reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); + expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc&page=fixture-page-2"); + await attachElementScreenshot(files, testInfo, "workspace-file-metadata-list"); + + await controlFixture(request, { environmentFilesStatus: 503 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect(files.getByRole("alert")).toContainText("Workspace files are temporarily unavailable"); + await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); + await expect(files).not.toContainText("No direct regular files were returned"); + + await controlFixture(request, { environmentFilesStatus: 404 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect(files.getByRole("alert")).toContainText("not supported by the connected Core"); + await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); + + const beforeAbort = (await fixtureState(request)).aborts.environmentFileReads; + await controlFixture(request, { environmentFilesStatus: 200, environmentFilesDelayMs: 2_000 }); + await files.getByRole("button", { name: "Refresh" }).click(); + await expect.poll(async () => ( + await fixtureRequests(request) + ).filter((entry) => entry.path.endsWith("/files")).length).toBeGreaterThan(reads.length + 1); + await controlFixture(request, { environmentScenario: 2 }); + await page.getByRole("button", { name: "Recover durable state" }).evaluate((button) => ( + button as HTMLButtonElement + ).click()); + await expect(page.getByRole("region", { name: "Workspace files" })).toHaveCount(0); + await expect.poll(async () => (await fixtureState(request)).aborts.environmentFileReads).toBeGreaterThan(beforeAbort); }); test("hydrates durable expired and unavailable Environment states without a write or paid Turn", async ({ page, request }) => { @@ -1661,7 +2814,7 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); await expect(timeline).toContainText("Loading every Turn page"); await page.getByRole("button", { name: "Agents" }).click(); - await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); await startSessionWithSecondAgent(page); await page.getByRole("tab", { name: "Trace" }).click(); @@ -1778,6 +2931,49 @@ test("manually retries uncertain sends with the original key only while the payl await attachScreenshot(page, testInfo, "desktop-send-recovery"); }); +test("reuses Function result identity only for an unchanged uncertain explicit retry", async ({ page, request }) => { + await resetFixture(request); + await controlFixture(request, { environmentScenario: 10 }); + await page.goto("/"); + await expect(connectedLiveEvents(page)).toBeVisible(); + const editor = page.getByLabel("Function result or error"); + const submit = page.getByRole("button", { name: "Submit result" }); + const eventWrites = async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ); + + await editor.fill("uncertain result"); + await controlFixture(request, { sendResponseLoss: 1 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await expect(editor).toHaveValue("uncertain result"); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(2); + let writes = await eventWrites(); + expect(writes[0]?.body).toEqual(writes[1]?.body); + expect(writes[0]?.idempotencyKey).toBeTruthy(); + expect(writes[1]?.idempotencyKey).toBe(writes[0]?.idempotencyKey); + + await editor.fill("before edit"); + await controlFixture(request, { sendResponseLoss: 1 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await editor.fill("after edit"); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(4); + writes = await eventWrites(); + expect(writes[3]?.idempotencyKey).not.toBe(writes[2]?.idempotencyKey); + + await editor.fill("definite rejection"); + await controlFixture(request, { sendStatus: 422 }); + await submit.click(); + await expect(editor).toBeEnabled(); + await submit.click(); + await expect.poll(async () => (await eventWrites()).length).toBe(6); + writes = await eventWrites(); + expect(writes[5]?.idempotencyKey).not.toBe(writes[4]?.idempotencyKey); +}); + test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { await resetFixture(request); await controlFixture(request, { environmentScenario: 6 }); @@ -1790,12 +2986,20 @@ test("keeps cancellation available for an Environment-only required action", asy ).length; const cancel = page.getByRole("button", { name: "Cancel active Turn" }); await expect(cancel).toBeEnabled(); + await controlFixture(request, { sendResponseLoss: 1 }); await cancel.click(); await expect.poll(async () => (await fixtureRequests(request)).filter( (entry) => entry.method === "POST" && entry.path.endsWith("/events"), ).length).toBe(writesBefore + 1); + await expect(cancel).toBeEnabled(); + await cancel.click(); + await expect.poll(async () => (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length).toBe(writesBefore + 2); const writes = (await fixtureRequests(request)).filter( (entry) => entry.method === "POST" && entry.path.endsWith("/events"), ); expect(writes.at(-1)?.body).toEqual({ events: [{ type: "agent.session.input.cancel" }] }); + expect(writes.at(-2)?.idempotencyKey).toBeTruthy(); + expect(writes.at(-1)?.idempotencyKey).toBe(writes.at(-2)?.idempotencyKey); }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index e00c683..2217c57 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -4,6 +4,8 @@ const host = "127.0.0.1"; const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); const baseline = 1_789_438_800; const canonicalEnvironmentUuid = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; +const hostedEnvironmentUuid = "7a263c51-6bf0-4d53-8518-c792eb1f0d21"; +const sourceFileUuid = "16e1f26e-8cf6-4272-9c31-d470b08d31af"; const canonicalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; function patchItems() { @@ -142,7 +144,7 @@ function sessionAdmissionError(agent) { } functionNames.add(tool.name); } else if (tool.type === "mcp") { - if (!isCanonicalExecutionMcp(tool) || mcpLabels.has(tool.server_label) || tool.credential_id != null) { + if (!isCanonicalExecutionMcp(tool) || mcpLabels.has(tool.server_label)) { return "Invalid execution MCP fields."; } mcpLabels.add(tool.server_label); @@ -154,16 +156,166 @@ function sessionAdmissionError(agent) { return null; } +function sessionVaultError(agent, vaultIds) { + if (!Array.isArray(vaultIds) || vaultIds.some((id) => typeof id !== "string" || !canonicalUuidPattern.test(id))) { + return "Fixture Session Vault attachments are invalid."; + } + const attached = new Set(vaultIds); + if (attached.size !== vaultIds.length || [...attached].some((id) => !state.vaults.some((vault) => vault.id === id))) { + return "Fixture Session Vault attachments are unavailable."; + } + for (const tool of agent.tools) { + if (tool.type !== "mcp") continue; + const matching = state.credentials.filter((credential) => ( + attached.has(credential.vault_id) && credential.auth.mcp_server_url === tool.transport.server_url + )); + if (typeof tool.credential_id === "string") { + const selected = matching.find((credential) => credential.id === tool.credential_id); + if (!selected || !state.credentialTokens.has(selected.id)) return "Fixture explicit MCP Credential is unavailable."; + } else if (matching.length > 1) { + return "Fixture anonymous MCP selection is ambiguous."; + } + } + return null; +} + function sessionSnapshot(agent) { const { object: _object, metadata: _metadata, created_at: _created, updated_at: _updated, ...snapshot } = agent; return snapshot; } +function sessionEffectiveAgent(saved, override) { + if (override === undefined) return saved; + if (!isRecord(override) || !hasOnlyKeys(override, [ + "model", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools", + ])) return null; + if (Object.hasOwn(override, "model") && typeof override.model !== "string") return null; + const effective = { ...saved }; + if (Object.hasOwn(override, "model")) effective.model = override.model; + if (Object.hasOwn(override, "instructions")) { + if (override.instructions !== null && typeof override.instructions !== "string") return null; + effective.instructions = override.instructions; + } + if (Object.hasOwn(override, "multi_agent")) { + if (override.multi_agent === null) effective.multi_agent = { enabled: false, max_concurrent_subagents: null }; + else if (isRecord(override.multi_agent) && typeof override.multi_agent.enabled === "boolean") { + effective.multi_agent = { + enabled: override.multi_agent.enabled, + max_concurrent_subagents: override.multi_agent.enabled + ? override.multi_agent.max_concurrent_subagents ?? 6 + : null, + }; + } else return null; + } + if (Object.hasOwn(override, "reasoning")) { + if (override.reasoning !== null && !isRecord(override.reasoning)) return null; + effective.reasoning = override.reasoning ?? {}; + } + if (Object.hasOwn(override, "service_tier")) { + if (override.service_tier !== null && typeof override.service_tier !== "string") return null; + effective.service_tier = override.service_tier ?? "auto"; + } + if (Object.hasOwn(override, "text")) { + if (override.text !== null && !isRecord(override.text)) return null; + effective.text = { + format: override.text?.format ?? { type: "text" }, + verbosity: override.text?.verbosity ?? "medium", + }; + } + if (Object.hasOwn(override, "tools")) { + if (override.tools !== null && !Array.isArray(override.tools)) return null; + effective.tools = override.tools ?? []; + } + return effective; +} + +function sessionInlineAgent(input, id) { + if ( + !isRecord(input) + || !hasOnlyKeys(input, ["model", "instructions", "tools"]) + || typeof input.model !== "string" + || /^\p{White_Space}*$/u.test(input.model) + || input.model !== input.model.trim() + || (Object.hasOwn(input, "instructions") + && (typeof input.instructions !== "string" + || /^\p{White_Space}*$/u.test(input.instructions) + || input.instructions !== input.instructions.trim())) + || (Object.hasOwn(input, "tools") && !Array.isArray(input.tools)) + ) return null; + return { + id, + model: input.model, + name: null, + instructions: input.instructions ?? null, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: input.tools ?? [], + }; +} + +function sessionInitialInputMessages(input) { + if (typeof input === "string") { + return /^\p{White_Space}*$/u.test(input) + ? null + : [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }]; + } + if (!Array.isArray(input) || input.length === 0) return null; + + const messages = []; + for (const message of input) { + if ( + !isRecord(message) + || !hasOnlyKeys(message, ["type", "role", "content"]) + || (message.type !== undefined && message.type !== "message") + || message.role !== "user" + || !Array.isArray(message.content) + || message.content.length === 0 + ) return null; + const content = []; + for (const part of message.content) { + if ( + !isRecord(part) + || !hasOnlyKeys(part, ["type", "text"]) + || part.type !== "input_text" + || typeof part.text !== "string" + ) return null; + content.push({ type: "input_text", text: part.text }); + } + if (/^\p{White_Space}*$/u.test(content.map((part) => part.text).join(""))) return null; + messages.push({ type: "message", role: "user", content }); + } + return messages; +} + function sessionEnvironmentResponse(environment) { if (!isRecord(environment) || typeof environment.type !== "string") return null; if (environment.type === "none") { return hasOnlyKeys(environment, ["type"]) ? { type: "none" } : null; } + if (environment.type === "openai_hosted") { + if (!hasOnlyKeys(environment, ["type", "network"])) return null; + let access = "enabled"; + if (environment.network !== undefined) { + if ( + !isRecord(environment.network) || + !hasOnlyKeys(environment.network, ["access"]) || + (environment.network.access !== "enabled" && environment.network.access !== "disabled") + ) return null; + access = environment.network.access; + } + return { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access, allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + } if ( environment.type !== "self_hosted" || !hasOnlyKeys(environment, ["type", "workspace_directory", "capability_directories"]) @@ -197,6 +349,9 @@ function initialState() { savedOnlyTool.tools = [{ type: "tool_search" }]; return { agents: [first, second, savedOnlyTool], + vaults: [], + credentials: [], + credentialTokens: new Set(), sessions: [{ id: "session_snapshot", object: "agent.session", @@ -212,13 +367,20 @@ function initialState() { last_active_at: baseline - 10, }], turns: [], + createdSessionItems: new Map(), requests: [], + sourceFiles: new Map(), + hostedWorkspaceFiles: [], sessionCreateReceipts: new Map(), controls: { createAgentResponseVariant: "valid", sessionCreateDelayMs: 0, sessionCreateStatus: 201, sessionCreateResponseLoss: 0, + sessionCreateStreamCloseDelayMs: 120, + sessionListDelayMs: 0, + sessionListStatus: 200, + sessionListPageSize: 100, retrieveDelayMs: 0, retrieveStatus: 200, updateDelayMs: 0, @@ -235,6 +397,10 @@ function initialState() { environmentScenario: 0, environmentRetrieveDelayMs: 0, environmentRetrieveStatus: 200, + environmentFilesDelayMs: 0, + environmentFilesStatus: 200, + environmentFileCreateStatus: 200, + environmentFileCreateResponseLoss: 0, environmentResourceStatus: "pending", environmentResourceVariant: "valid", environmentEventStatus: 0, @@ -255,12 +421,18 @@ function initialState() { sessionDeleteStreamCloseDelayMs: 0, itemsRetrieveDelayMs: 0, itemsRetrieveStatus: 200, + sourceUploadStatus: 200, + sourceUploadResponseLoss: 0, + sourceDeleteStatus: 200, + sourceDeleteResponseLoss: 0, }, aborts: { + sessionListReads: 0, sessionReads: 0, itemReads: 0, turnReads: 0, streams: 0, + environmentFileReads: 0, }, sequence: 0, }; @@ -288,6 +460,63 @@ function applyEnvironmentScenario(value) { const session = state.sessions[0]; if (!session) return; const hostileRemote = "https://launcher:private@executor.example.test/connect?executor_token=secret#credential"; + if (value === 10) { + session.environment = { + type: "self_hosted", + id: "environment_fixture", + remote_url: hostileRemote, + workspace_directory: `/workspace//${"long/".repeat(45)}project`, + capability_directories: ["/capabilities/read-only", `/capabilities/${"wide/".repeat(55)}`], + }; + session.status = "requires_action"; + session.required_actions = [ + { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, + ]; + return; + } + if (value === 9) { + session.environment = { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + session.status = "failed"; + session.error = "The environment is no longer available for this input."; + session.required_actions = []; + return; + } + if (value === 8) { + session.environment = { + type: "openai_hosted", + id: hostedEnvironmentUuid, + capability_directories: [], + network: { access: "disabled", allowed_domains: [] }, + packages: { npm: [], python: [], system: [] }, + files: [], + plugins: [], + skills: [], + }; + session.status = "idle"; + session.required_actions = []; + return; + } + if (value === 7) { + session.environment = { + type: "self_hosted", + id: canonicalEnvironmentUuid, + remote_url: "https://executor.example.test", + workspace_directory: "/executor/workspace", + capability_directories: [], + }; + session.status = "idle"; + session.required_actions = []; + return; + } if (value === 1 || value === 4 || value === 5 || value === 6) { session.environment = { type: "self_hosted", @@ -298,10 +527,7 @@ function applyEnvironmentScenario(value) { }; session.status = value === 1 || value === 6 ? "requires_action" : "idle"; session.required_actions = value === 1 - ? [ - { type: "environment_connection", environment_id: "environment_fixture" }, - { type: "function_call", call_id: "call_fixture", turn_id: "turn_fixture", name: "confirm", arguments: { safe: true } }, - ] + ? [{ type: "environment_connection", environment_id: "environment_fixture" }] : value === 6 ? [{ type: "environment_connection", environment_id: "environment_fixture" }] : []; @@ -376,11 +602,11 @@ function sendJson(response, value, status = 200) { response.end(body); } -function sendError(response, status, message) { +function sendError(response, status, message, code = "fixture_failure", type = "fixture_error") { sendJson(response, { error: { - code: "fixture_failure", - type: "fixture_error", + code, + type, message, }, }, status); @@ -393,6 +619,41 @@ async function readJson(request) { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } +async function readBuffer(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function readSourceMultipart(request) { + const contentType = request.headers["content-type"] ?? ""; + const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); + if (!match) return null; + const boundary = match[1] ?? match[2]; + const raw = (await readBuffer(request)).toString("latin1"); + const parts = raw.split(`--${boundary}`).slice(1, -1); + const result = { file: null, filename: null, purpose: null }; + for (const rawPart of parts) { + const part = rawPart.replace(/^\r\n/, "").replace(/\r\n$/, ""); + const separator = part.indexOf("\r\n\r\n"); + if (separator < 0) return null; + const header = part.slice(0, separator); + const body = part.slice(separator + 4); + const name = /\bname="([^"]+)"/i.exec(header)?.[1]; + if (name === "file") { + if (result.file !== null) return null; + result.filename = /\bfilename="([^"]*)"/i.exec(header)?.[1] ?? null; + result.file = Buffer.from(body, "latin1"); + } else if (name === "purpose") { + if (result.purpose !== null) return null; + result.purpose = body; + } else { + return null; + } + } + return result.file !== null && result.filename && result.purpose === "user_data" ? result : null; +} + function page(data) { return { object: "list", @@ -403,7 +664,33 @@ function page(data) { }; } +function queryPage(data, url, maximumPageSize = 100) { + const ordered = url.searchParams.get("order") === "asc" ? [...data].reverse() : [...data]; + const after = url.searchParams.get("after"); + const start = after ? ordered.findIndex((value) => value.id === after) + 1 : 0; + if (after && start === 0) return null; + const requestedLimit = Number(url.searchParams.get("limit") ?? 20); + const size = Math.max(1, Math.min(requestedLimit, maximumPageSize)); + const values = ordered.slice(start, start + size); + return { + object: "list", + data: values, + has_more: start + values.length < ordered.length, + first_id: values[0]?.id ?? null, + last_id: values.at(-1)?.id ?? null, + }; +} + function recordRequest(request, url, body) { + let safeBody = body; + if ( + request.method === "POST" && + /^\/v1\/vaults\/[^/]+\/credentials(?:\/[^/]+)?$/u.test(url.pathname) && + isRecord(body) && isRecord(body.auth) && Object.hasOwn(body.auth, "token") + ) { + const { token: _token, ...safeAuth } = body.auth; + safeBody = { ...body, auth: { ...safeAuth, token_present: true } }; + } state.requests.push({ method: request.method, path: url.pathname, @@ -412,10 +699,14 @@ function recordRequest(request, url, body) { authorizationPresent: Boolean(request.headers.authorization), idempotencyKeyPresent: Boolean(request.headers["idempotency-key"]), idempotencyKey: request.headers["idempotency-key"] ?? null, - body, + body: safeBody, }); } +function fixtureUuid(sequence) { + return `10000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; +} + function consumeControl(prefix, successStatus = 200) { const delayMs = state.controls[`${prefix}DelayMs`]; const status = state.controls[`${prefix}Status`]; @@ -493,9 +784,172 @@ const server = http.createServer(async (request, response) => { return sendJson(response, { removed: state.sessions.length !== before }); } + if (request.method === "POST" && url.pathname === "/v1/files") { + const upload = await readSourceMultipart(request); + recordRequest(request, url, upload ? { + filename: upload.filename, + bytes: upload.file.length, + purpose: upload.purpose, + } : { multipart: "invalid" }); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + if (state.controls.sourceUploadStatus !== 200) { + const status = state.controls.sourceUploadStatus; + state.controls.sourceUploadStatus = 200; + return sendError(response, status, "Fixture Source upload failed."); + } + if (!upload) return sendError(response, 400, "Fixture Source multipart is invalid."); + const id = `file-${sourceFileUuid}`; + const metadata = { + id, + object: "file", + bytes: upload.file.length, + created_at: baseline, + filename: upload.filename, + purpose: "user_data", + status: "processed", + expires_at: null, + status_details: null, + }; + state.sourceFiles.set(id, { metadata, data: upload.file }); + const lose = state.controls.sourceUploadResponseLoss; + state.controls.sourceUploadResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, metadata); + } + + const sourceContentMatch = url.pathname.match(/^\/v1\/files\/([^/]+)\/content$/); + if (request.method === "GET" && sourceContentMatch) { + const id = decodeURIComponent(sourceContentMatch[1]); + recordRequest(request, url, undefined); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + const source = state.sourceFiles.get(id); + if (!source) return sendError(response, 404, "Fixture Source File not found."); + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-disposition": `attachment; filename="${source.metadata.filename}"`, + "content-length": source.data.length, + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + response.end(source.data); + return; + } + + const sourceFileMatch = url.pathname.match(/^\/v1\/files\/([^/]+)$/); + if (sourceFileMatch && (request.method === "GET" || request.method === "DELETE")) { + const id = decodeURIComponent(sourceFileMatch[1]); + recordRequest(request, url, undefined); + if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); + const source = state.sourceFiles.get(id); + if (!source) return sendError(response, 404, "Fixture Source File not found."); + if (request.method === "GET") return sendJson(response, source.metadata); + if (state.controls.sourceDeleteStatus !== 200) { + const status = state.controls.sourceDeleteStatus; + state.controls.sourceDeleteStatus = 200; + return sendError(response, status, "Fixture Source delete failed."); + } + state.sourceFiles.delete(id); + const lose = state.controls.sourceDeleteResponseLoss; + state.controls.sourceDeleteResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, { id, object: "file", deleted: true }); + } + const body = request.method === "GET" || request.method === "DELETE" ? undefined : await readJson(request); recordRequest(request, url, body); + if (url.pathname === "/v1/vaults") { + if (request.method === "GET") return sendJson(response, page(state.vaults)); + if (request.method === "POST") { + if (!isRecord(body) || typeof body.name !== "string" || !body.name.trim() || !isRecord(body.metadata ?? {})) { + return sendError(response, 400, "Fixture Vault fields are invalid."); + } + state.sequence += 1; + const vault = { + id: fixtureUuid(state.sequence), + object: "vault", + created_at: baseline + state.sequence, + name: body.name, + metadata: body.metadata ?? {}, + }; + state.vaults.unshift(vault); + return sendJson(response, vault); + } + } + + const credentialsMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)\/credentials$/u); + if (credentialsMatch) { + const vaultId = decodeURIComponent(credentialsMatch[1]); + if (!state.vaults.some((vault) => vault.id === vaultId)) return sendError(response, 404, "Fixture Vault not found."); + if (request.method === "GET") { + return sendJson(response, page(state.credentials.filter((credential) => credential.vault_id === vaultId))); + } + if (request.method === "POST") { + if ( + !isRecord(body) || typeof body.name !== "string" || !body.name.trim() || + !isRecord(body.auth) || body.auth.type !== "static_bearer" || + typeof body.auth.mcp_server_url !== "string" || !body.auth.mcp_server_url.startsWith("https://") || + typeof body.auth.token !== "string" + ) return sendError(response, 400, "Fixture Credential fields are invalid."); + state.sequence += 1; + const credential = { + id: fixtureUuid(state.sequence), + vault_id: vaultId, + name: body.name, + object: "vault.credential", + auth: { type: "static_bearer", mcp_server_url: body.auth.mcp_server_url }, + created_at: baseline + state.sequence, + updated_at: baseline + state.sequence, + }; + state.credentials.unshift(credential); + state.credentialTokens.add(credential.id); + return sendJson(response, credential); + } + } + + const credentialMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)\/credentials\/([^/]+)$/u); + if (credentialMatch) { + const vaultId = decodeURIComponent(credentialMatch[1]); + const credentialId = decodeURIComponent(credentialMatch[2]); + const credential = state.credentials.find((candidate) => candidate.vault_id === vaultId && candidate.id === credentialId); + if (!credential) return sendError(response, 404, "Fixture Credential not found."); + if (request.method === "GET") return sendJson(response, credential); + if (request.method === "POST") { + if (!isRecord(body) || !isRecord(body.auth) || body.auth.type !== "static_bearer" || typeof body.auth.token !== "string") { + return sendError(response, 400, "Fixture Credential replacement is invalid."); + } + credential.updated_at += 1; + state.credentialTokens.add(credential.id); + return sendJson(response, credential); + } + if (request.method === "DELETE") { + state.credentials = state.credentials.filter((candidate) => candidate.id !== credentialId); + state.credentialTokens.delete(credentialId); + return sendJson(response, { id: credentialId, object: "vault.credential.deleted", deleted: true }); + } + } + + const vaultMatch = url.pathname.match(/^\/v1\/vaults\/([^/]+)$/u); + if (vaultMatch) { + const vaultId = decodeURIComponent(vaultMatch[1]); + const vault = state.vaults.find((candidate) => candidate.id === vaultId); + if (!vault) return sendError(response, 404, "Fixture Vault not found."); + if (request.method === "GET") return sendJson(response, vault); + if (request.method === "DELETE") { + const deletedCredentialIds = state.credentials.filter((credential) => credential.vault_id === vaultId).map((credential) => credential.id); + state.vaults = state.vaults.filter((candidate) => candidate.id !== vaultId); + state.credentials = state.credentials.filter((credential) => credential.vault_id !== vaultId); + for (const credentialId of deletedCredentialIds) state.credentialTokens.delete(credentialId); + return sendJson(response, { id: vaultId, object: "vault.deleted", deleted: true }); + } + } + if (request.method === "GET" && url.pathname === "/v1/agents") { const listed = state.agents.map((agent, index) => index === 0 ? { ...agent, name: `${agent.name} · stale list`, updated_at: agent.updated_at - 10 } @@ -528,12 +982,24 @@ const server = http.createServer(async (request, response) => { } if (request.method === "GET" && url.pathname === "/v1/agents/sessions") { - return sendJson(response, page(state.sessions)); + trackAbort(response, "sessionListReads"); + const control = consumeControl("sessionList"); + if (control.delayMs) await wait(control.delayMs); + if (control.status !== 200) return sendError(response, control.status, "Fixture Session list failed."); + const agentId = url.searchParams.get("agent_id"); + const filtered = agentId === null + ? state.sessions + : state.sessions.filter((session) => session.agent.id === agentId); + const listed = queryPage(filtered, url, state.controls.sessionListPageSize); + return listed + ? sendJson(response, listed) + : sendError(response, 400, "Fixture Session cursor is outside the selected Agent filter."); } if (request.method === "POST" && url.pathname === "/v1/agents/sessions") { const idempotencyKey = request.headers["idempotency-key"]; - const fingerprint = JSON.stringify(body); + const { stream: _streamResponseMode, ...creationIntent } = body; + const fingerprint = JSON.stringify(creationIntent); const receipt = typeof idempotencyKey === "string" ? state.sessionCreateReceipts.get(idempotencyKey) : undefined; @@ -541,6 +1007,16 @@ const server = http.createServer(async (request, response) => { if (receipt.fingerprint !== fingerprint) { return sendError(response, 409, "Fixture idempotency key was reused with a different Session request."); } + if (body.stream === true) { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + response.write(": fixture creation retry observes only future events\n\n"); + setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); + return; + } return sendJson(response, receipt.session, 200); } @@ -549,12 +1025,38 @@ const server = http.createServer(async (request, response) => { state.controls.sessionCreateResponseLoss = 0; if (control.delayMs) await wait(control.delayMs); if (control.status !== 201) return sendError(response, control.status, "Fixture Session create failed."); - const agent = state.agents.find((candidate) => candidate.id === body.agent_id); - if (!agent) return sendError(response, 404, "Fixture Agent not found for Session."); + const savedAgent = typeof body.agent_id === "string" + ? state.agents.find((candidate) => candidate.id === body.agent_id) + : undefined; + const agent = body.agent_id === undefined + ? sessionInlineAgent(body.agent, `inline_agent_${state.sequence + 1}`) + : savedAgent + ? sessionEffectiveAgent(savedAgent, body.agent) + : null; + if (body.agent_id !== undefined && !savedAgent) { + return sendError(response, 404, "Fixture Agent not found for Session."); + } + if (!agent) return sendError(response, 400, "Fixture Session Agent override is invalid."); const admissionError = sessionAdmissionError(agent); if (admissionError) return sendError(response, 400, admissionError); + const vaultError = sessionVaultError(agent, body.vault_ids ?? []); + if (vaultError) return sendError(response, 400, vaultError); const environment = sessionEnvironmentResponse(body.environment); if (!environment) return sendError(response, 400, "Fixture Session environment is unsupported."); + if (environment.type === "openai_hosted" && agent.tools.some((tool) => tool.type === "mcp")) { + return sendError(response, 400, "Fixture managed hosted MCP combination is not qualified."); + } + if ( + body.metadata !== undefined && + (!isRecord(body.metadata) || Object.entries(body.metadata).some(([key, value]) => ( + typeof value !== "string" || [...key].length > 64 || [...value].length > 512 + )) || Object.keys(body.metadata).length > 16) + ) return sendError(response, 400, "Fixture Session metadata is invalid."); + const hasInitialInput = body.input !== undefined && body.input !== null; + const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : []; + if (hasInitialInput && !initialInputMessages) { + return sendError(response, 400, "Fixture initial Session input is invalid."); + } state.sequence += 1; const created = { id: `session_created_${state.sequence}`, @@ -578,6 +1080,72 @@ const server = http.createServer(async (request, response) => { response.destroy(); return; } + if (body.stream === true) { + const createdSnapshot = structuredClone(created); + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }); + response.write(": connected\n\n"); + state.sequence += 1; + response.write(`event: agent.session.created\nid: create_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.created", + event_id: `create_${state.sequence}`, + session: createdSnapshot, + })}\n\n`); + if (hasInitialInput && initialInputMessages) { + state.sequence += 1; + const turn = { + id: `turn_created_${state.sequence}`, + agent_id: created.agent.id, + session_id: created.id, + object: "agent.session.turn", + status: "queued", + created_at: baseline + state.sequence, + started_at: null, + completed_at: null, + error: null, + usage: null, + }; + const items = initialInputMessages.map((message, index) => ({ + id: `item_created_${state.sequence}_${index + 1}`, + turn_id: turn.id, + type: "message", + status: "completed", + role: "user", + content: message.content, + })); + state.turns.push(turn); + state.createdSessionItems.set(created.id, items); + response.write(`event: agent.session.turn.created\nid: turn_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.turn.created", + event_id: `turn_${state.sequence}`, + session_id: created.id, + turn_id: turn.id, + turn, + })}\n\n`); + created.status = "in_progress"; + created.last_active_at = baseline + state.sequence; + response.write(`event: agent.session.in_progress\nid: progress_${state.sequence}\ndata: ${JSON.stringify({ + type: "agent.session.in_progress", + event_id: `progress_${state.sequence}`, + session_id: created.id, + session: created, + })}\n\n`); + for (const [index, item] of items.entries()) { + response.write(`event: agent.session.turn.item.added\nid: item_${state.sequence}_${index + 1}\ndata: ${JSON.stringify({ + type: "agent.session.turn.item.added", + event_id: `item_${state.sequence}_${index + 1}`, + session_id: created.id, + turn_id: turn.id, + item, + })}\n\n`); + } + } + setTimeout(() => response.end(), state.controls.sessionCreateStreamCloseDelayMs); + return; + } return sendJson(response, created, 201); } @@ -705,6 +1273,109 @@ const server = http.createServer(async (request, response) => { } } + const environmentFilesMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)\/files$/); + if (request.method === "POST" && environmentFilesMatch) { + const id = decodeURIComponent(environmentFilesMatch[1]); + if (id !== hostedEnvironmentUuid) return sendError(response, 503, "Fixture Environment is not a writable hosted placement."); + if (request.headers["openai-beta"] !== "agents=v1") return sendError(response, 400, "Fixture Environment Files requires the Agents beta header."); + if (state.controls.environmentFileCreateStatus !== 200) { + const status = state.controls.environmentFileCreateStatus; + state.controls.environmentFileCreateStatus = 200; + return sendError(response, status, "Fixture Environment copy failed."); + } + if (!isRecord(body) || typeof body.path !== "string" || !body.path.startsWith("/workspace/")) { + return sendError(response, 400, "Fixture Environment copy body is invalid."); + } + let data; + if (body.type === "file_id" && Object.keys(body).sort().join(",") === "file_id,path,type" && typeof body.file_id === "string") { + const source = state.sourceFiles.get(body.file_id); + if (!source) return sendError(response, 404, "Fixture Source File not found for copy."); + data = source.data; + } else if (body.type === "inline" && Object.keys(body).sort().join(",") === "data,path,type" && typeof body.data === "string") { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(body.data)) { + return sendError(response, 400, "Fixture inline Environment data is invalid."); + } + data = Buffer.from(body.data, "base64"); + if (data.toString("base64") !== body.data) return sendError(response, 400, "Fixture inline Environment data is invalid."); + } else { + return sendError(response, 400, "Fixture Environment copy body is invalid."); + } + if (data.length > 50 * 1024 * 1024) return sendError(response, 413, "Fixture Environment copy is too large."); + const result = { + environment_id: hostedEnvironmentUuid, + object: "agent.environment.file", + path: body.path, + size_bytes: data.length, + }; + state.hostedWorkspaceFiles = [ + ...state.hostedWorkspaceFiles.filter((file) => file.path !== body.path), + result, + ]; + const lose = state.controls.environmentFileCreateResponseLoss; + state.controls.environmentFileCreateResponseLoss = 0; + if (lose) { + response.destroy(); + return; + } + return sendJson(response, result); + } + if (request.method === "GET" && environmentFilesMatch) { + trackAbort(response, "environmentFileReads"); + const control = consumeControl("environmentFiles"); + if (control.delayMs) await wait(control.delayMs); + if (response.destroyed) return; + if (control.status !== 200) { + return sendError( + response, + control.status, + control.status === 404 ? "This API operation is not supported." : "Fixture Environment files read failed.", + control.status === 404 ? "unsupported_operation" : "fixture_failure", + control.status === 404 ? "invalid_request_error" : "fixture_error", + ); + } + + const id = decodeURIComponent(environmentFilesMatch[1]); + if (id === hostedEnvironmentUuid) { + const directory = url.searchParams.get("path") ?? "/workspace"; + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + if (url.searchParams.get("page") !== null || limit < 1 || limit > 100 || !["asc", "desc"].includes(order)) { + return sendError(response, 400, "Fixture hosted Environment files query is invalid."); + } + const files = state.hostedWorkspaceFiles + .filter((file) => file.path.slice(0, file.path.lastIndexOf("/")) === directory) + .sort((left, right) => left.path.localeCompare(right.path)); + if (order === "desc") files.reverse(); + return sendJson(response, { data: files.slice(0, limit), next: null }); + } + const sessionEnvironment = state.sessions[0]?.environment; + const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null; + if (id !== expectedId) return sendError(response, 404, "Fixture Environment not found."); + const directory = url.searchParams.get("path") ?? sessionEnvironment.workspace_directory; + const limit = Number(url.searchParams.get("limit") ?? 20); + const order = url.searchParams.get("order") ?? "desc"; + const cursor = url.searchParams.get("page"); + if ( + directory !== sessionEnvironment.workspace_directory || + limit !== 20 || + !["asc", "desc"].includes(order) || + (cursor !== null && cursor !== "fixture-page-2") + ) return sendError(response, 400, "Fixture Environment files query is invalid."); + + const allFiles = Array.from({ length: 21 }, (_, index) => ({ + environment_id: canonicalEnvironmentUuid, + object: "agent.environment.file", + path: `${sessionEnvironment.workspace_directory}/file-${String(index + 1).padStart(2, "0")}.txt`, + size_bytes: (index + 1) * 128, + })); + if (order === "desc") allFiles.reverse(); + const start = cursor === "fixture-page-2" ? 20 : 0; + return sendJson(response, { + data: allFiles.slice(start, start + limit), + next: start + limit < allFiles.length ? "fixture-page-2" : null, + }); + } + const environmentMatch = url.pathname.match(/^\/v1\/agents\/environments\/([^/]+)$/); if (request.method === "GET" && environmentMatch) { if (state.controls.environmentRetrieveDelayMs) await wait(state.controls.environmentRetrieveDelayMs); @@ -712,6 +1383,23 @@ const server = http.createServer(async (request, response) => { return sendError(response, state.controls.environmentRetrieveStatus, "Fixture Environment retrieve failed."); } const id = decodeURIComponent(environmentMatch[1]); + if (id === hostedEnvironmentUuid) { + const resource = { + id, + object: "agent.environment", + type: "openai_hosted", + status: state.controls.environmentResourceStatus, + files: [], + plugins: [], + skills: [], + }; + if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; + if (state.controls.environmentResourceVariant === "wrong_id") resource.id = canonicalEnvironmentUuid; + if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + if (state.controls.environmentResourceVariant === "wrong_type") resource.type = "self_hosted"; + if (state.controls.environmentResourceVariant === "populated") resource.files = [{ id: "unsupported-install" }]; + return sendJson(response, resource); + } const sessionEnvironment = state.sessions[0]?.environment; const expectedId = sessionEnvironment?.type === "self_hosted" ? sessionEnvironment.id : null; if (id !== expectedId) return sendError(response, 404, "Fixture Environment not found."); @@ -728,6 +1416,7 @@ const server = http.createServer(async (request, response) => { if (state.controls.environmentResourceVariant === "missing_skills") delete resource.skills; if (state.controls.environmentResourceVariant === "wrong_id") resource.id = "another_environment"; if (state.controls.environmentResourceVariant === "extra_field") resource.extra = true; + if (state.controls.environmentResourceVariant === "wrong_type") resource.type = "openai_hosted"; return sendJson(response, resource); } @@ -744,7 +1433,7 @@ const server = http.createServer(async (request, response) => { return sendError(response, 404, "Fixture Session not found for Items."); } const items = sessionId !== "session_snapshot" - ? [] + ? state.createdSessionItems.get(sessionId) ?? [] : state.controls.itemsScenario === 2 ? [...observableTurnItems(), ...patchItems()] : state.controls.itemsScenario @@ -819,14 +1508,16 @@ const server = http.createServer(async (request, response) => { if (environmentStatus && state.controls.environmentEventCount > 0) { state.controls.environmentEventCount -= 1; state.sequence += 1; - const sessionEnvironment = state.sessions[0]?.environment; - const rawEnvironmentId = sessionEnvironment?.type === "self_hosted" - ? sessionEnvironment.id - : "environment_fixture"; + const streamSession = state.sessions.find((candidate) => candidate.id === sessionId); + const sessionEnvironment = streamSession?.environment; + const supportedEnvironment = sessionEnvironment?.type === "self_hosted" || sessionEnvironment?.type === "openai_hosted" + ? sessionEnvironment + : null; + const rawEnvironmentId = supportedEnvironment?.id ?? "environment_fixture"; const canonicalEnvironmentId = rawEnvironmentId.toLowerCase(); const environment = { id: canonicalUuidPattern.test(canonicalEnvironmentId) ? canonicalEnvironmentId : rawEnvironmentId, - type: "self_hosted", + type: supportedEnvironment?.type ?? "self_hosted", status: environmentStatus, error: environmentStatus === "failed" ? { code: "environment_failed", @@ -837,7 +1528,7 @@ const server = http.createServer(async (request, response) => { response.write(`id: environment_${state.sequence}\ndata: ${JSON.stringify({ type: `agent.session.environment.${environmentStatus}`, event_id: `environment_${state.sequence}`, - session_id: "session_snapshot", + session_id: sessionId, environment, })}\n\n`); } diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts new file mode 100644 index 0000000..2ea9d33 --- /dev/null +++ b/apps/web/e2e/vault-credentials.spec.ts @@ -0,0 +1,244 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; + +// Runner traces record HTTP bodies. This file deliberately submits transient +// bearer values, so it must never produce a trace artifact. +test.use({ trace: "off" }); + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +interface FixtureRequest { + method: string; + path: string; + body?: Record; +} + +async function resetFixture(request: APIRequestContext): Promise { + const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); + expect(response.ok()).toBe(true); +} + +async function fixtureRequests(request: APIRequestContext): Promise { + const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} + +async function openAgents(page: Page, request: APIRequestContext): Promise { + await resetFixture(request); + await page.goto("/"); + await page.getByRole("button", { name: "Agents" }).click(); + await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); +} + +async function openAdvancedSessionSettings(dialog: Locator): Promise { + const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +} + +async function fillWriteOnlyBearer(dialog: Locator): Promise { + const input = dialog.locator('input[type="password"]'); + await expect(input).toHaveCount(1); + await input.evaluate((element) => { + (element as HTMLInputElement).value = globalThis.crypto.randomUUID().replaceAll("-", ""); + }); +} + +test.describe("Vault capability discovery", () => { + for (const status of [404, 405]) { + test(`hides Vault controls when the connected Core returns ${status}`, async ({ page, request }) => { + await resetFixture(request); + await page.route("**/v1/vaults?*", async (route) => { + if (route.request().method() === "GET" && new URL(route.request().url()).pathname === "/v1/vaults") { + await route.fulfill({ + status, + json: { error: { code: status === 404 ? "not_found" : "method_not_allowed", message: "Unavailable." } }, + }); + return; + } + await route.continue(); + }); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Vaults", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "System", exact: true }).click(); + await expect(page.locator(".system-summary-cell").filter({ hasText: "Vaults" })) + .toContainText("Unavailable"); + }); + } +}); + +test("keeps Vault content aligned with the page header without narrow-screen clipping", async ({ page, request }) => { + await resetFixture(request); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); + + const desktop = await page.locator(".vaults-page").evaluate((pageElement) => { + const title = pageElement.querySelector(".page-header h1")?.getBoundingClientRect(); + const contentElement = pageElement.querySelector(".vaults-content"); + const note = contentElement?.querySelector(".vault-security-note")?.getBoundingClientRect(); + const page = pageElement.getBoundingClientRect(); + return { + contentOverflowY: contentElement ? getComputedStyle(contentElement).overflowY : null, + pageRight: Math.round(page.right), + titleLeft: Math.round(title?.left ?? -1), + noteLeft: Math.round(note?.left ?? -2), + noteRight: Math.round(note?.right ?? -3), + }; + }); + expect(desktop.contentOverflowY).toBe("auto"); + expect(desktop.noteLeft).toBe(desktop.titleLeft); + expect(desktop.pageRight - desktop.noteRight).toBeGreaterThanOrEqual(23); + expect(desktop.pageRight - desktop.noteRight).toBeLessThanOrEqual(24); + + await page.setViewportSize({ width: 375, height: 720 }); + const narrow = await page.locator(".vaults-page").evaluate((pageElement) => { + const title = pageElement.querySelector(".page-header h1")?.getBoundingClientRect(); + const note = pageElement.querySelector(".vault-security-note")?.getBoundingClientRect(); + const createButton = pageElement.querySelector(".page-actions .button.primary")?.getBoundingClientRect(); + const page = pageElement.getBoundingClientRect(); + return { + pageClientWidth: pageElement.clientWidth, + pageScrollWidth: pageElement.scrollWidth, + pageRight: Math.round(page.right), + titleLeft: Math.round(title?.left ?? -1), + noteLeft: Math.round(note?.left ?? -2), + createButtonRight: Math.round(createButton?.right ?? -3), + }; + }); + expect(narrow.pageScrollWidth).toBeLessThanOrEqual(narrow.pageClientWidth); + expect(narrow.noteLeft).toBe(narrow.titleLeft); + expect(narrow.pageRight - narrow.createButtonRight).toBeGreaterThanOrEqual(11); + expect(narrow.pageRight - narrow.createButtonRight).toBeLessThanOrEqual(12); +}); + +test("shows a fixed 503 storage error, clears the token, and does not retry the write", async ({ page, request }) => { + await openAgents(page, request); + await page.getByRole("button", { name: "Vaults", exact: true }).click(); + await page.getByRole("button", { name: "New Vault" }).click(); + const vaultDialog = page.getByRole("dialog", { name: "Create a Vault" }); + await vaultDialog.getByLabel("Name").fill("Unavailable storage"); + await vaultDialog.getByRole("button", { name: "Create Vault" }).click(); + + let credentialWrites = 0; + await page.route("**/v1/vaults/*/credentials", async (route) => { + if (route.request().method() === "POST") { + credentialWrites += 1; + await route.fulfill({ + status: 503, + json: { + error: { + code: "credential_storage_unavailable", + message: "Private fixture detail that the Web must not expose.", + }, + }, + }); + return; + } + await route.continue(); + }); + + const vaultCard = page.locator(".vault-card").filter({ hasText: "Unavailable storage" }); + await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); + const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await credentialDialog.getByLabel("Name").fill("Unavailable MCP"); + await credentialDialog.getByLabel("Exact MCP server URL").fill("https://mcp.example/unavailable"); + await fillWriteOnlyBearer(credentialDialog); + await credentialDialog.getByRole("button", { name: "Create Credential" }).click(); + + await expect(credentialDialog.getByRole("alert")).toContainText("Credential encryption is not configured on this Core"); + await expect(credentialDialog).not.toContainText("Private fixture detail"); + await expect(credentialDialog.locator('input[type="password"]')).toHaveValue(""); + expect(credentialWrites).toBe(1); +}); + +test("creates, replaces, uses, and deletes a write-only Vault Credential", async ({ page, request }) => { + const mcpURL = "https://mcp.example/vault-tools"; + await openAgents(page, request); + + const vaultsNavigation = page.getByRole("button", { name: "Vaults" }); + await expect(vaultsNavigation).toBeVisible(); + await vaultsNavigation.click(); + await page.getByRole("button", { name: "New Vault" }).click(); + const vaultDialog = page.getByRole("dialog", { name: "Create a Vault" }); + await vaultDialog.getByLabel("Name").fill("Runtime credentials"); + await vaultDialog.getByRole("button", { name: "Create Vault" }).click(); + + const vaultCard = page.locator(".vault-card").filter({ hasText: "Runtime credentials" }); + await expect(vaultCard).toBeVisible(); + await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); + const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await credentialDialog.getByLabel("Name").fill("Private docs MCP"); + await credentialDialog.getByLabel("Exact MCP server URL").fill(mcpURL); + await fillWriteOnlyBearer(credentialDialog); + await credentialDialog.getByRole("button", { name: "Create Credential" }).click(); + await expect(vaultCard).toContainText("Private docs MCP"); + await expect(vaultCard).toContainText("token hidden"); + + let requests = await fixtureRequests(request); + const credentialWrite = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials$/u.test(entry.path)); + expect(credentialWrite?.body?.auth).toMatchObject({ type: "static_bearer", mcp_server_url: mcpURL, token_present: true }); + expect(credentialWrite?.body?.auth).not.toHaveProperty("token"); + + await vaultCard.getByRole("button", { name: "Replace token for Private docs MCP", exact: true }).click(); + const replacementDialog = page.getByRole("dialog", { name: "Replace token · Private docs MCP" }); + await fillWriteOnlyBearer(replacementDialog); + await replacementDialog.getByRole("button", { name: "Replace token", exact: true }).click(); + await expect(replacementDialog).toHaveCount(0); + requests = await fixtureRequests(request); + const replacementWrite = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials\/[^/]+$/u.test(entry.path)); + expect(replacementWrite?.body?.auth).toEqual({ type: "static_bearer", token_present: true }); + expect(replacementWrite?.body?.auth).not.toHaveProperty("token"); + + await page.getByRole("button", { name: "Agents" }).click(); + await page.getByRole("button", { name: /^Create agent/ }).click(); + await page.getByLabel("Name").fill("Credentialed MCP Agent"); + await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); + await page.getByLabel("Custom model ID").fill("fixture/credential-model"); + await page.getByRole("button", { name: "Add HTTP MCP" }).click(); + const mcpCard = page.locator(".agent-tool-card").filter({ hasText: "HTTP MCP" }).first(); + await mcpCard.getByLabel("Server label").fill("private-docs"); + await mcpCard.getByLabel("Authentication").selectOption({ label: `Private docs MCP · ${mcpURL}` }); + await expect(mcpCard.getByLabel("Server URL")).toHaveValue(mcpURL); + await expect(mcpCard.getByLabel("Server URL")).toHaveAttribute("readonly", ""); + await page.getByRole("button", { name: "Save Agent definition" }).click(); + await expect(page.getByRole("status")).toContainText("Agent definition saved as"); + await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); + await page.getByRole("button", { name: "Start Session" }).click(); + + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await openAdvancedSessionSettings(sessionDialog); + await expect(sessionDialog.getByRole("heading", { name: "Tools & Vaults" })).toBeVisible(); + await expect(sessionDialog).toContainText("Private docs MCP · Runtime credentials"); + await sessionDialog.getByRole("button", { name: "Create Session" }).click(); + await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Idle Session created"); + + requests = await fixtureRequests(request); + const vaultCreate = requests.find((entry) => entry.method === "POST" && entry.path === "/v1/vaults"); + const vaultId = requests.find((entry) => entry.method === "POST" && /\/v1\/vaults\/[^/]+\/credentials$/u.test(entry.path))?.path.split("/")[3]; + expect(vaultCreate).toBeTruthy(); + expect(vaultId).toMatch(/^[0-9a-f-]{36}$/u); + const agentCreate = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents").at(-1); + const credentialId = (agentCreate?.body?.tools as Array> | undefined)?.[0]?.credential_id; + expect(credentialId).toMatch(/^[0-9a-f-]{36}$/u); + const sessionCreate = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions").at(-1); + expect(sessionCreate?.body?.vault_ids).toEqual([vaultId]); + expect(JSON.stringify(requests)).not.toContain('"token":'); + + await page.getByRole("button", { name: "Vaults" }).click(); + const currentVaultCard = page.locator(".vault-card").filter({ hasText: "Runtime credentials" }); + await currentVaultCard.getByRole("button", { name: "Delete Private docs MCP", exact: true }).click(); + const deleteCredentialDialog = page.getByRole("dialog", { name: "Delete Credential?" }); + await deleteCredentialDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(currentVaultCard).not.toContainText("Private docs MCP"); + await currentVaultCard.getByRole("button", { name: "Delete Runtime credentials", exact: true }).click(); + const deleteVaultDialog = page.getByRole("dialog", { name: "Delete Vault?" }); + await deleteVaultDialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(page.getByRole("heading", { name: "No Vaults" })).toBeVisible(); + + requests = await fixtureRequests(request); + expect(requests.filter((entry) => entry.method === "DELETE" && /\/v1\/vaults\/[^/]+\/credentials\/[^/]+$/u.test(entry.path))).toHaveLength(1); + expect(requests.filter((entry) => entry.method === "DELETE" && /^\/v1\/vaults\/[^/]+$/u.test(entry.path))).toHaveLength(1); + expect(JSON.stringify(requests)).not.toContain('"token":'); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d2f0916..91140a5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,7 +21,9 @@ import { StatusIcon } from "./components/StatusIcon"; import { ThemeMenu } from "./components/ThemeMenu"; import { useToast } from "./components/Toast"; import { AgentsView } from "./features/agents/AgentsView"; -import { knownSessionAdmissionBlocker } from "./features/agents/session-admission"; +import { + sessionEnvironmentAdmissionBlocker, +} from "./features/agents/session-admission"; import { removeSavedAgent, replaceSavedAgent, @@ -29,13 +31,16 @@ import { requestAgentDetail, requestAgentUpdate, } from "./features/agents/agent-actions"; +import { DashboardView } from "./features/dashboard/DashboardView"; import { SessionsView, type SessionDetailState, type StreamState, } from "./features/sessions/SessionsView"; import type { SessionStartInput } from "./features/sessions/create/SessionStartDialog"; -import { sessionEnvironmentInput } from "./features/sessions/create/session-environment"; +import { sessionCreateRequestPayload } from "./features/sessions/create/session-create-attempt"; +import { normalizeSessionEnvironmentInput } from "./features/sessions/create/session-environment"; +import { validateSessionAgentSubmission } from "./features/sessions/create/session-start-draft"; import { removeSession, reconcileUnknownSessionDelete, @@ -49,11 +54,13 @@ import { } from "./features/sessions/actions/session-actions"; import { environmentObservationFromResource, - environmentIdsMatch, + environmentIdentitiesMatch, environmentReadIsCurrent, + mergeDurableEnvironmentObservation, matchingSessionSnapshot, reduceEnvironmentObservation, - selfHostedEnvironmentId, + supportedEnvironmentIdentity, + type EnvironmentIdentity, type EnvironmentObservation, type ScopedEnvironmentObservation, unavailableEnvironmentObservation, @@ -67,6 +74,10 @@ import { upsertTurn, } from "./features/sessions/turns/turn-state"; import { SystemView } from "./features/system/SystemView"; +import type { SourceFilesOperations } from "./features/system/SourceFilesPanel"; +import { VaultsView, type VaultOperations } from "./features/vaults/VaultsView"; +import { deriveSessionVaultPlan, loadVaultCatalog, type VaultCatalog } from "./features/vaults/vault-catalog"; +import { requestVaultCreate } from "./features/vaults/vault-operations"; import { createCore, loadConnection, @@ -75,12 +86,20 @@ import { type CoreConnectionState, } from "./lib/connection"; import { settleCollection } from "./lib/collection-load"; +import { listStableCollectionPages } from "./lib/collection-pagination"; +import { + beginPendingFunctionResult, + failPendingFunctionResult, + functionResultActionKey, + type FailedPendingFunctionResult, +} from "./lib/pending-function-result"; import { beginPendingSend, failPendingSend, type FailedPendingSend, } from "./lib/pending-send"; import { + appendCommandOutputDelta, mergeDurableAndLiveItems, updateLiveSessionItems, upsertSessionItem, @@ -118,6 +137,23 @@ interface SessionCreateRequest { requestId: number; } +interface CreationStreamOwner { + controller: AbortController; + coreGeneration: number; + sessionId: string | null; + streamEpoch: number | null; + startReconciliation?: () => void; +} + +interface LiveSessionEventContext { + sessionId: string; + streamEpoch: number; + isCurrent: () => boolean; + refreshCoordinator: DurableRefreshCoordinator | null; +} + +const COLLECTION_RECONCILIATION_ATTEMPTS = 3; + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The Agent core request failed."; } @@ -182,6 +218,10 @@ export function App() { const [connection, setConnection] = useState(() => loadConnection()); const [connectionOpen, setConnectionOpen] = useState(false); const [agents, setAgents] = useState([]); + const [vaultCatalog, setVaultCatalog] = useState(null); + const [vaultCollectionState, setVaultCollectionState] = useState("connecting"); + const [vaultCollectionError, setVaultCollectionError] = useState(null); + const [vaultSupported, setVaultSupported] = useState(null); const [sessions, setSessions] = useState([]); const [selectedId, setSelectedId] = useState(null); const [items, setItems] = useState([]); @@ -198,8 +238,14 @@ export function App() { ); const [agentCollectionState, setAgentCollectionState] = useState("connecting"); const [agentCollectionError, setAgentCollectionError] = useState(null); + const [agentCollectionHasSnapshot, setAgentCollectionHasSnapshot] = useState(false); const [sessionCollectionState, setSessionCollectionState] = useState("connecting"); const [sessionCollectionError, setSessionCollectionError] = useState(null); + const [sessionCollectionHasSnapshot, setSessionCollectionHasSnapshot] = useState(false); + const [sessionAgentFilter, setSessionAgentFilter] = useState(null); + const [filteredSessions, setFilteredSessions] = useState([]); + const [filteredSessionCollectionState, setFilteredSessionCollectionState] = useState("connecting"); + const [filteredSessionCollectionError, setFilteredSessionCollectionError] = useState(null); const [selectedSessionLoad, setSelectedSessionLoad] = useState({ sessionId: null, state: "idle", @@ -214,6 +260,8 @@ export function App() { const [sessionSendFailures, setSessionSendFailures] = useState>( () => new Map(), ); + const cancelFailureRef = useRef(undefined); + const functionResultFailuresRef = useRef(new Map()); const [busy, setBusy] = useState(false); const [agentCreateRequest, setAgentCreateRequest] = useState(null); const [sessionCreateRequest, setSessionCreateRequest] = useState(null); @@ -221,11 +269,19 @@ export function App() { const sessionCreateSequenceRef = useRef(0); const selectedIdRef = useRef(selectedId); const sessionsRef = useRef(sessions); + const filteredSessionsRef = useRef(filteredSessions); const itemsSessionIdRef = useRef(itemsSessionId); const turnsSessionIdRef = useRef(turnsSessionId); const connectionGenerationRef = useRef(0); const agentCollectionRequestRef = useRef(0); const sessionCollectionRequestRef = useRef(0); + const agentCollectionAbortRef = useRef(null); + const sessionCollectionAbortRef = useRef(null); + const filteredSessionCollectionAbortRef = useRef(null); + const filteredSessionCollectionRequestRef = useRef(0); + const sessionAgentFilterRef = useRef(sessionAgentFilter); + const vaultCollectionAbortRef = useRef(null); + const vaultCollectionRequestRef = useRef(0); const agentCollectionRevisionRef = useRef(0); const sessionCollectionRevisionRef = useRef(0); const sessionRequestRef = useRef(new Map()); @@ -234,22 +290,40 @@ export function App() { const turnEventRevisionRef = useRef(new Map()); const environmentEventRevisionRef = useRef(new Map()); const environmentRequestRef = useRef(new Map()); - const sessionEnvironmentIdRef = useRef(new Map()); + const sessionEnvironmentIdentityRef = useRef(new Map()); const operationRequestRef = useRef(0); const streamEpochRef = useRef(0); const selectedSessionReadAbortRef = useRef(null); const selectedStreamAbortRef = useRef(null); + const creationStreamOwnerRef = useRef(null); selectedIdRef.current = selectedId; sessionsRef.current = sessions; + filteredSessionsRef.current = filteredSessions; + sessionAgentFilterRef.current = sessionAgentFilter; const core = useMemo(() => createCore(connection), [connection]); + const sourceFilesOperations = useMemo(() => ({ + uploadSourceFile: (input, options) => core.uploadSourceFile(input, options), + retrieveSourceFile: (fileId, options) => core.retrieveSourceFile(fileId, options), + downloadSourceFile: (fileId, options) => core.downloadSourceFile(fileId, options), + deleteSourceFile: (fileId, options) => core.deleteSourceFile(fileId, options), + retrieveEnvironment: (environmentId, options) => core.retrieveEnvironment(environmentId, options), + createEnvironmentFile: (environmentId, input, options) => core.createEnvironmentFile(environmentId, input, options), + listEnvironmentFiles: (environmentId, options) => core.listEnvironmentFiles(environmentId, options), + }), [core]); const coreGeneration = connectionGenerationRef.current; const coreState: CoreConnectionState = agentCollectionState === "ready" || sessionCollectionState === "ready" ? "ready" : agentCollectionState === "failed" && sessionCollectionState === "failed" ? "failed" : "connecting"; - const selected = selectedId ? sessions.find((session) => session.id === selectedId) ?? null : null; + const sessionVaultCatalog = vaultCollectionState === "ready" ? vaultCatalog : null; + const sessionBrowserSessions = sessionAgentFilter ? filteredSessions : sessions; + const sessionBrowserState = sessionAgentFilter ? filteredSessionCollectionState : sessionCollectionState; + const sessionBrowserError = sessionAgentFilter ? filteredSessionCollectionError : sessionCollectionError; + const selected = selectedId + ? sessionBrowserSessions.find((session) => session.id === selectedId) ?? null + : null; const detailState: SessionDetailState = !selectedId ? "idle" : selectedSessionLoad.sessionId === selectedId @@ -284,92 +358,244 @@ export function App() { const refreshAgents = useCallback(async () => { if (coreGeneration !== connectionGenerationRef.current) return false; - const agentRevision = agentCollectionRevisionRef.current; - const request = agentCollectionRequestRef.current + 1; - agentCollectionRequestRef.current = request; + agentCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + agentCollectionAbortRef.current = controller; setAgentCollectionState("connecting"); setAgentCollectionError(null); - const result = await settleCollection(() => core.listAgents({ limit: 100, order: "desc" })); - if ( - coreGeneration !== connectionGenerationRef.current || - request !== agentCollectionRequestRef.current - ) return false; - if (result.status === "fulfilled") { - if (agentRevision === agentCollectionRevisionRef.current) setAgents(result.value.data); + try { + const request = agentCollectionRequestRef.current + 1; + agentCollectionRequestRef.current = request; + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listAgents(options), + () => agentCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== agentCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setAgentCollectionState("failed"); + setAgentCollectionError(message); + notify(message, "error"); + return false; + } + if (result.value === null) { + const message = "Agent data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setAgentCollectionState("failed"); + setAgentCollectionError(message); + notify(message, "error"); + return false; + } + setAgents(result.value); + setAgentCollectionHasSnapshot(true); setAgentCollectionState("ready"); return true; + } finally { + if (agentCollectionAbortRef.current === controller) agentCollectionAbortRef.current = null; } - const message = errorMessage(result.reason); - setAgentCollectionState("failed"); - setAgentCollectionError(message); - notify(message, "error"); - return false; }, [core, coreGeneration, notify]); const refreshSessions = useCallback(async () => { if (coreGeneration !== connectionGenerationRef.current) return false; - const sessionRevision = sessionCollectionRevisionRef.current; - const request = sessionCollectionRequestRef.current + 1; - sessionCollectionRequestRef.current = request; + sessionCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + sessionCollectionAbortRef.current = controller; setSessionCollectionState("connecting"); setSessionCollectionError(null); - const result = await settleCollection(() => core.listSessions({ limit: 100, order: "desc" })); - if ( - coreGeneration !== connectionGenerationRef.current || - request !== sessionCollectionRequestRef.current - ) return false; - if (result.status === "fulfilled") { - if (sessionRevision === sessionCollectionRevisionRef.current) { - const nextEnvironmentIds = new Map( - result.value.data.map((session) => [session.id, selfHostedEnvironmentId(session.environment)]), - ); - const changedEnvironmentSessions = new Set(); - for (const [sessionId, environmentId] of nextEnvironmentIds) { - if (!environmentIdsMatch(sessionEnvironmentIdRef.current.get(sessionId), environmentId)) { - changedEnvironmentSessions.add(sessionId); - } - } - for (const sessionId of sessionEnvironmentIdRef.current.keys()) { - if (!nextEnvironmentIds.has(sessionId)) changedEnvironmentSessions.add(sessionId); - } - for (const sessionId of changedEnvironmentSessions) { - environmentRequestRef.current.set( - sessionId, - (environmentRequestRef.current.get(sessionId) ?? 0) + 1, - ); - environmentEventRevisionRef.current.set( - sessionId, - (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, - ); - } - sessionEnvironmentIdRef.current = nextEnvironmentIds; - if (changedEnvironmentSessions.size) { - setEnvironmentObservations((current) => { - if (![...changedEnvironmentSessions].some((sessionId) => current.has(sessionId))) return current; - const next = new Map(current); - for (const sessionId of changedEnvironmentSessions) next.delete(sessionId); - return next; - }); + try { + const request = sessionCollectionRequestRef.current + 1; + sessionCollectionRequestRef.current = request; + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listSessions(options), + () => sessionCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== sessionCollectionRequestRef.current + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setSessionCollectionState("failed"); + setSessionCollectionError(message); + notify(message, "error"); + return false; + } + if (result.value === null) { + const message = "Session data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setSessionCollectionState("failed"); + setSessionCollectionError(message); + notify(message, "error"); + return false; + } + const stableSessions = result.value; + + const nextEnvironmentIdentities = new Map( + stableSessions.map((session) => [session.id, supportedEnvironmentIdentity(session.environment)]), + ); + const changedEnvironmentSessions = new Set(); + for (const [sessionId, identity] of nextEnvironmentIdentities) { + if (!environmentIdentitiesMatch(sessionEnvironmentIdentityRef.current.get(sessionId), identity)) { + changedEnvironmentSessions.add(sessionId); } - setSessions(result.value.data); + } + for (const sessionId of sessionEnvironmentIdentityRef.current.keys()) { + if (!nextEnvironmentIdentities.has(sessionId)) changedEnvironmentSessions.add(sessionId); + } + for (const sessionId of changedEnvironmentSessions) { + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + sessionEnvironmentIdentityRef.current = nextEnvironmentIdentities; + if (changedEnvironmentSessions.size) { + setEnvironmentObservations((current) => { + if (![...changedEnvironmentSessions].some((sessionId) => current.has(sessionId))) return current; + const next = new Map(current); + for (const sessionId of changedEnvironmentSessions) next.delete(sessionId); + return next; + }); + } + sessionsRef.current = stableSessions; + setSessions(stableSessions); + setSessionCollectionHasSnapshot(true); + if (!sessionAgentFilterRef.current) { setSelectedId((current) => { - if (current && result.value.data.some((session) => session.id === current)) return current; - return result.value.data[0]?.id ?? null; + const next = current && stableSessions.some((session) => session.id === current) + ? current + : stableSessions[0]?.id ?? null; + selectedIdRef.current = next; + return next; }); } setSessionCollectionState("ready"); return true; + } finally { + if (sessionCollectionAbortRef.current === controller) sessionCollectionAbortRef.current = null; + } + }, [core, coreGeneration, notify]); + + const refreshFilteredSessions = useCallback(async (agentId: string) => { + if ( + coreGeneration !== connectionGenerationRef.current || + sessionAgentFilterRef.current !== agentId + ) return false; + filteredSessionCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + filteredSessionCollectionAbortRef.current = controller; + const request = filteredSessionCollectionRequestRef.current + 1; + filteredSessionCollectionRequestRef.current = request; + setFilteredSessionCollectionState("connecting"); + setFilteredSessionCollectionError(null); + try { + const result = await settleCollection(() => listStableCollectionPages( + (options) => core.listSessions({ ...options, agentId }), + () => sessionCollectionRevisionRef.current, + controller.signal, + COLLECTION_RECONCILIATION_ATTEMPTS, + )); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== filteredSessionCollectionRequestRef.current || + sessionAgentFilterRef.current !== agentId + ) return false; + if (result.status === "rejected") { + if (isAbort(result.reason)) return false; + const message = errorMessage(result.reason); + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + if (result.value === null) { + const message = "Filtered Session data changed while the collection was loading. Refresh again to reconcile all loaded pages."; + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + const stableFilteredSessions = result.value; + if (stableFilteredSessions.some((session) => session.agent.id !== agentId)) { + const message = "Agent Core returned a Session outside the requested Agent filter."; + setFilteredSessionCollectionState("failed"); + setFilteredSessionCollectionError(message); + notify(message, "error"); + return false; + } + filteredSessionsRef.current = stableFilteredSessions; + setFilteredSessions(stableFilteredSessions); + setSelectedId((current) => { + const next = current && stableFilteredSessions.some((session) => session.id === current) + ? current + : stableFilteredSessions[0]?.id ?? null; + selectedIdRef.current = next; + return next; + }); + setFilteredSessionCollectionState("ready"); + return true; + } finally { + if (filteredSessionCollectionAbortRef.current === controller) { + filteredSessionCollectionAbortRef.current = null; + } } - const message = errorMessage(result.reason); - setSessionCollectionState("failed"); - setSessionCollectionError(message); - notify(message, "error"); - return false; }, [core, coreGeneration, notify]); + const refreshVaults = useCallback(async () => { + if (coreGeneration !== connectionGenerationRef.current) return false; + vaultCollectionAbortRef.current?.abort(); + const controller = new AbortController(); + vaultCollectionAbortRef.current = controller; + const request = vaultCollectionRequestRef.current + 1; + vaultCollectionRequestRef.current = request; + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + try { + const catalog = await loadVaultCatalog(core, controller.signal); + if ( + coreGeneration !== connectionGenerationRef.current || + request !== vaultCollectionRequestRef.current + ) return false; + setVaultCatalog(catalog); + setVaultSupported(true); + setVaultCollectionState("ready"); + return true; + } catch (error) { + if ( + coreGeneration !== connectionGenerationRef.current || + request !== vaultCollectionRequestRef.current || + isAbort(error) + ) return false; + if (error instanceof AgentCoreError && (error.status === 404 || error.status === 405)) { + setVaultCatalog(null); + setVaultSupported(false); + setVaultCollectionState("ready"); + setVaultCollectionError(null); + return false; + } + setVaultCollectionState("failed"); + setVaultCollectionError(errorMessage(error)); + return false; + } finally { + if (vaultCollectionAbortRef.current === controller) vaultCollectionAbortRef.current = null; + } + }, [core, coreGeneration]); + const refreshSession = useCallback( async (sessionId: string, signal?: AbortSignal): Promise => { if (coreGeneration !== connectionGenerationRef.current) return false; + const sessionScopeAgentId = sessionAgentFilterRef.current; const request = (sessionRequestRef.current.get(sessionId) ?? 0) + 1; const sessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; const itemRevision = itemEventRevisionRef.current.get(sessionId) ?? 0; @@ -421,18 +647,34 @@ export function App() { coreGeneration !== connectionGenerationRef.current || request !== sessionRequestRef.current.get(sessionId) ) return false; + if (sessionScopeAgentId && session.agent.id !== sessionScopeAgentId) { + throw new Error("Agent Core returned Session details outside the active Agent filter."); + } const currentSessionRevision = sessionEventRevisionRef.current.get(sessionId) ?? 0; const sessionIsCurrent = sessionRevision === currentSessionRevision; - const environmentId = selfHostedEnvironmentId(session.environment); + const environmentIdentity = supportedEnvironmentIdentity(session.environment); if (sessionIsCurrent) { - sessionEnvironmentIdRef.current.set(sessionId, environmentId); + sessionEnvironmentIdentityRef.current.set(sessionId, environmentIdentity); sessionCollectionRevisionRef.current += 1; setSessions((current) => { const found = current.some((value) => value.id === session.id); - return found + const next = found ? current.map((value) => (value.id === session.id ? session : value)) - : [session, ...current]; + : sessionScopeAgentId ? current : [session, ...current]; + sessionsRef.current = next; + return next; }); + if ( + sessionScopeAgentId && + sessionAgentFilterRef.current === sessionScopeAgentId + ) { + setFilteredSessions((current) => { + if (!current.some((value) => value.id === session.id)) return current; + const next = current.map((value) => (value.id === session.id ? session : value)); + filteredSessionsRef.current = next; + return next; + }); + } } if (selectedIdRef.current === sessionId) { const liveRevisionChanged = itemRevision !== (itemEventRevisionRef.current.get(sessionId) ?? 0); @@ -453,7 +695,7 @@ export function App() { } if (!sessionIsCurrent) return true; - if (!environmentId) { + if (!environmentIdentity) { if (environmentRevision === (environmentEventRevisionRef.current.get(sessionId) ?? 0)) { setEnvironmentObservations((current) => { if (!current.has(sessionId)) return current; @@ -472,7 +714,7 @@ export function App() { const environmentRead = { coreGeneration, sessionId, - environmentId, + ...environmentIdentity, sessionRequest: request, environmentRequest, streamEpoch: environmentStreamEpoch, @@ -481,17 +723,19 @@ export function App() { }; let observation: EnvironmentObservation; try { - const resource = await core.retrieveEnvironment(environmentId, { signal }); - observation = environmentObservationFromResource(resource, environmentId) - ?? unavailableEnvironmentObservation(environmentId); + const resource = await core.retrieveEnvironment(environmentIdentity.environmentId, { signal }); + observation = environmentObservationFromResource(resource, environmentIdentity) + ?? unavailableEnvironmentObservation(environmentIdentity); } catch (error) { if (isAbort(error)) return false; - observation = unavailableEnvironmentObservation(environmentId); + observation = unavailableEnvironmentObservation(environmentIdentity); } + const currentEnvironmentIdentity = sessionEnvironmentIdentityRef.current.get(sessionId); if (signal?.aborted || !environmentReadIsCurrent(environmentRead, { coreGeneration: connectionGenerationRef.current, sessionId, - environmentId: sessionEnvironmentIdRef.current.get(sessionId) ?? "", + environmentId: currentEnvironmentIdentity?.environmentId ?? "", + environmentType: currentEnvironmentIdentity?.environmentType ?? environmentIdentity.environmentType, sessionRequest: sessionRequestRef.current.get(sessionId) ?? 0, environmentRequest: environmentRequestRef.current.get(sessionId) ?? 0, streamEpoch: streamEpochRef.current, @@ -501,7 +745,11 @@ export function App() { })) return false; setEnvironmentObservations((current) => { const next = new Map(current); - next.set(sessionId, { observation, sessionId, streamEpoch: environmentStreamEpoch }); + const existing = current.get(sessionId); + const merged = existing?.streamEpoch === environmentStreamEpoch + ? mergeDurableEnvironmentObservation(existing.observation, observation) + : observation; + next.set(sessionId, { observation: merged, sessionId, streamEpoch: environmentStreamEpoch }); return next; }); return true; @@ -538,14 +786,57 @@ export function App() { void refreshAgents(); const refreshed = await refreshSessions(); if (!refreshed || generation !== connectionGenerationRef.current) return; + const filter = sessionAgentFilterRef.current; + if (filter) await refreshFilteredSessions(filter); + if (generation !== connectionGenerationRef.current) return; const sessionId = selectedIdRef.current; if (sessionId) await refreshSelectedSession(sessionId); })(); - }, [coreGeneration, refreshAgents, refreshSelectedSession, refreshSessions]); + }, [coreGeneration, refreshAgents, refreshFilteredSessions, refreshSelectedSession, refreshSessions]); + + const refreshDashboard = useCallback(() => { + void refreshAgents(); + void refreshSessions(); + void refreshVaults(); + const filter = sessionAgentFilterRef.current; + if (filter) void refreshFilteredSessions(filter); + }, [refreshAgents, refreshFilteredSessions, refreshSessions, refreshVaults]); + + const changeSessionAgentFilter = useCallback((agentId: string | null) => { + if (sessionAgentFilterRef.current === agentId) return; + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + filteredSessionCollectionRequestRef.current += 1; + sessionAgentFilterRef.current = agentId; + filteredSessionsRef.current = []; + setFilteredSessions([]); + setFilteredSessionCollectionError(null); + setFilteredSessionCollectionState(agentId ? "connecting" : "ready"); + const current = selectedIdRef.current; + const nextSelectedId = agentId + ? null + : current && sessionsRef.current.some((session) => session.id === current) + ? current + : sessionsRef.current[0]?.id ?? null; + selectedIdRef.current = nextSelectedId; + setSelectedId(nextSelectedId); + setSessionAgentFilter(agentId); + }, []); + + useEffect(() => { + cancelFailureRef.current = undefined; + functionResultFailuresRef.current.clear(); + }, [coreGeneration, selectedId]); useEffect(() => { setAgents([]); + setVaultCatalog(null); + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + setVaultSupported(null); setSessions([]); + setAgentCollectionHasSnapshot(false); + setSessionCollectionHasSnapshot(false); setItems([]); setTurns([]); setEnvironmentObservations(new Map()); @@ -557,7 +848,31 @@ export function App() { setSelectedId(null); void refreshAgents(); void refreshSessions(); - }, [refreshAgents, refreshSessions]); + void refreshVaults(); + }, [refreshAgents, refreshSessions, refreshVaults]); + + useEffect(() => { + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + filteredSessionCollectionRequestRef.current += 1; + filteredSessionsRef.current = []; + setFilteredSessions([]); + setFilteredSessionCollectionError(null); + if (!sessionAgentFilter) { + setFilteredSessionCollectionState("ready"); + return; + } + setFilteredSessionCollectionState("connecting"); + void refreshFilteredSessions(sessionAgentFilter); + return () => { + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + }; + }, [refreshFilteredSessions, sessionAgentFilter]); + + useEffect(() => { + if (vaultSupported === false && view === "vaults") setView("dashboard"); + }, [vaultSupported, view]); useEffect(() => { selectedSessionReadAbortRef.current?.abort(); @@ -596,16 +911,172 @@ export function App() { next.delete(selectedId); return next; }); - void refreshSelectedSession(selectedId); + const creationOwner = creationStreamOwnerRef.current; + if ( + creationOwner && + creationOwner.coreGeneration === coreGeneration && + creationOwner.sessionId === selectedId && + !creationOwner.controller.signal.aborted + ) creationOwner.startReconciliation?.(); + else void refreshSelectedSession(selectedId); return () => { selectedSessionReadAbortRef.current?.abort(); selectedSessionReadAbortRef.current = null; }; - }, [refreshSelectedSession, selectedId]); + }, [coreGeneration, refreshSelectedSession, selectedId]); + + const applyLiveSessionEvent = useCallback(( + event: SessionEvent, + context: LiveSessionEventContext, + ) => { + const { sessionId, streamEpoch, isCurrent, refreshCoordinator } = context; + if (!isCurrent()) return; + if (typeof event.session_id === "string" && event.session_id && event.session_id !== sessionId) return; + const eventType = typeof event.type === "string" ? event.type : ""; + const eventSession = matchingSessionSnapshot(event, sessionId); + if (eventSession) { + const nextEnvironmentIdentity = supportedEnvironmentIdentity(eventSession.environment); + const previousEnvironmentIdentity = sessionEnvironmentIdentityRef.current.get(sessionId); + if (!environmentIdentitiesMatch(previousEnvironmentIdentity, nextEnvironmentIdentity)) { + sessionEnvironmentIdentityRef.current.set(sessionId, nextEnvironmentIdentity); + environmentRequestRef.current.set( + sessionId, + (environmentRequestRef.current.get(sessionId) ?? 0) + 1, + ); + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + if (!current.has(sessionId)) return current; + const next = new Map(current); + next.delete(sessionId); + return next; + }); + } + } + const isEnvironmentEvent = eventType.startsWith("agent.session.environment."); + if (isEnvironmentEvent) { + environmentEventRevisionRef.current.set( + sessionId, + (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + setEnvironmentObservations((current) => { + const next = new Map(current); + const existing = current.get(sessionId); + const previous = existing?.streamEpoch === streamEpoch ? existing.observation : null; + const reduced = reduceEnvironmentObservation( + previous, + event, + sessionId, + sessionEnvironmentIdentityRef.current.get(sessionId) ?? null, + ); + if (reduced) next.set(sessionId, { observation: reduced, sessionId, streamEpoch }); + else next.delete(sessionId); + return next; + }); + } + if (eventSession) { + sessionEventRevisionRef.current.set( + sessionId, + (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + sessionCollectionRevisionRef.current += 1; + setSessions((current) => { + const next = current.map((session) => (session.id === eventSession.id ? eventSession : session)); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + if (!current.some((session) => session.id === eventSession.id)) return current; + const next = current.map((session) => (session.id === eventSession.id ? eventSession : session)); + filteredSessionsRef.current = next; + return next; + }); + } + const eventTurn = matchingTurnSnapshot(event, sessionId); + if (eventTurn) { + turnEventRevisionRef.current.set( + sessionId, + (turnEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + if (selectedIdRef.current === sessionId) { + const currentTurnsSessionId = turnsSessionIdRef.current; + turnsSessionIdRef.current = sessionId; + setTurnsSessionId(sessionId); + setTurns((current) => upsertTurn( + currentTurnsSessionId === sessionId ? current : [], + eventTurn, + )); + } + } + const isCommandOutputDelta = eventType === "agent.output.command_execution_output.delta"; + if (event.item || eventType.includes(".output_text.") || isCommandOutputDelta) { + itemEventRevisionRef.current.set( + sessionId, + (itemEventRevisionRef.current.get(sessionId) ?? 0) + 1, + ); + } + if (event.item && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => { + const sessionItems = updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (value) => value, + ); + const withoutTemporary = sessionItems.filter((item) => ( + event.item?.type !== "message" || event.item.role !== "assistant" || !item.id.startsWith(`stream:${event.item.turn_id}:`) + )); + return upsertSessionItem(withoutTemporary, event.item as SessionItem); + }); + } + if (eventType.includes(".output_text.") && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (sessionItems) => projectTextEvent(sessionItems, event), + )); + } + if (isCommandOutputDelta && selectedIdRef.current === sessionId) { + const currentItemsSessionId = itemsSessionIdRef.current; + itemsSessionIdRef.current = sessionId; + setItemsSessionId(sessionId); + setItems((current) => updateLiveSessionItems( + current, + currentItemsSessionId, + sessionId, + (sessionItems) => appendCommandOutputDelta(sessionItems, event), + )); + } + refreshCoordinator?.accept(event); + }, []); useEffect(() => { selectedStreamAbortRef.current?.abort(); - if (!selectedId) return; + const creationOwner = creationStreamOwnerRef.current; + if (!selectedId) { + creationOwner?.controller.abort(); + if (creationStreamOwnerRef.current === creationOwner) creationStreamOwnerRef.current = null; + return; + } + if ( + creationOwner && + !creationOwner.controller.signal.aborted && + creationOwner.coreGeneration === coreGeneration && + creationOwner.sessionId === selectedId + ) return; + if (creationOwner?.sessionId && creationOwner.sessionId !== selectedId) { + creationOwner.controller.abort(); + if (creationStreamOwnerRef.current === creationOwner) creationStreamOwnerRef.current = null; + } const sessionId = selectedId; const controller = new AbortController(); @@ -632,113 +1103,12 @@ export function App() { )); }; - const applyEvent = (event: SessionEvent) => { - if (!isCurrentStream()) return; - if (typeof event.session_id === "string" && event.session_id && event.session_id !== sessionId) return; - const eventType = typeof event.type === "string" ? event.type : ""; - const eventSession = matchingSessionSnapshot(event, sessionId); - if (eventSession) { - const nextEnvironmentId = selfHostedEnvironmentId(eventSession.environment); - const previousEnvironmentId = sessionEnvironmentIdRef.current.get(sessionId); - if (!environmentIdsMatch(previousEnvironmentId, nextEnvironmentId)) { - sessionEnvironmentIdRef.current.set(sessionId, nextEnvironmentId); - environmentRequestRef.current.set( - sessionId, - (environmentRequestRef.current.get(sessionId) ?? 0) + 1, - ); - environmentEventRevisionRef.current.set( - sessionId, - (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, - ); - setEnvironmentObservations((current) => { - if (!current.has(sessionId)) return current; - const next = new Map(current); - next.delete(sessionId); - return next; - }); - } - } - const isEnvironmentEvent = eventType.startsWith("agent.session.environment."); - if (isEnvironmentEvent) { - environmentEventRevisionRef.current.set( - sessionId, - (environmentEventRevisionRef.current.get(sessionId) ?? 0) + 1, - ); - setEnvironmentObservations((current) => { - const next = new Map(current); - const existing = current.get(sessionId); - const previous = existing?.streamEpoch === streamEpoch ? existing.observation : null; - const reduced = reduceEnvironmentObservation( - previous, - event, - sessionId, - sessionEnvironmentIdRef.current.get(sessionId) ?? null, - ); - if (reduced) next.set(sessionId, { observation: reduced, sessionId, streamEpoch }); - else next.delete(sessionId); - return next; - }); - } - if (eventSession) { - sessionEventRevisionRef.current.set( - sessionId, - (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, - ); - sessionCollectionRevisionRef.current += 1; - setSessions((current) => current.map((session) => (session.id === eventSession.id ? eventSession : session))); - } - const eventTurn = matchingTurnSnapshot(event, sessionId); - if (eventTurn) { - turnEventRevisionRef.current.set( - sessionId, - (turnEventRevisionRef.current.get(sessionId) ?? 0) + 1, - ); - if (selectedIdRef.current === sessionId) { - const currentTurnsSessionId = turnsSessionIdRef.current; - turnsSessionIdRef.current = sessionId; - setTurnsSessionId(sessionId); - setTurns((current) => upsertTurn( - currentTurnsSessionId === sessionId ? current : [], - eventTurn, - )); - } - } - if (event.item || eventType.includes(".output_text.")) { - itemEventRevisionRef.current.set( - sessionId, - (itemEventRevisionRef.current.get(sessionId) ?? 0) + 1, - ); - } - if (event.item && selectedIdRef.current === sessionId) { - const currentItemsSessionId = itemsSessionIdRef.current; - itemsSessionIdRef.current = sessionId; - setItemsSessionId(sessionId); - setItems((current) => { - const sessionItems = updateLiveSessionItems( - current, - currentItemsSessionId, - sessionId, - (value) => value, - ); - const withoutTemporary = sessionItems.filter((item) => ( - event.item?.type !== "message" || event.item.role !== "assistant" || !item.id.startsWith(`stream:${event.item.turn_id}:`) - )); - return upsertSessionItem(withoutTemporary, event.item as SessionItem); - }); - } - if (eventType.includes(".output_text.") && selectedIdRef.current === sessionId) { - const currentItemsSessionId = itemsSessionIdRef.current; - itemsSessionIdRef.current = sessionId; - setItemsSessionId(sessionId); - setItems((current) => updateLiveSessionItems( - current, - currentItemsSessionId, - sessionId, - (sessionItems) => projectTextEvent(sessionItems, event), - )); - } - refreshCoordinator?.accept(event); - }; + const applyEvent = (event: SessionEvent) => applyLiveSessionEvent(event, { + sessionId, + streamEpoch, + isCurrent: isCurrentStream, + refreshCoordinator, + }); refreshCoordinator = createDurableRefreshCoordinator( () => refreshSession(sessionId, controller.signal), @@ -817,7 +1187,7 @@ export function App() { controller.abort(); if (selectedStreamAbortRef.current === controller) selectedStreamAbortRef.current = null; }; - }, [core, coreGeneration, notify, refreshSession, selectedId, streamRetryRevision]); + }, [applyLiveSessionEvent, core, coreGeneration, notify, refreshSession, selectedId, streamRetryRevision]); const retryCurrentStream = useCallback(() => { requestCurrentStreamRetry(selectedIdRef.current, (sessionId) => { @@ -875,11 +1245,152 @@ export function App() { setAgents((current) => removeSavedAgent(current, agentId)); }; + const runVaultMutation = async (operation: () => Promise): Promise => { + if (coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Core connection changed before the Vault operation started."); + } + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + try { + const result = await operation(); + if (coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Core connection changed before the Vault operation was confirmed."); + } + return result; + } finally { + if ( + coreGeneration === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); + } + }; + + const refreshAfterVaultMutation = async () => { + if (coreGeneration === connectionGenerationRef.current) await refreshVaults(); + }; + + const vaultOperations: VaultOperations = { + async createVault(name, metadata) { + try { + await runVaultMutation(() => requestVaultCreate(core, name, metadata)); + notify("Vault created.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async createCredential(vaultId, name, serverURL, token) { + try { + const created = await runVaultMutation(() => core.createVaultCredential(vaultId, { + name, + auth: { type: "static_bearer", mcp_server_url: serverURL, token }, + })); + if (created.vault_id !== vaultId || created.name !== name || created.auth.mcp_server_url !== serverURL) { + throw new Error("Agent Core returned mismatched Credential metadata."); + } + notify("Credential created. Token remains hidden.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async replaceCredential(vaultId, credentialId, token) { + const baseline = vaultCatalog?.credentials.find((credential) => ( + credential.vault_id === vaultId && credential.id === credentialId + )); + if (!baseline || vaultCollectionState !== "ready") { + throw new Error("The latest Credential metadata is unavailable. Refresh before replacing its token."); + } + try { + const updated = await runVaultMutation(() => core.replaceVaultCredentialToken(vaultId, credentialId, { + auth: { type: "static_bearer", token }, + })); + if ( + updated.id !== baseline.id || updated.vault_id !== baseline.vault_id || + updated.name !== baseline.name || updated.auth.mcp_server_url !== baseline.auth.mcp_server_url || + updated.created_at !== baseline.created_at || updated.updated_at < baseline.updated_at + ) throw new Error("Agent Core returned mismatched Credential metadata after replacement."); + notify("Credential token replaced. Running work may still hold the previous token.", "success"); + } catch (error) { + await refreshAfterVaultMutation(); + throw error; + } + await refreshAfterVaultMutation(); + }, + async deleteCredential(vaultId, credentialId) { + try { + await runVaultMutation(() => core.deleteVaultCredential(vaultId, credentialId)); + notify("Credential deleted. Provider-side token was not revoked.", "success"); + } catch (error) { + let confirmedDeleted = false; + try { + await core.retrieveVaultCredential(vaultId, credentialId); + } catch (readError) { + confirmedDeleted = readError instanceof AgentCoreError && readError.status === 404; + } + await refreshAfterVaultMutation(); + if (confirmedDeleted) { + notify("Credential deletion reconciled from Core.", "success"); + return; + } + throw error; + } + await refreshAfterVaultMutation(); + }, + async deleteVault(vaultId) { + try { + await runVaultMutation(() => core.deleteVault(vaultId)); + notify("Vault and its Credentials deleted. Provider-side tokens were not revoked.", "success"); + } catch (error) { + let confirmedDeleted = false; + try { + await core.retrieveVault(vaultId); + } catch (readError) { + confirmedDeleted = readError instanceof AgentCoreError && readError.status === 404; + } + await refreshAfterVaultMutation(); + if (confirmedDeleted) { + notify("Vault deletion reconciled from Core.", "success"); + return; + } + throw error; + } + await refreshAfterVaultMutation(); + }, + refresh() { + void refreshVaults(); + }, + }; + const createSession = async (input: SessionStartInput) => { - const savedAgent = agents.find((agent) => agent.id === input.agentId); - const admissionBlocker = savedAgent ? knownSessionAdmissionBlocker(savedAgent) : "The selected saved Agent is not loaded."; - if (admissionBlocker) { - const error = new Error(`Session was not created. ${admissionBlocker}`); + const submittedAgent = validateSessionAgentSubmission( + input.agentMode, + input.agentId, + input.agent, + agents, + sessionVaultCatalog, + ); + if (submittedAgent.error || !submittedAgent.effectiveAgent || !submittedAgent.requestAgent && input.agentMode === "inline") { + const error = new Error(`Session was not created. ${submittedAgent.error ?? "The Agent request is invalid."}`); + notify(error.message, "error"); + throw error; + } + const effectiveAgent = submittedAgent.effectiveAgent; + const vaultPlan = deriveSessionVaultPlan( + effectiveAgent, + sessionVaultCatalog, + input.manualVaultIds, + ); + const expectedVaultIds = [...input.vaultIds].sort(); + if ( + vaultPlan.blocker || + vaultPlan.vaultIds.length !== expectedVaultIds.length || + vaultPlan.vaultIds.some((vaultId, index) => vaultId !== expectedVaultIds[index]) + ) { + const error = new Error(`Session was not created. ${vaultPlan.blocker ?? "The derived Vault attachments changed before submission."}`); notify(error.message, "error"); throw error; } @@ -888,37 +1399,197 @@ export function App() { notify(error.message, "error"); throw error; } - const rawEnvironment = input.environment as unknown as Record; - const environmentType = rawEnvironment.type; - const workspaceDirectory = typeof rawEnvironment.workspace_directory === "string" - ? rawEnvironment.workspace_directory - : ""; - const normalizedEnvironment = environmentType === "self_hosted" - ? sessionEnvironmentInput("self_hosted", workspaceDirectory) - : environmentType === "none" - ? sessionEnvironmentInput("none", "") - : sessionEnvironmentInput(environmentType, ""); + if (input.environment.type === "openai_hosted" && !__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) { + const error = new Error("Session was not created. Managed hosted Sessions are not enabled for this Web build."); + notify(error.message, "error"); + throw error; + } + const normalizedEnvironment = normalizeSessionEnvironmentInput(input.environment); const environmentInput = normalizedEnvironment.input; if (!environmentInput) { const error = new Error(`Session was not created. ${normalizedEnvironment.error ?? "The Environment input is invalid."}`); notify(error.message, "error"); throw error; } - const session = await run(() => core.createSession( - { agent_id: input.agentId, environment: environmentInput, stream: false }, - input.idempotencyKey, - )); - if (!session || coreGeneration !== connectionGenerationRef.current) { - throw new Error("The Session creation outcome could not be confirmed."); + const environmentAdmissionBlocker = sessionEnvironmentAdmissionBlocker(effectiveAgent, environmentInput.type); + if (environmentAdmissionBlocker) { + const error = new Error(`Session was not created. ${environmentAdmissionBlocker}`); + notify(error.message, "error"); + throw error; + } + const request = sessionCreateRequestPayload({ + ...(input.agentMode === "saved" ? { agentId: input.agentId } : {}), + ...(submittedAgent.requestAgent ? { agent: submittedAgent.requestAgent } : {}), + environment: environmentInput, + ...(input.input !== undefined ? { input: input.input } : {}), + metadata: input.metadata, + stream: input.stream, + vaultIds: vaultPlan.vaultIds, + }); + + const openSession = (session: AgentSession) => { + sessionCollectionRevisionRef.current += 1; + sessionEnvironmentIdentityRef.current.set(session.id, supportedEnvironmentIdentity(session.environment)); + setSessions((current) => { + const next = [session, ...current.filter((value) => value.id !== session.id)]; + sessionsRef.current = next; + return next; + }); + changeSessionAgentFilter(null); + selectedIdRef.current = session.id; + setSelectedId(session.id); + setItems([]); + itemsSessionIdRef.current = session.id; + setItemsSessionId(session.id); + setTurns([]); + turnsSessionIdRef.current = session.id; + setTurnsSessionId(session.id); + setView("sessions"); + }; + + if (!input.stream) { + const session = await run(() => core.createSession( + { ...request, stream: false }, + input.idempotencyKey, + )); + if (!session || coreGeneration !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed."); + } + openSession(session); + notify("Idle Session created. Opening live events…", "success"); + return; + } + + const generation = coreGeneration; + const operationRequest = operationRequestRef.current + 1; + operationRequestRef.current = operationRequest; + setBusy(true); + creationStreamOwnerRef.current?.controller.abort(); + const owner: CreationStreamOwner = { + controller: new AbortController(), + coreGeneration: generation, + sessionId: null, + streamEpoch: null, + }; + creationStreamOwnerRef.current = owner; + const recovery = createStreamRecoveryBuffer(); + let refreshCoordinator: DurableRefreshCoordinator | null = null; + let recoveryPromise: Promise | null = null; + let resolveCreated: (session: AgentSession) => void = () => undefined; + let rejectCreated: (error: unknown) => void = () => undefined; + const created = new Promise((resolve, reject) => { + resolveCreated = resolve; + rejectCreated = reject; + }); + const isCurrentCreationStream = () => ( + creationStreamOwnerRef.current === owner && + !owner.controller.signal.aborted && + generation === connectionGenerationRef.current && + owner.sessionId !== null && + owner.streamEpoch !== null && + streamEpochRef.current === owner.streamEpoch && + selectedIdRef.current === owner.sessionId + ); + const applyCreationEvent = (event: SessionEvent) => { + if (!owner.sessionId || owner.streamEpoch === null) return; + applyLiveSessionEvent(event, { + sessionId: owner.sessionId, + streamEpoch: owner.streamEpoch, + isCurrent: isCurrentCreationStream, + refreshCoordinator, + }); + }; + const handoffToGetStream = async () => { + const sessionId = owner.sessionId; + if (!sessionId) return; + owner.startReconciliation?.(); + await recoveryPromise; + if (!isCurrentCreationStream()) return; + setStreamConnection({ sessionId, state: "recovering", error: null }); + await refreshSession(sessionId, owner.controller.signal); + if (!isCurrentCreationStream()) return; + refreshCoordinator?.dispose(); + recovery.invalidate(); + if (creationStreamOwnerRef.current === owner) creationStreamOwnerRef.current = null; + owner.controller.abort(); + setStreamRetryRevision((current) => current + 1); + }; + + void core.createSessionStream(request, input.idempotencyKey, { + signal: owner.controller.signal, + onSession: (session) => { + if ( + creationStreamOwnerRef.current !== owner || + owner.controller.signal.aborted || + generation !== connectionGenerationRef.current + ) return; + owner.sessionId = session.id; + owner.streamEpoch = streamEpochRef.current + 1; + streamEpochRef.current = owner.streamEpoch; + const token = recovery.begin(); + refreshCoordinator = createDurableRefreshCoordinator( + () => refreshSession(session.id, owner.controller.signal), + ); + owner.startReconciliation = () => { + if (recoveryPromise || !isCurrentCreationStream()) return; + recoveryPromise = refreshSession(session.id, owner.controller.signal) + .finally(() => recovery.finish(token, isCurrentCreationStream, applyCreationEvent)); + }; + openSession(session); + setStreamConnection({ sessionId: session.id, state: "listening", error: null }); + owner.startReconciliation(); + resolveCreated(session); + }, + onOpen: () => undefined, + onEvent: (event) => { + if (event.type === "agent.session.created") return; + recovery.accept(event, applyCreationEvent); + }, + }).then( + () => handoffToGetStream(), + (error: unknown) => { + if (!owner.sessionId) { + recovery.invalidate(); + refreshCoordinator?.dispose(); + if (creationStreamOwnerRef.current === owner) creationStreamOwnerRef.current = null; + owner.controller.abort(); + rejectCreated(error); + return; + } + return handoffToGetStream(); + }, + ).catch((error: unknown) => { + if ( + generation === connectionGenerationRef.current && + owner.sessionId && + selectedIdRef.current === owner.sessionId + ) { + setStreamConnection({ sessionId: owner.sessionId, state: "failed", error: errorMessage(error) }); + } + }); + + try { + await created; + if (generation !== connectionGenerationRef.current) { + throw new Error("The Session creation outcome could not be confirmed after the Core connection changed."); + } + notify( + input.input === undefined + ? input.environment.type === "openai_hosted" + ? "Managed hosted Session created. Live creation events are connected." + : "Idle Session created. Live creation events are connected." + : "Session created with initial input. Live creation events are connected.", + "success", + ); + } catch (error) { + if (generation === connectionGenerationRef.current) notify(errorMessage(error), "error"); + throw error; + } finally { + if ( + generation === connectionGenerationRef.current && + operationRequest === operationRequestRef.current + ) setBusy(false); } - sessionCollectionRevisionRef.current += 1; - setSessions((current) => [session, ...current.filter((value) => value.id !== session.id)]); - setSelectedId(session.id); - setItems([]); - itemsSessionIdRef.current = session.id; - setItemsSessionId(session.id); - setView("sessions"); - notify("Idle Session created. Opening live events…", "success"); }; const retrieveSessionForAction = useCallback(async (sessionId: string) => { @@ -949,7 +1620,16 @@ export function App() { sessionId, (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, ); - setSessions((current) => replaceSessionMetadata(current, error.latestSession as AgentSession)); + setSessions((current) => { + const next = replaceSessionMetadata(current, error.latestSession as AgentSession); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = replaceSessionMetadata(current, error.latestSession as AgentSession); + filteredSessionsRef.current = next; + return next; + }); } throw error; } @@ -962,7 +1642,16 @@ export function App() { sessionId, (sessionEventRevisionRef.current.get(sessionId) ?? 0) + 1, ); - setSessions((current) => replaceSessionMetadata(current, updated)); + setSessions((current) => { + const next = replaceSessionMetadata(current, updated); + sessionsRef.current = next; + return next; + }); + setFilteredSessions((current) => { + const next = replaceSessionMetadata(current, updated); + filteredSessionsRef.current = next; + return next; + }); notify("Session metadata updated.", "success"); return updated; }; @@ -971,7 +1660,7 @@ export function App() { const selectedAtCompletion = selectedIdRef.current; const deletingSelected = selectedAtCompletion === sessionId; const nextSelectedId = selectionAfterSessionDelete( - sessionsRef.current, + sessionAgentFilterRef.current ? filteredSessionsRef.current : sessionsRef.current, selectedAtCompletion, sessionId, ); @@ -985,13 +1674,18 @@ export function App() { increment(turnEventRevisionRef.current); increment(environmentEventRevisionRef.current); increment(environmentRequestRef.current); - sessionEnvironmentIdRef.current.delete(sessionId); + sessionEnvironmentIdentityRef.current.delete(sessionId); setSessions((current) => { const next = removeSession(current, sessionId); sessionsRef.current = next; return next; }); + setFilteredSessions((current) => { + const next = removeSession(current, sessionId); + filteredSessionsRef.current = next; + return next; + }); setSessionSendFailures((current) => { if (!current.has(sessionId)) return current; const next = new Map(current); @@ -1012,6 +1706,10 @@ export function App() { selectedSessionReadAbortRef.current = null; selectedStreamAbortRef.current?.abort(); selectedStreamAbortRef.current = null; + if (creationStreamOwnerRef.current?.sessionId === sessionId) { + creationStreamOwnerRef.current.controller.abort(); + creationStreamOwnerRef.current = null; + } itemsSessionIdRef.current = null; turnsSessionIdRef.current = null; setItems([]); @@ -1104,24 +1802,79 @@ export function App() { const cancel = async () => { const sessionId = selectedId; if (!sessionId) return; - await run(() => core.cancelTurn(sessionId), "Cancellation requested."); + const pending = beginPendingSend( + sessionId, + "agent.session.input.cancel", + cancelFailureRef.current, + ); + cancelFailureRef.current = undefined; + try { + await run( + () => core.cancelTurn(sessionId, pending.idempotencyKey), + "Cancellation requested.", + ); + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + selectedIdRef.current === sessionId + ) cancelFailureRef.current = failPendingSend(pending, error, errorMessage(error)); + throw error; + } if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + cancelFailureRef.current = undefined; await refreshSelectedSession(sessionId); }; const submitFunctionResult = async (input: FunctionResultInput) => { const sessionId = selectedId; if (!sessionId) return; - await run(() => core.submitFunctionResult(sessionId, input), "Function result submitted."); + const actionKey = functionResultActionKey(sessionId, input); + const pending = beginPendingFunctionResult( + sessionId, + input, + functionResultFailuresRef.current.get(actionKey), + ); + functionResultFailuresRef.current.delete(actionKey); + try { + await run( + () => core.submitFunctionResult(sessionId, input, pending.idempotencyKey), + "Function result submitted.", + ); + } catch (error) { + if ( + coreGeneration === connectionGenerationRef.current && + selectedIdRef.current === sessionId + ) { + functionResultFailuresRef.current.set( + actionKey, + failPendingFunctionResult(pending, error, errorMessage(error)), + ); + } + throw error; + } if (coreGeneration !== connectionGenerationRef.current || selectedIdRef.current !== sessionId) return; + functionResultFailuresRef.current.delete(actionKey); await refreshSelectedSession(sessionId); }; + const listEnvironmentFiles = useCallback(( + environmentId: string, + options: Parameters[1], + ) => core.listEnvironmentFiles(environmentId, options), [core]); + + const createEnvironmentFile = useCallback(( + environmentId, + input, + options, + ) => core.createEnvironmentFile(environmentId, input, options), [core]); + const applyConnection = (next: CoreConnection) => { const normalized = { ...next, baseUrl: next.baseUrl.trim() || "/v1", token: next.token.trim() }; connectionGenerationRef.current += 1; agentCollectionRequestRef.current += 1; sessionCollectionRequestRef.current += 1; + filteredSessionCollectionRequestRef.current += 1; + vaultCollectionRequestRef.current += 1; agentCollectionRevisionRef.current = 0; sessionCollectionRevisionRef.current = 0; sessionRequestRef.current.clear(); @@ -1130,24 +1883,51 @@ export function App() { turnEventRevisionRef.current.clear(); environmentEventRevisionRef.current.clear(); environmentRequestRef.current.clear(); - sessionEnvironmentIdRef.current.clear(); + sessionEnvironmentIdentityRef.current.clear(); operationRequestRef.current += 1; streamEpochRef.current += 1; selectedSessionReadAbortRef.current?.abort(); selectedSessionReadAbortRef.current = null; selectedStreamAbortRef.current?.abort(); selectedStreamAbortRef.current = null; + creationStreamOwnerRef.current?.controller.abort(); + creationStreamOwnerRef.current = null; + agentCollectionAbortRef.current?.abort(); + agentCollectionAbortRef.current = null; + sessionCollectionAbortRef.current?.abort(); + sessionCollectionAbortRef.current = null; + filteredSessionCollectionAbortRef.current?.abort(); + filteredSessionCollectionAbortRef.current = null; + vaultCollectionAbortRef.current?.abort(); + vaultCollectionAbortRef.current = null; saveConnection(normalized); setBusy(false); setAgentCollectionState("connecting"); setAgentCollectionError(null); + setAgentCollectionHasSnapshot(false); setSessionCollectionState("connecting"); setSessionCollectionError(null); + setSessionCollectionHasSnapshot(false); + sessionAgentFilterRef.current = null; + filteredSessionsRef.current = []; + setSessionAgentFilter(null); + setFilteredSessions([]); + setFilteredSessionCollectionState("connecting"); + setFilteredSessionCollectionError(null); + setVaultCollectionState("connecting"); + setVaultCollectionError(null); + setVaultSupported(null); + setVaultCatalog(null); + setAgents([]); + setSessions([]); + setItems([]); setSelectedId(null); setSelectedSessionLoad({ sessionId: null, state: "idle", error: null }); setTurnCollectionLoad({ sessionId: null, state: "idle", error: null }); setStreamConnection({ sessionId: null, state: "idle", error: null }); setSessionSendFailures(new Map()); + cancelFailureRef.current = undefined; + functionResultFailuresRef.current.clear(); setEnvironmentObservations(new Map()); itemsSessionIdRef.current = null; setItemsSessionId(null); @@ -1165,13 +1945,9 @@ export function App() { }; const openSessionSetup = (agentId?: string) => { - if (agentId) { - const agent = agents.find((candidate) => candidate.id === agentId); - const blocker = agent ? knownSessionAdmissionBlocker(agent) : "The selected saved Agent is not loaded."; - if (blocker) { - notify(`Session was not created. ${blocker}`, "error"); - return; - } + if (agentId && !agents.some((candidate) => candidate.id === agentId)) { + notify("Session setup could not open because the selected saved Agent is not loaded.", "error"); + return; } setView("sessions"); sessionCreateSequenceRef.current += 1; @@ -1201,6 +1977,7 @@ export function App() { setView(nextView)} + showVaults={vaultSupported === true} /> @@ -1242,23 +2019,43 @@ export function App() {
!knownSessionAdmissionBlocker(agent)) && !busy} + canStartSession={sessionCollectionState === "ready" && !busy} onCreateAgent={openAgentSetup} onStartSession={() => openSessionSetup()} />
+ {view === "dashboard" ? ( + { + changeSessionAgentFilter(null); + selectedIdRef.current = sessionId; + setSelectedId(sessionId); + setView("sessions"); + }} + /> + ) : null} {view === "sessions" ? ( { if (selectedId) void refreshSelectedSession(selectedId); @@ -1293,6 +2095,7 @@ export function App() { coreBaseUrl={connection.baseUrl} coreError={agentCollectionError} coreState={agentCollectionState} + vaultCatalog={sessionVaultCatalog} createRequest={agentCreateRequest ?? 0} onCreateRequestConsumed={consumeAgentCreateRequest} onCreate={createAgent} @@ -1303,7 +2106,33 @@ export function App() { onUpdate={updateAgent} /> ) : null} - {view === "system" ? : null} + {view === "vaults" && vaultSupported === true ? ( + + ) : null} + {view === "system" ? ( + + ) : null}
diff --git a/apps/web/src/components/ConnectionModal.test.tsx b/apps/web/src/components/ConnectionModal.test.tsx index 05abe1a..573b912 100644 --- a/apps/web/src/components/ConnectionModal.test.tsx +++ b/apps/web/src/components/ConnectionModal.test.tsx @@ -93,7 +93,7 @@ describe("Agent Core connection modes", () => { expect(markup).toContain("Legacy troubleshooting · 043 snapshot"); expect(markup).toContain("Parsar Core setup"); expect(markup).toContain("98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"); - expect(markup).toContain("2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e"); + expect(markup).toContain("c31f81677a8b16c53b665de9075181df837a0032"); expect(markup).not.toContain("0438880ab21aa16d05cb91a4c7f91cc0abc12358"); expect(markup).not.toContain("f7cdf591396529880d80f8211fc7a0f4768fdf46"); expect(markup).not.toContain("8cc2898ca42b272cb3771234ee6a0ad0d2e932ba"); diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index c30a1ba..00571a4 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -29,7 +29,7 @@ export type ConnectionProbeState = | { status: "complete"; result: CoreProbeResult }; const webBaseline = "98c5b3312ad33e1fae8b94283a011eb3e5f4ee2c"; -const parsarBaseline = "2b34ea4630a5a0daf90e745fe1af3edcfa4f0e9e"; +const parsarBaseline = "c31f81677a8b16c53b665de9075181df837a0032"; const operatorGuideUrl = `https://github.com/MiniMax-AI-Dev/agents-core-web/blob/${webBaseline}/docs/core-connection.md`; const troubleshootingUrl = `${operatorGuideUrl}#troubleshooting`; const parsarCoreSetupUrl = `https://github.com/MiniMax-AI-Dev/parsar/blob/${parsarBaseline}/services/agents-api/README.md#standalone-http-service`; diff --git a/apps/web/src/components/ProductNavigation.test.tsx b/apps/web/src/components/ProductNavigation.test.tsx index c87c068..75029bc 100644 --- a/apps/web/src/components/ProductNavigation.test.tsx +++ b/apps/web/src/components/ProductNavigation.test.tsx @@ -12,9 +12,20 @@ describe("Product navigation", () => { expect(html).toContain('aria-label="Agents product"'); expect(html).toContain('class="main-nav product-navigation"'); expect(html).toContain("Workspace"); + expect(html).toContain("Dashboard"); expect(html).toContain("Agents"); expect(html).toContain("Sessions"); + expect(html).not.toContain("Vaults"); expect(html).not.toContain("Environments"); expect(html).toContain('aria-current="page"'); }); + + it("shows Vaults only after Core capability discovery succeeds", () => { + const html = renderToStaticMarkup( + undefined} />, + ); + + expect(html).toContain("Vaults"); + expect(html).toContain('aria-current="page"'); + }); }); diff --git a/apps/web/src/components/ProductNavigation.tsx b/apps/web/src/components/ProductNavigation.tsx index c623497..75b672e 100644 --- a/apps/web/src/components/ProductNavigation.tsx +++ b/apps/web/src/components/ProductNavigation.tsx @@ -1,23 +1,27 @@ -import { Bot, MessageSquare, type LucideIcon } from "lucide-react"; +import { Bot, LayoutDashboard, MessageSquare, Vault, type LucideIcon } from "lucide-react"; -export type ProductView = "agents" | "sessions"; +export type ProductView = "dashboard" | "agents" | "sessions" | "vaults"; const productViews: Array<{ id: ProductView; label: string; icon: LucideIcon }> = [ + { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, { id: "agents", label: "Agents", icon: Bot }, { id: "sessions", label: "Sessions", icon: MessageSquare }, + { id: "vaults", label: "Vaults", icon: Vault }, ]; export function ProductNavigation({ active, onSelect, + showVaults = false, }: { active: ProductView | null; onSelect: (view: ProductView) => void; + showVaults?: boolean; }) { return (