diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72bcd1f5b..d798c1976 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,9 +124,9 @@ jobs: CORE_DISTRIBUTION_OFFLINE: ${{ (github.event_name == 'push' || inputs.offline) && '1' || '0' }} run: | inputs="$HOME/.oac/build/release-inputs/inputs.json" - AGENTS_RUNTIME_CODEX_PACKAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" + CODEX_CLI_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" MCODE_HARNESS_BUILD_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["mcode"])' "$inputs")" - export AGENTS_RUNTIME_CODEX_PACKAGE MCODE_HARNESS_BUILD_DIR + export CODEX_CLI_DIR MCODE_HARNESS_BUILD_DIR export CORE_DISTRIBUTION_RELEASE_BASE_URL="https://github.com/$RELEASE_REPOSITORY/releases/download/$RELEASE_TAG" bash scripts/build-core-distribution.sh mkdir -p "$HOME/.oac/build/release-upload" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04cbec682..95a989fe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,7 +142,7 @@ Native adapter changes require their build/check targets and live provider accep | Provider ownership labels | `io.oac.*` | | E2B metadata | `oac_*` | -Provider bootstrap, Runtime images and Harness adapters must agree on these names. The separate Parsar product integration settings keep their own names. +Provider bootstrap, Runtime images and Harness adapters must agree on these names. The [installation version policy](docs/getting-started/operations.md#installation-version-policy) owns release changes and preservation of installed data and resources. diff --git a/Makefile b/Makefile index a69748107..f755dec39 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-codex-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime help: @printf '%s\n' 'make build-core Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make build-sandbox-io Build the Sandbox I/O service for Linux' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' @@ -151,8 +151,8 @@ check-mcode-harness: @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh -build-agents-runtime: - ./scripts/build-agents-runtime.sh +build-codex-runtime: + ./scripts/build-codex-runtime.sh build-claude-runtime: ./scripts/build-claude-runtime.sh diff --git a/README.md b/README.md index 6277909fd..5713ac292 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source, self-hosted implementation of the OpenAI Agents API with multiple native harnesses. -[Website](https://minimax-ai.github.io/OpenAgentCore/) · [Install](#install) · [Call the API](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/quickstart) · [Documentation](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/) · [Contributing](CONTRIBUTING.md) +[Website](https://openagentcore.dev/) · [Install](#install) · [Call the API](https://openagentcore.dev/docs/getting-started/quickstart) · [Documentation](https://openagentcore.dev/docs/getting-started/) · [Contributing](CONTRIBUTING.md) **English** · [简体中文](README.zh-CN.md) @@ -17,9 +17,9 @@ An open-source, self-hosted implementation of the OpenAI Agents API with multipl OpenAgentCore runs AI agents on your own infrastructure behind the [OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview). - **Same API as OpenAI.** Point the [OpenAI Agent API](https://developers.openai.com/api/docs/guides/agents/sdk), or plain HTTP, at your installation. No new client to learn. -- **Your choice of agent.** Each [Session](https://minimax-ai.github.io/OpenAgentCore/docs/api/public-agent-api) runs a [native harness](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/harness-onboarding): [Codex](https://github.com/openai/codex), [Claude Code](https://code.claude.com/docs/en/overview) or [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), with the [model provider you configure](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/model-execution). -- **Your choice of machine.** Agents work in a [managed sandbox](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/sandbox-deployment) ([Docker](https://www.docker.com/), [microsandbox](https://github.com/zerocore-ai/microsandbox) or [E2B](https://e2b.dev/)), or on your own Linux, macOS or Windows machine. -- **Every part is replaceable.** [Sandboxes](https://minimax-ai.github.io/OpenAgentCore/docs/sandbox-provider), [harnesses](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/harness-onboarding) and [model providers](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/model-execution) plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary). +- **Your choice of agent.** Each [Session](https://openagentcore.dev/docs/api/public-agent-api) runs a [native harness](https://openagentcore.dev/contracts/agents-api/harness-onboarding): [Codex](https://github.com/openai/codex), [Claude Code](https://code.claude.com/docs/en/overview) or [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), with the [model provider you configure](https://openagentcore.dev/contracts/agents-api/model-execution). +- **Your choice of machine.** Agents work in a [managed sandbox](https://openagentcore.dev/contracts/agents-api/sandbox-deployment) ([Docker](https://www.docker.com/), [microsandbox](https://github.com/zerocore-ai/microsandbox) or [E2B](https://e2b.dev/)), or on your own Linux, macOS or Windows machine. +- **Every part is replaceable.** [Sandboxes](https://openagentcore.dev/docs/sandbox-provider), [harnesses](https://openagentcore.dev/contracts/agents-api/harness-onboarding) and [model providers](https://openagentcore.dev/contracts/agents-api/model-execution) plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary). ## How it fits together @@ -29,10 +29,10 @@ Applications and operators use these Core APIs: | API | Path | Used by | | --- | --- | --- | -| **[Agents API](https://minimax-ai.github.io/OpenAgentCore/docs/api/public-agent-api)** | `/v1` | Your applications. Same protocol as [OpenAI's Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) | -| **[Core API](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/admin-api)** | `/core/v1` | Operators, through Web | +| **[Agents API](https://openagentcore.dev/docs/api/public-agent-api)** | `/v1` | Your applications. Same protocol as [OpenAI's Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) | +| **[Core API](https://openagentcore.dev/contracts/agents-api/admin-api)** | `/core/v1` | Operators, through Web | -Core keeps durable execution state. The Runtime runs the chosen harness inside the Environment. Each connection is a defined protocol, so any part can be replaced on its own. See the [architecture guide](https://minimax-ai.github.io/OpenAgentCore/docs/architecture). +Core keeps durable execution state. The Runtime runs the chosen harness inside the Environment. Each connection is a defined protocol, so any part can be replaced on its own. See the [architecture guide](https://openagentcore.dev/docs/architecture). ## Screenshots @@ -53,20 +53,20 @@ Then: 1. **Sign in to Web**, the admin console, with the Core key the installer created, and **configure the domain and HTTPS**. 2. **Set a default model** and **issue a Project API key**. 3. **Add execution capacity:** a node, E2B, or your own machine. -4. **[Run your first Session](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/quickstart)** with the OpenAI SDK. +4. **[Run your first Session](https://openagentcore.dev/docs/getting-started/quickstart)** with the OpenAI SDK. -The [installation guide](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/install) covers each step, HTTPS and a quick local trial. Listen addresses, ports and other options: [installation options](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/install-options). +The [installation guide](https://openagentcore.dev/docs/getting-started/install) covers each step, HTTPS and a quick local trial. Listen addresses, ports and other options: [installation options](https://openagentcore.dev/docs/getting-started/install-options). ## Documentation | I want to | Start with | | --- | --- | -| Install and operate an installation | [Installation](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/install), then [operations](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/operations) | -| Build an application on the API | [Quickstart](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/quickstart), then the [Agents API guide](https://minimax-ai.github.io/OpenAgentCore/docs/api/public-agent-api) | -| See a complete application | [Examples](https://minimax-ai.github.io/OpenAgentCore/docs/examples) | -| Run agents on my own machine | [Self-hosted execution](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/self-hosted) | -| Check Harness capabilities and limits | [Harness capabilities](https://minimax-ai.github.io/OpenAgentCore/contracts/agents-api/harness-capabilities) | -| Understand the design | [Architecture](https://minimax-ai.github.io/OpenAgentCore/docs/architecture) | -| Add a sandbox, harness or other component | [Developer guide](https://minimax-ai.github.io/OpenAgentCore/docs/development) | - -All pages: [documentation index](https://minimax-ai.github.io/OpenAgentCore/docs/getting-started/). Before changing code, read the [contributor rules](CONTRIBUTING.md). +| Install and operate an installation | [Installation](https://openagentcore.dev/docs/getting-started/install), then [operations](https://openagentcore.dev/docs/getting-started/operations) | +| Build an application on the API | [Quickstart](https://openagentcore.dev/docs/getting-started/quickstart), then the [Agents API guide](https://openagentcore.dev/docs/api/public-agent-api) | +| See a complete application | [Examples](https://openagentcore.dev/docs/examples) | +| Run agents on my own machine | [Self-hosted execution](https://openagentcore.dev/docs/getting-started/self-hosted) | +| Check Harness capabilities and limits | [Harness capabilities](https://openagentcore.dev/contracts/agents-api/harness-capabilities) | +| Understand the design | [Architecture](https://openagentcore.dev/docs/architecture) | +| Add a sandbox, harness or other component | [Developer guide](https://openagentcore.dev/docs/development) | + +All pages: [documentation index](https://openagentcore.dev/docs/getting-started/). Before changing code, read the [contributor rules](CONTRIBUTING.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index b2d5f42a9..5414cea3c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ OpenAI Agents API 的开源实现,支持多种原生执行引擎,可部署在自己的基础设施上。 -[官网](https://minimax-ai.github.io/OpenAgentCore/zh/) · [安装](#安装) · [调用 API](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/quickstart) · [文档](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/) · [参与贡献](CONTRIBUTING.md) +[官网](https://openagentcore.dev/zh/) · [安装](#安装) · [调用 API](https://openagentcore.dev/zh/docs/getting-started/quickstart) · [文档](https://openagentcore.dev/zh/docs/getting-started/) · [参与贡献](CONTRIBUTING.md) [English](README.md) · **简体中文** @@ -20,19 +20,19 @@ OpenAI Agents API 的开源实现,支持多种原生执行引擎,可部署 | API | 路径 | 调用方 | | --- | --- | --- | -| **[Agents API](https://minimax-ai.github.io/OpenAgentCore/zh/docs/api/public-agent-api)** | `/v1` | 你的应用,与 [OpenAI 的 Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) 协议一致 | -| **[Core API](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/admin-api)** | `/core/v1` | 管理员,通过 Web 调用 | +| **[Agents API](https://openagentcore.dev/zh/docs/api/public-agent-api)** | `/v1` | 你的应用,与 [OpenAI 的 Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) 协议一致 | +| **[Core API](https://openagentcore.dev/zh/contracts/agents-api/admin-api)** | `/core/v1` | 管理员,通过 Web 调用 | -持久化执行状态由 Core 保存;Runtime 在 Environment 中运行所选 Harness。各部件之间都通过既定协议连接, 任何一个都可以单独替换。详见[架构说明](https://minimax-ai.github.io/OpenAgentCore/zh/docs/architecture)。 +持久化执行状态由 Core 保存;Runtime 在 Environment 中运行所选 Harness。各部件之间都通过既定协议连接, 任何一个都可以单独替换。详见[架构说明](https://openagentcore.dev/zh/docs/architecture)。 ## 这是什么 OpenAgentCore 在你自己的基础设施上运行 AI Agent,对外提供 [OpenAI Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview)。 - **与 OpenAI 相同的 API。** [官方 OpenAI SDK](https://developers.openai.com/api/docs/guides/agents/sdk) 或直接 HTTP 调用,改一下地址即可,无需学习新客户端。 -- **自选 Agent。** 每个 [Session](https://minimax-ai.github.io/OpenAgentCore/zh/docs/api/public-agent-api) 运行一个[原生 Harness](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/harness-onboarding):[Codex](https://github.com/openai/codex)、[Claude Code](https://code.claude.com/docs/en/overview) 或 [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), 使用[你配置的模型供应商](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/model-execution)。 -- **自选机器。** Agent 可以在[托管沙箱](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/sandbox-deployment)([Docker](https://www.docker.com/)、[microsandbox](https://github.com/zerocore-ai/microsandbox) 或 [E2B](https://e2b.dev/))里工作, 也可以在你自己的 Linux、macOS 或 Windows 机器上工作。 -- **每个部件都可替换。** [沙箱](https://minimax-ai.github.io/OpenAgentCore/zh/docs/sandbox-provider)、[Harness](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/harness-onboarding) 和[模型供应商](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/model-execution)都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。 +- **自选 Agent。** 每个 [Session](https://openagentcore.dev/zh/docs/api/public-agent-api) 运行一个[原生 Harness](https://openagentcore.dev/zh/contracts/agents-api/harness-onboarding):[Codex](https://github.com/openai/codex)、[Claude Code](https://code.claude.com/docs/en/overview) 或 [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), 使用[你配置的模型供应商](https://openagentcore.dev/zh/contracts/agents-api/model-execution)。 +- **自选机器。** Agent 可以在[托管沙箱](https://openagentcore.dev/zh/contracts/agents-api/sandbox-deployment)([Docker](https://www.docker.com/)、[microsandbox](https://github.com/zerocore-ai/microsandbox) 或 [E2B](https://e2b.dev/))里工作, 也可以在你自己的 Linux、macOS 或 Windows 机器上工作。 +- **每个部件都可替换。** [沙箱](https://openagentcore.dev/zh/docs/sandbox-provider)、[Harness](https://openagentcore.dev/zh/contracts/agents-api/harness-onboarding) 和[模型供应商](https://openagentcore.dev/zh/contracts/agents-api/model-execution)都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。 ## 界面预览 @@ -53,20 +53,20 @@ curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/ 1. 用安装器生成的 Core key **登录 Web**(管理控制台),并**配置域名和 HTTPS**。 2. **设置默认模型**,并**签发 Project API key**。 3. **添加执行资源**:节点、E2B,或你自己的机器。 -4. 用 OpenAI SDK **[运行第一个 Session](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/quickstart)**。 +4. 用 OpenAI SDK **[运行第一个 Session](https://openagentcore.dev/zh/docs/getting-started/quickstart)**。 -[安装指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/install)详细介绍每一步,以及 HTTPS 配置和本地快速试用。监听地址、端口等参数见[安装配置选项](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/install-options)。 +[安装指南](https://openagentcore.dev/zh/docs/getting-started/install)详细介绍每一步,以及 HTTPS 配置和本地快速试用。监听地址、端口等参数见[安装配置选项](https://openagentcore.dev/zh/docs/getting-started/install-options)。 ## 文档 | 我想要 | 从这里开始 | | --- | --- | -| 安装并运维 | [安装指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/install),然后看[运维](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/operations) | -| 基于 API 开发应用 | [快速开始](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/quickstart),然后看 [Agents API 指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/api/public-agent-api) | -| 看一个完整的应用 | [示例](https://minimax-ai.github.io/OpenAgentCore/zh/docs/examples) | -| 在自己的机器上运行 Agent | [自托管执行](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/self-hosted) | -| 查看 Harness 能力和限制 | [Harness 能力](https://minimax-ai.github.io/OpenAgentCore/zh/contracts/agents-api/harness-capabilities) | -| 了解设计 | [架构说明](https://minimax-ai.github.io/OpenAgentCore/zh/docs/architecture) | -| 接入新的沙箱、Harness 或其他组件 | [开发指南](https://minimax-ai.github.io/OpenAgentCore/zh/docs/development) | - -全部文档见[文档目录](https://minimax-ai.github.io/OpenAgentCore/zh/docs/getting-started/)。修改代码前请阅读[贡献规范](CONTRIBUTING.md)。 +| 安装并运维 | [安装指南](https://openagentcore.dev/zh/docs/getting-started/install),然后看[运维](https://openagentcore.dev/zh/docs/getting-started/operations) | +| 基于 API 开发应用 | [快速开始](https://openagentcore.dev/zh/docs/getting-started/quickstart),然后看 [Agents API 指南](https://openagentcore.dev/zh/docs/api/public-agent-api) | +| 看一个完整的应用 | [示例](https://openagentcore.dev/zh/docs/examples) | +| 在自己的机器上运行 Agent | [自托管执行](https://openagentcore.dev/zh/docs/getting-started/self-hosted) | +| 查看 Harness 能力和限制 | [Harness 能力](https://openagentcore.dev/zh/contracts/agents-api/harness-capabilities) | +| 了解设计 | [架构说明](https://openagentcore.dev/zh/docs/architecture) | +| 接入新的沙箱、Harness 或其他组件 | [开发指南](https://openagentcore.dev/zh/docs/development) | + +全部文档见[文档目录](https://openagentcore.dev/zh/docs/getting-started/)。修改代码前请阅读[贡献规范](CONTRIBUTING.md)。 diff --git a/apps/daemon/cmd/oac-daemon/main.go b/apps/daemon/cmd/oac-daemon/main.go index b704d8cc3..7ed9bf050 100644 --- a/apps/daemon/cmd/oac-daemon/main.go +++ b/apps/daemon/cmd/oac-daemon/main.go @@ -1,5 +1,5 @@ -// Command oac-daemon is the reverse-WebSocket worker that pairs a user -// machine with a OpenAgentCore server and exposes a local agent CLI +// Command oac-daemon is the reverse-WebSocket worker that connects a +// machine to an OpenAgentCore server and exposes a local agent CLI // subprocess as a connector_type=agent_daemon target. See // apps/daemon/README.md for the subcommand spec. package main diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go index e4549a8b7..05038fe88 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -65,7 +65,7 @@ func TestLiveClaudeSDKCancelResume(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} running, err := NewFactory(config)(ctx, request, out) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/commands.go b/apps/daemon/internal/agent/claudesdk/commands.go index 46dfa31b1..9bc4c266a 100644 --- a/apps/daemon/internal/agent/claudesdk/commands.go +++ b/apps/daemon/internal/agent/claudesdk/commands.go @@ -33,9 +33,7 @@ func (c *commandState) receive(event bridgeEvent, start startRequest, sessionID return fmt.Errorf("claudesdk: inconsistent command observation") } c.calls[event.ID] = *n - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "Bash", Stage: event.Stage, Observation: n}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "Bash", Stage: event.Stage, Observation: n}) return nil } @@ -48,15 +46,13 @@ func (c *commandState) complete() bool { return true } -func (c *commandState) close(start startRequest, emit func(string, any)) { +func (c *commandState) close(emit func(string, any)) { for id, call := range c.calls { if call.Status != "in_progress" { continue } call.Status = "incomplete" c.calls[id] = call - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "Bash", Stage: "after", Observation: &call}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "Bash", Stage: "after", Observation: &call}) } } diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index 12becd34a..8ed414dc4 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -16,74 +16,56 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestWorkspaceCommandsRequirePackagedFeatureOnlyWhenRequested(t *testing.T) { - for _, observed := range []bool{false, true} { - config := preparationFixture(t, "old-command-runtime") - req := preparationRequest() - req.ObserveToolObservations = observed - resource, err := NewExecutorFactory(config)(t.Context(), req) - if observed { - if err == nil || !strings.Contains(err.Error(), "workspace command observations") { - t.Fatal("old bridge accepted requested command observations", err) - } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { - t.Fatal("old bridge started execution before rejection") - } - } else { - if err != nil { - t.Fatal("old bridge changed opt-out behavior", err) - } - if err := resource.Close(t.Context()); err != nil { - t.Fatal(err) - } - } +func TestWorkspaceCommandsRequirePackagedFeature(t *testing.T) { + config := preparationFixture(t, "old-command-runtime") + if _, err := NewExecutorFactory(config)(t.Context(), preparationRequest()); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { + t.Fatal("old bridge accepted command observations", err) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + t.Fatal("old bridge started execution before rejection") } } func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) { - for _, observed := range []bool{false, true} { - config := preparationFixture(t, "commands-success") - req := preparationRequest() - req.ObserveToolObservations = observed - resource, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer resource.Close(context.Background()) - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { - t.Fatal("preparation submitted a command") - } - out := make(chan proto.Envelope, 16) - s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) + config := preparationFixture(t, "commands-success") + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + defer resource.Close(context.Background()) + if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + t.Fatal("preparation submitted a command") + } + out := make(chan proto.Envelope, 16) + s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + var frames []proto.ToolCallPayload + done := 0 + for event := range out { + if event.ID != "actual-command-run" || event.Type == proto.TypeError || event.Type == proto.TypeCommandOutput { + t.Fatal("execution identity or final-only command behavior changed", event.Type) } - defer s.Cancel(context.Background()) - var frames []proto.ToolCallPayload - done := 0 - for event := range out { - if event.ID != "actual-command-run" || event.Type == proto.TypeError || event.Type == proto.TypeCommandOutput { - t.Fatal("execution identity or final-only command behavior changed", event.Type) - } - if event.Type == proto.TypeToolCall { - var payload proto.ToolCallPayload - if err := event.DecodePayload(&payload); err != nil { - t.Fatal(err) - } - frames = append(frames, payload) - } - if event.Type == proto.TypeDone { - done++ + if event.Type == proto.TypeToolCall { + var payload proto.ToolCallPayload + if err := event.DecodePayload(&payload); err != nil { + t.Fatal(err) } + frames = append(frames, payload) } - if done != 1 || observed && len(frames) != 2 || !observed && len(frames) != 0 { - t.Fatal("completion or observation opt-in changed", done, frames) - } - if observed && (frames[0].ID != "observed" || frames[1].ID != "observed" || frames[1].Observation.Status != "failed" || - string(frames[1].Observation.Output) != `"Exit code 7\nretained"`) { - t.Fatal("native failure output was not retained", frames) + if event.Type == proto.TypeDone { + done++ } } + if done != 1 || len(frames) != 2 { + t.Fatal("completion or observation changed", done, frames) + } + if frames[0].ID != "observed" || frames[1].ID != "observed" || frames[1].Observation.Status != "failed" || + string(frames[1].Observation.Output) != `"Exit code 7\nretained"` { + t.Fatal("native failure output was not retained", frames) + } } func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *testing.T) { @@ -93,7 +75,7 @@ func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *t defer cancel() config := preparationFixture(t, mode) req := workspaceRequest() - req.AgentSessionID, req.ObserveToolObservations = "native-session", true + req.AgentSessionID = "native-session" out := make(chan proto.Envelope, 32) s, err := NewFactory(config)(ctx, req, out) if err != nil { diff --git a/apps/daemon/internal/agent/claudesdk/commands_test.go b/apps/daemon/internal/agent/claudesdk/commands_test.go index 33b923c7f..f98129c51 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_test.go @@ -13,53 +13,45 @@ func commandEvent(id, stage, status, command string) bridgeEvent { Observation: &proto.ToolObservation{Kind: "command", Status: status, Command: command}} } -func TestCommandObservationLifecycleAndOptIn(t *testing.T) { - for _, observed := range []bool{false, true} { - state := commandState{calls: map[string]proto.ToolObservation{}} - start := startRequest{Workspace: &workspaceProfile{}, observeFunctions: observed} - var events []proto.ToolCallPayload - emit := func(kind string, payload any) { - if kind != proto.TypeToolCall { - t.Fatal(kind) - } - events = append(events, payload.(proto.ToolCallPayload)) - } - const command = " printf 'failure\\n'; exit 7 " - before := commandEvent("native-call", "before", "in_progress", command) - if err := state.receive(before, start, "native-session", emit); err != nil || state.complete() { - t.Fatal("call was not pending", err) - } - if err := state.receive(before, start, "native-session", emit); err == nil { - t.Fatal("duplicate bridge call accepted") - } - after := commandEvent("native-call", "after", "failed", command) - after.Observation.Output = json.RawMessage(`"Exit code 7\nfailure"`) - if err := state.receive(after, start, "native-session", emit); err != nil || !state.complete() { - t.Fatal("native result did not complete the call", err) - } - if err := state.receive(after, start, "native-session", emit); err == nil { - t.Fatal("duplicate bridge result accepted") - } - if err := state.receive(commandEvent("pending", "before", "in_progress", "sleep 30"), start, "native-session", emit); err != nil { - t.Fatal(err) - } - state.close(start, emit) - state.close(start, emit) - if !state.complete() || state.calls["pending"].Status != "incomplete" || state.calls["native-call"].Status != "failed" { - t.Fatal("closure changed an observed result or lost an unfinished call") - } - if !observed { - if len(events) != 0 { - t.Fatal("opt-out emitted observations") - } - continue - } - if len(events) != 4 || events[0].ID != "native-call" || events[0].Name != "Bash" || events[0].Observation.Command != command || - string(events[1].Observation.Output) != `"Exit code 7\nfailure"` || events[1].Observation.ExitCode != nil || - events[1].Observation.Cwd != nil || events[1].Observation.DurationMS != nil || events[3].ID != "pending" || - events[3].Observation.Status != "incomplete" || len(events[3].Observation.Output) != 0 { - t.Fatal("observation identity, output or unknown metadata changed", events) +func TestCommandObservationLifecycle(t *testing.T) { + state := commandState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}} + var events []proto.ToolCallPayload + emit := func(kind string, payload any) { + if kind != proto.TypeToolCall { + t.Fatal(kind) } + events = append(events, payload.(proto.ToolCallPayload)) + } + const command = " printf 'failure\\n'; exit 7 " + before := commandEvent("native-call", "before", "in_progress", command) + if err := state.receive(before, start, "native-session", emit); err != nil || state.complete() { + t.Fatal("call was not pending", err) + } + if err := state.receive(before, start, "native-session", emit); err == nil { + t.Fatal("duplicate bridge call accepted") + } + after := commandEvent("native-call", "after", "failed", command) + after.Observation.Output = json.RawMessage(`"Exit code 7\nfailure"`) + if err := state.receive(after, start, "native-session", emit); err != nil || !state.complete() { + t.Fatal("native result did not complete the call", err) + } + if err := state.receive(after, start, "native-session", emit); err == nil { + t.Fatal("duplicate bridge result accepted") + } + if err := state.receive(commandEvent("pending", "before", "in_progress", "sleep 30"), start, "native-session", emit); err != nil { + t.Fatal(err) + } + state.close(emit) + state.close(emit) + if !state.complete() || state.calls["pending"].Status != "incomplete" || state.calls["native-call"].Status != "failed" { + t.Fatal("closure changed an observed result or lost an unfinished call") + } + if len(events) != 4 || events[0].ID != "native-call" || events[0].Name != "Bash" || events[0].Observation.Command != command || + string(events[1].Observation.Output) != `"Exit code 7\nfailure"` || events[1].Observation.ExitCode != nil || + events[1].Observation.Cwd != nil || events[1].Observation.DurationMS != nil || events[3].ID != "pending" || + events[3].Observation.Status != "incomplete" || len(events[3].Observation.Output) != 0 { + t.Fatal("observation identity, output or unknown metadata changed", events) } } @@ -67,7 +59,7 @@ func TestCommandObservationsRejectUnqualifiedOrInconsistentEvents(t *testing.T) for _, mode := range []string{"profile", "uninitialized", "session", "id", "nil", "kind", "empty-command", "name", "cwd", "exit", "duration", "arguments", "error", "output-object", "before-output", "before-status", "after-before", "changed-command", "after-status", "stage"} { t.Run(mode, func(t *testing.T) { state := commandState{calls: map[string]proto.ToolObservation{}} - start := startRequest{Workspace: &workspaceProfile{}, observeFunctions: true} + start := startRequest{Workspace: &workspaceProfile{}} sessionID := "native-session" event := commandEvent("call", "before", "in_progress", "pwd") if strings.HasPrefix(mode, "after-") || mode == "changed-command" { @@ -130,7 +122,7 @@ func TestCommandObservationUnknownAndEmptyOutputRemainDistinct(t *testing.T) { for _, output := range []json.RawMessage{nil, json.RawMessage(`null`), json.RawMessage(`""`)} { state := commandState{calls: map[string]proto.ToolObservation{}} start := startRequest{Workspace: &workspaceProfile{}} - emit := func(string, any) { t.Fatal("opt-out emitted an observation") } + emit := func(string, any) {} if err := state.receive(commandEvent("call", "before", "in_progress", "pwd"), start, "native-session", emit); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 7ade50fba..664579186 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -48,7 +48,7 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { } func preparationOnly(req proto.PromptRequestPayload) error { - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return errors.New("claudesdk: Executor preparation cannot submit input") } return nil @@ -104,9 +104,6 @@ func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { if start.Subagents != nil && !info.SupportsSubagents() { return errors.New("claudesdk: subagent resources are unavailable") } - if start.Workspace != nil && start.observeFunctions && !info.supportsWorkspaceCommands() { - return errors.New("claudesdk: packaged runtime does not support workspace command observations") - } if start.Workspace != nil && len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { return errors.New("claudesdk: workspace functions are unavailable") } diff --git a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go index f4cb246bd..bba0b7099 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -27,6 +27,9 @@ func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { t.Fatal("initial output missing") } if mode == "pending_function" || mode == "pending_function_unconfirmed" { + if event := <-out; event.Type != proto.TypeToolCall { + t.Fatal("function observation missing") + } if event := <-out; event.Type != proto.TypeFunctionCall { t.Fatal("function obligation missing") } diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go index 1b5dab8f4..221764e40 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -80,7 +80,7 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) } defer persist() - request := proto.PromptRequestPayload{StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: model, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} + request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: model, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} factory := NewExecutorFactory(config) prepared := time.Now() owner, err := factory(ctx, request) diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index d1b9ce48c..97c6638cf 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -197,8 +197,8 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" } - mcp.close(start, emit) - commands.close(start, emit) + mcp.close(emit) + commands.close(emit) s.stopSteering() metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} if id := s.inputSessionID(); id != "" { diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go index 80048c70b..752d1347f 100644 --- a/apps/daemon/internal/agent/claudesdk/functions.go +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -84,13 +84,11 @@ func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit fu if err != nil { return err } - if start.observeFunctions { - id, stage := event.CallID, "after" - if call != nil { - id, stage = call.CallID, "before" - } - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + id, stage := event.CallID, "after" + if call != nil { + id, stage = call.CallID, "before" } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) if call != nil { emit(proto.TypeFunctionCall, call) } else { diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index a142b0139..23091fb1f 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -22,7 +22,7 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", ObserveToolObservations: true, Model: "fake-model", SystemPrompt: "instructions", FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions", FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go index 704c20af6..0973e449d 100644 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -137,9 +137,8 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { requestStart := len(requests) mu.Unlock() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} if success != nil { - request.ObserveToolObservations = true request.SystemPrompt = "Call lookup exactly once as requested, then report both result parts and any prior verification value. Never retry a failed tool." request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} } diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index a74da05d7..b193136d7 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -109,9 +109,7 @@ func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(stri return fmt.Errorf("claudesdk: inconsistent MCP observation") } m.calls[event.ID] = *n - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) return nil } @@ -124,15 +122,13 @@ func (m *mcpState) complete() bool { return true } -func (m *mcpState) close(start startRequest, emit func(string, any)) { +func (m *mcpState) close(emit func(string, any)) { for id, call := range m.calls { if call.Status != "in_progress" { continue } call.Status = "incomplete" m.calls[id] = call - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) - } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) } } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index 328674950..ab84b3b9e 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -73,7 +73,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { } func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { - start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}, observeFunctions: true} + start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}} state := mcpState{calls: map[string]proto.ToolObservation{}} observation := proto.ToolObservation{Kind: "mcp", Name: "echo", Server: "installed", Status: "in_progress", Arguments: json.RawMessage(`{}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)} var emitted []proto.ToolCallPayload @@ -81,7 +81,7 @@ func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { if err := state.receive(bridgeEvent{ID: "native-call", Stage: "before", Observation: &observation}, start, emit); err != nil { t.Fatal(err) } - state.close(start, emit) + state.close(emit) if len(emitted) != 2 || emitted[1].ID != "native-call" || emitted[1].Observation.Status != "incomplete" { t.Fatal("interrupted environment call lost identity") } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index 0d451ee91..4b555a806 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -72,7 +72,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { } func TestMCPObservationLifecycle(t *testing.T) { - start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}, observeFunctions: true} + start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}} state := mcpState{calls: map[string]proto.ToolObservation{}} var observations []proto.ToolCallPayload emit := func(kind string, payload any) { @@ -107,7 +107,7 @@ func TestMCPObservationLifecycle(t *testing.T) { if err := state.receive(before, start, emit); err != nil { t.Fatal(err) } - state.close(start, emit) + state.close(emit) if !state.complete() || observations[len(observations)-1].Observation.Status != "incomplete" { t.Fatal("cancellation lost pending call") } diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 9735ee71a..614b45804 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -39,7 +39,6 @@ type startRequest struct { MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` Workspace *workspaceProfile `json:"workspace,omitempty"` RequireHistory bool `json:"require_history,omitempty"` - observeFunctions bool } func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { @@ -111,7 +110,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR // an agent-host view takes from its Session rather than the request. func prepareOptions(req proto.PromptRequestPayload, mcp bool) (startRequest, []string, error) { skills := req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) != 0 - start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} + start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools} fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 434d1dcbc..769195e2c 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -97,7 +97,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "conversation", "attachments", "subagents", "none", "functions", "mcp", "controls", "old-runtime"} { + for _, name := range []string{"run", "prompt", "attachments", "subagents", "none", "functions", "mcp", "controls", "old-runtime"} { t.Run(name, func(t *testing.T) { config := preparationFixture(t, name) req := preparationRequest() @@ -106,8 +106,6 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) req.RunID = "unexpected" case "prompt": req.Input = proto.TextInput("unexpected") - case "conversation": - req.ConversationID = "product" case "attachments": req.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} case "subagents": diff --git a/apps/daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go index ab520bf04..33e24a426 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -75,16 +75,14 @@ func (info RuntimeInfo) supportsWorkspace() bool { return slices.Contains(info.Features, "workspace_tools") } +// Workspace execution always emits neutral command observations, so a bridge +// without them cannot run a prepared workspace. func (info RuntimeInfo) supportsWorkspacePreparation() bool { - return info.supportsWorkspace() && slices.Contains(info.Features, "workspace_prepare") -} - -func (info RuntimeInfo) supportsWorkspaceCommands() bool { - return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "workspace_command_observations") + return info.supportsWorkspace() && slices.Contains(info.Features, "workspace_prepare") && slices.Contains(info.Features, "workspace_command_observations") } func (info RuntimeInfo) SupportsLocalRuntime() bool { - return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v2") + return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "local_runtime_v2") } func (info RuntimeInfo) SupportsWorkspaceFunctions() bool { @@ -116,10 +114,10 @@ func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { } process, err := clirunner.Start(clirunner.StartOptions{ Parent: ctx, Binary: binary, - Args: []string{filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), config.Entrypoint}, - Dir: filepath.Dir(config.Entrypoint), - Env: env, - OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, + Args: []string{filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), config.Entrypoint}, + Dir: filepath.Dir(config.Entrypoint), + Env: env, + KillTimeout: 250 * time.Millisecond, }) if err != nil { return RuntimeInfo{}, fmt.Errorf("claudesdk: cannot start runtime check: %w", err) diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 78003305a..0d8610264 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -37,7 +37,7 @@ func NewFactory(config Config) agent.Factory { prepareExecutor := NewExecutorFactory(config) return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { run, input := req.RunID, req.Input - req.RunID, req.ConversationID, req.Input = "", "", nil + req.RunID, req.Input = "", nil resource, err := prepareExecutor(ctx, req) if err != nil { return nil, err @@ -86,7 +86,7 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if binary == "" { binary = "node" } - return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) } // startSession runs the bridge through start: clirunner.Start, or an agent-host diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index f859518d8..8f0146c78 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -111,7 +111,7 @@ func newViewExecutorFactory(probe Config, layout viewLayout) agent.ViewExecutorF return nil, err } return startExecutor(ctx, checked, probe, start, func() (*session, error) { - return startSession(view.Launch, clirunner.StartOptions{Parent: ctx, Binary: layout.node, Args: []string{layout.bridge}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + return startSession(view.Launch, clirunner.StartOptions{Parent: ctx, Binary: layout.node, Args: []string{layout.bridge}, Dir: start.Cwd, Env: env, NeedStdin: true}) }) } } diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 98f28329c..ba6a24729 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -54,7 +54,7 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { defer executor.Close(ctx) request := <-requests - if !slices.Contains(view.LocalExec, launched.Binary) || !slices.Equal(launched.Args, []string{agent.ViewPrivateRoot + "/claude-sdk/dist/main.js"}) || launched.Dir != "/workspace" || !launched.OwnProcessGroup { + if !slices.Contains(view.LocalExec, launched.Binary) || !slices.Equal(launched.Args, []string{agent.ViewPrivateRoot + "/claude-sdk/dist/main.js"}) || launched.Dir != "/workspace" { t.Fatalf("launch = %+v, want the closure's node running the bridge in the workspace", launched) } env := map[string]string{} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index dfaac5162..1f310776d 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -24,7 +24,7 @@ test "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 case "$1" in */runtime_check.js) - printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; + printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare","workspace_command_observations"]}' ;; *) IFS= read -r request printf '%s\n' '{"type":"executor_ready","protocol":3}' @@ -59,7 +59,7 @@ esac t.Fatal("expected one settled completion") } // Feature checking must reject an older bridge without starting execution. - script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare"]`, `"features":[]`) + script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare","workspace_command_observations"]`, `"features":[]`) script = strings.ReplaceAll(script, "IFS= read -r request", "touch '"+filepath.Join(config.StateDir, "unexpected-start")+"'") if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index f391878a9..d9a7e3321 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -91,7 +91,7 @@ func TestLiveClaudeWorkspaceFactory(t *testing.T) { out := make(chan proto.Envelope, 64) req := workspaceRequest() req.RunID, req.Input, req.AgentSessionID = uuid.NewString(), proto.TextInput(prompt), resume - req.StrictResume, req.ReleaseOnCompletion, req.ObserveMessages, req.ObserveToolObservations = true, true, true, true + req.ObserveMessages = true req.Model, req.SystemPrompt = "MiniMax-M3", "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work." proof := evidence{RunID: req.RunID} running, err := NewFactory(config)(ctx, req, out) diff --git a/apps/daemon/internal/agent/clirunner/handle.go b/apps/daemon/internal/agent/clirunner/handle.go index e331ec3ca..4c0ce3cf4 100644 --- a/apps/daemon/internal/agent/clirunner/handle.go +++ b/apps/daemon/internal/agent/clirunner/handle.go @@ -44,7 +44,7 @@ func FromHandle(h Handle, opts HandleOptions) (*Process, error) { opts.KillTimeout = DefaultKillTimeout } ctx, cancel := context.WithCancel(opts.Parent) - p := &Process{Stdin: opts.Stdin, Stdout: opts.Stdout, Stderr: opts.Stderr, ctx: ctx, cancel: cancel, done: make(chan struct{}), killAfter: opts.KillTimeout} + p := &Process{Stdin: opts.Stdin, Stdout: opts.Stdout, Stderr: opts.Stderr, ctx: ctx, cancel: cancel, done: make(chan struct{})} var terminateOnce sync.Once var interrupted atomic.Bool p.cancelProcess = func() error { @@ -53,7 +53,7 @@ func FromHandle(h Handle, opts HandleOptions) (*Process, error) { interrupted.Store(err == nil) go func() { if err == nil || errors.Is(err, os.ErrProcessDone) { - timer := time.NewTimer(p.killAfter) + timer := time.NewTimer(opts.KillTimeout) defer timer.Stop() select { case <-p.done: diff --git a/apps/daemon/internal/agent/clirunner/process.go b/apps/daemon/internal/agent/clirunner/process.go index fa4064012..30bd5d5e9 100644 --- a/apps/daemon/internal/agent/clirunner/process.go +++ b/apps/daemon/internal/agent/clirunner/process.go @@ -5,9 +5,7 @@ import ( "fmt" "io" "os/exec" - "runtime" "sync" - "syscall" "time" ) @@ -23,8 +21,6 @@ type StartOptions struct { Env []string NeedStdin bool KillTimeout time.Duration - // OwnProcessGroup bounds the lifetime of subprocess descendants on Unix. - OwnProcessGroup bool } type Process struct { @@ -33,13 +29,11 @@ type Process struct { Stdout io.ReadCloser Stderr io.ReadCloser - ctx context.Context - cancel context.CancelFunc - done chan struct{} - killAfter time.Duration + ctx context.Context + cancel context.CancelFunc + done chan struct{} cancelOnce sync.Once - waitOnce sync.Once cancelProcess func() error waitProcess func() error @@ -59,54 +53,9 @@ func Start(opts StartOptions) (*Process, error) { opts.KillTimeout = DefaultKillTimeout } - if opts.OwnProcessGroup || runtime.GOOS == "windows" { - return startProcessGroup(opts) - } - - ctx, cancel := context.WithCancel(opts.Parent) - cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) - cmd.Dir = opts.Dir - if len(opts.Env) > 0 { - cmd.Env = append([]string{}, opts.Env...) - } - - var stdin io.WriteCloser - var err error - if opts.NeedStdin { - stdin, err = cmd.StdinPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("clirunner: stdin pipe: %w", err) - } - } - stdout, err := cmd.StdoutPipe() - if err != nil { - closePipe(stdin) - cancel() - return nil, fmt.Errorf("clirunner: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - closePipe(stdin) - cancel() - return nil, fmt.Errorf("clirunner: stderr pipe: %w", err) - } - if err := cmd.Start(); err != nil { - closePipe(stdin) - cancel() - return nil, fmt.Errorf("clirunner: start %q: %w", opts.Binary, err) - } - - return &Process{ - Cmd: cmd, - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - ctx: ctx, - cancel: cancel, - done: make(chan struct{}), - killAfter: opts.KillTimeout, - }, nil + // Every child owns its process group (a Job object on Windows), bounding the + // lifetime of its descendants. + return startProcessGroup(opts) } func (p *Process) Context() context.Context { @@ -126,44 +75,20 @@ func (p *Process) Done() <-chan struct{} { } func (p *Process) Cancel() { - if p == nil { + if p == nil || p.cancelProcess == nil { return } p.cancelOnce.Do(func() { - if p.cancelProcess != nil { - _ = p.cancelProcess() - p.cancel() - return - } - if p.Cmd != nil && p.Cmd.Process != nil { - _ = p.Cmd.Process.Signal(syscall.SIGTERM) - go func() { - select { - case <-p.done: - case <-time.After(p.killAfter): - _ = p.Cmd.Process.Signal(syscall.SIGKILL) - } - }() - } - if p.cancel != nil { - p.cancel() - } + _ = p.cancelProcess() + p.cancel() }) } func (p *Process) Wait() error { - if p == nil { - return nil - } - if p.waitProcess != nil { - return p.waitProcess() - } - if p.Cmd == nil { + if p == nil || p.waitProcess == nil { return nil } - err := p.Cmd.Wait() - p.waitOnce.Do(func() { close(p.done) }) - return err + return p.waitProcess() } // ExitCode returns the exit code once Done is closed, or -1 when a signal ended the process. ok is false before then and when the exit is unknown. diff --git a/apps/daemon/internal/agent/clirunner/process_group_other.go b/apps/daemon/internal/agent/clirunner/process_group_other.go index d5763742b..796f8aa19 100644 --- a/apps/daemon/internal/agent/clirunner/process_group_other.go +++ b/apps/daemon/internal/agent/clirunner/process_group_other.go @@ -5,5 +5,5 @@ package clirunner import "errors" func startProcessGroup(StartOptions) (*Process, error) { - return nil, errors.New("clirunner: process-group ownership requires Unix") + return nil, errors.New("clirunner: process ownership requires Unix or Windows") } diff --git a/apps/daemon/internal/agent/clirunner/process_group_unix_test.go b/apps/daemon/internal/agent/clirunner/process_group_unix_test.go index 8a3cbf162..da3162be5 100644 --- a/apps/daemon/internal/agent/clirunner/process_group_unix_test.go +++ b/apps/daemon/internal/agent/clirunner/process_group_unix_test.go @@ -107,9 +107,9 @@ func startOwnedHelper(t *testing.T, ctx context.Context, mode, dir string) *Proc t.Helper() p, err := Start(StartOptions{ Parent: ctx, Binary: os.Args[0], - Args: []string{"-test.run=^TestOwnedGroupHelper$", "--", "leader", mode, dir}, - Env: append(os.Environ(), "GO_WANT_OWNED_GROUP=1", "GORACE=atexit_sleep_ms=0"), - OwnProcessGroup: true, KillTimeout: 300 * time.Millisecond, + Args: []string{"-test.run=^TestOwnedGroupHelper$", "--", "leader", mode, dir}, + Env: append(os.Environ(), "GO_WANT_OWNED_GROUP=1", "GORACE=atexit_sleep_ms=0"), + KillTimeout: 300 * time.Millisecond, }) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/clirunner/process_group_windows_test.go b/apps/daemon/internal/agent/clirunner/process_group_windows_test.go index b2d442b54..fee66f0f2 100644 --- a/apps/daemon/internal/agent/clirunner/process_group_windows_test.go +++ b/apps/daemon/internal/agent/clirunner/process_group_windows_test.go @@ -21,7 +21,7 @@ func TestWindowsOwnedTree(t *testing.T) { dir := t.TempDir() ctx, cancel := context.WithCancel(t.Context()) defer cancel() - p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1"), OwnProcessGroup: true}) + p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1")}) if err != nil { t.Fatal(err) } @@ -129,7 +129,7 @@ func TestWindowsTreeHelper(t *testing.T) { time.Sleep(10 * time.Millisecond) } case "owner": - p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ(), OwnProcessGroup: true}) + p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ()}) if err != nil { os.Exit(3) } diff --git a/apps/daemon/internal/agent/codex/environment.go b/apps/daemon/internal/agent/codex/environment.go index 73e1e1c98..725e5ef58 100644 --- a/apps/daemon/internal/agent/codex/environment.go +++ b/apps/daemon/internal/agent/codex/environment.go @@ -38,8 +38,8 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) } // Native still recognizes the retired transport variables. Reject them before -// setup so an inherited environment cannot select a separate executor. The -// explicit none selector remains part of native execution isolation. +// setup so the inherited environment cannot select a separate executor. +// The explicit none selector remains part of native execution isolation. func validateNativeTransportEnvironment() error { for _, entry := range os.Environ() { key, value, _ := strings.Cut(entry, "=") diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index 5019d617e..089e09770 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -1,6 +1,7 @@ package codex import ( + "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -22,18 +23,10 @@ func TestExecutionControlsSelectNativeSettings(t *testing.T) { if err != nil { t.Fatal(err) } - want := map[string]string{"web_search": `"` + search + `"`, "model_verbosity": `"` + verbosity + `"`} - for _, kv := range plan.ExtraConfig { - if v, ok := want[kv[0]]; ok { - if kv[1] != v { - t.Fatal("native control differs", kv) - } - delete(want, kv[0]) - } - } plan.Cleanup() - if len(want) != 0 { - t.Fatal("native settings omitted", want) + want := [][2]string{{"web_search", `"` + search + `"`}, {"model_verbosity", `"` + verbosity + `"`}} + if !reflect.DeepEqual(plan.ExtraConfig, want) { + t.Fatalf("config = %v, want %v", plan.ExtraConfig, want) } } } diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 2f1e2eb09..75403c57d 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -69,8 +69,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} turnCtx, cancel := context.WithCancel(base.cancelCtx) s := &Session{executor: e, nativeHome: base.nativeHome, - functions: functions, observeMessages: base.observeMessages, - observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, + functions: functions, observeMessages: base.observeMessages, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, interactions: newPendingCodexInteractions(), runID: runID, out: out} @@ -94,7 +93,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess } s.registerHandlers() e.mu.Unlock() - req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, StrictResume: e.prepared.strictResume, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} + req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} // Ownership precedes any native submission. Even an uncertain start returns the // exact Turn so its caller can await settlement without replaying the input. err := s.startNative(ctx, e.prepared.plan, req) diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go index cb65df39a..9505d4b50 100644 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -51,7 +51,7 @@ func TestExecutorNativeReuse(t *testing.T) { cfg.logger = obslog.Discard() req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "executor-native", - StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, + DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, Model: model, ModelProvider: &modelprovider.Provider{BaseURL: endpoint, Protocol: modelprovider.Responses, APIKey: strings.TrimSpace(string(key))}, FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, diff --git a/apps/daemon/internal/agent/codex/executor_test.go b/apps/daemon/internal/agent/codex/executor_test.go index dd8d4f518..954f00869 100644 --- a/apps/daemon/internal/agent/codex/executor_test.go +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -165,7 +165,7 @@ func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { } func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index f10c5ebc8..0b1461a11 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -72,7 +72,6 @@ func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { return errors.New("codex: cannot write public MCP configuration") } for _, feature := range []string{"plugins", "apps"} { - plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { plan.DisableFeatures = append(plan.DisableFeatures, feature) } diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index cde8f8f98..20ac1767f 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -26,7 +26,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatal(err) } defer plan.Cleanup() - if slices.Contains(plan.EnableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { + if !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { t.Fatal("native profile was not pinned") } home, err := allocCodexHome(req.AgentStateKey) diff --git a/apps/daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go index 70f126310..b488c4aae 100644 --- a/apps/daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -17,7 +17,6 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. for _, mode := range []string{"new ready", "new failed", "resume ready", "resume failed"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) - req.StrictResume = true req.ExecutionControls = nil servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} req.MCPHTTPServers = &servers diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 4bf686d28..0cb8d9f58 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -30,12 +30,11 @@ type SessionPlan struct { Env []string // ExtraConfig is a list of `-c key=value` overrides applied at the - // app-server CLI. Used to layer web_search, model_verbosity and - // model_reasoning_effort without editing config.toml. + // app-server CLI. ExtraConfig [][2]string // EnableFeatures / DisableFeatures forward to `--enable / --disable` - // flags. + // flags for the profiles the adapter configures. EnableFeatures []string DisableFeatures []string @@ -60,9 +59,6 @@ type SessionPlan struct { // SystemPrompt is forwarded as developerInstructions on thread/start. SystemPrompt string - // CollaborationMode selects Codex's default or plan tool surface. - CollaborationMode CollaborationModeKind - // ModelReasoningEffort is frozen for launch and every native Turn. ModelReasoningEffort string @@ -89,10 +85,9 @@ func BuildSessionPlan(req proto.PromptRequestPayload) (SessionPlan, error) { // only after the request validates. func buildSessionPlan(req proto.PromptRequestPayload, allocHome func() (agent.ViewDir, error)) (SessionPlan, error) { plan := SessionPlan{ - CollaborationMode: CollaborationModeDefault, - ApprovalPolicy: AskForApproval{String: "never"}, - Sandbox: SandboxDangerFullAcces, - Cleanup: func() {}, + ApprovalPolicy: AskForApproval{String: "never"}, + Sandbox: SandboxDangerFullAcces, + Cleanup: func() {}, } prepared, err := harnessconfiguration.Configuration().Prepare(req) if err != nil { @@ -107,7 +102,7 @@ func buildSessionPlan(req proto.PromptRequestPayload, allocHome func() (agent.Vi switch controls.TextVerbosity { case "low", "medium", "high": default: - return plan, fmt.Errorf("codex: model_verbosity must be low, medium or high") + return plan, fmt.Errorf("codex: text_verbosity must be low, medium or high") } plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"web_search", strconv(controls.WebSearch)}, [2]string{"model_verbosity", strconv(controls.TextVerbosity)}) } diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index a10975b3f..cb9b8ee77 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -79,7 +79,7 @@ func TestBuildSessionPlan_CarriesModelAndSystemPrompt(t *testing.T) { t.Fatal(err) } defer plan.Cleanup() - if plan.CollaborationMode != CollaborationModeDefault || plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { + if plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { t.Fatalf("plan did not carry the default turn instructions: %+v", plan) } } diff --git a/apps/daemon/internal/agent/codex/permission_profile.go b/apps/daemon/internal/agent/codex/permission_profile.go index 46911ca67..f5e7cb807 100644 --- a/apps/daemon/internal/agent/codex/permission_profile.go +++ b/apps/daemon/internal/agent/codex/permission_profile.go @@ -6,9 +6,9 @@ import ( ) // Runtime execution uses the current user; isolation belongs to its outer host. -func runtimePermissionProfile(req proto.PromptRequestPayload) (string, error) { +func validatePermissionProfile(req proto.PromptRequestPayload) error { if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || req.WorkspaceReadOnly || req.LocalEnvironment.NetworkAccess != "enabled" || len(req.LocalEnvironment.AllowedDomains) != 0) { - return "", fmt.Errorf("codex: Runtime execution requires unrestricted host access") + return fmt.Errorf("codex: Runtime execution requires unrestricted host access") } - return "", nil + return nil } diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 3a072c808..402794dbf 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -16,7 +16,6 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan return nil, errors.New("codex: nil out channel") } runID, prompt := req.RunID, req.Input - req.AgentStateKey = effectiveAgentStateKey(req) req.RunID, req.Input = "", nil prepared, err := newPreparation(parent, req, cfg) if err != nil { @@ -33,9 +32,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if req.WorkspaceReadOnly { return nil, errors.New("codex: workspace reads use the local Runtime interface") } - if req.RequireExistingNativeSession && (!req.StrictResume || req.AgentStateKey == "" || req.WorkspaceReadOnly) { - return nil, errors.New("codex: native-session recovery requires strict private state") - } if req.RunID != "" || len(req.Input) != 0 { return nil, errors.New("codex: preparation does not accept a run identity or prompt") } @@ -52,7 +48,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if err != nil { return nil, err } - req.AgentStateKey = effectiveAgentStateKey(req) var plan SessionPlan var skillRoots []string if cfg.view != nil { @@ -85,11 +80,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg rpc := NewJSONRPCClient(rpcCfg) s := &Session{ - nativeHome: plan.home, - functions: functions, - observeMessages: req.ObserveMessages, - - observeToolObservations: req.ObserveToolObservations, + nativeHome: plan.home, + functions: functions, + observeMessages: req.ObserveMessages, observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, rpc: rpc, @@ -104,7 +97,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg plan.Cleanup = s.cleanup p := &Prepared{ session: s, plan: plan, - resumeID: req.AgentSessionID, strictResume: req.StrictResume, requireExistingNativeSession: req.RequireExistingNativeSession, + resumeID: req.AgentSessionID, requireExistingNativeSession: req.RequireExistingNativeSession, transferred: make(chan struct{}), } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 19ef9367a..307060877 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -42,7 +42,6 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig cfg.codexBinary = binary req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", - ReleaseOnCompletion: true, StrictResume: true, Model: "fixture-model", ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, DisableExecutionEnvironment: true, diff --git a/apps/daemon/internal/agent/codex/prepared.go b/apps/daemon/internal/agent/codex/prepared.go index edae1d169..a8a57c30d 100644 --- a/apps/daemon/internal/agent/codex/prepared.go +++ b/apps/daemon/internal/agent/codex/prepared.go @@ -17,7 +17,6 @@ type Prepared struct { session *Session plan SessionPlan resumeID string - strictResume bool requireExistingNativeSession bool claimed bool closed bool @@ -60,7 +59,7 @@ func (p *Prepared) start(ctx context.Context, runID string, prompt proto.Message p.started = true close(p.transferred) transferred = true - req := proto.PromptRequestPayload{RunID: runID, Input: prompt, AgentSessionID: p.resumeID, StrictResume: p.strictResume, RequireExistingNativeSession: p.requireExistingNativeSession} + req := proto.PromptRequestPayload{RunID: runID, Input: prompt, AgentSessionID: p.resumeID, RequireExistingNativeSession: p.requireExistingNativeSession} go s.run(p.plan, req) return s, nil } diff --git a/apps/daemon/internal/agent/codex/programmatic_tools.go b/apps/daemon/internal/agent/codex/programmatic_tools.go index e0d377fa0..c2da9f9a1 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools.go @@ -16,7 +16,6 @@ func disableProgrammaticTools(plan *SessionPlan, controls *proto.ExecutionContro return } for _, feature := range programmaticFeatures { - plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { plan.DisableFeatures = append(plan.DisableFeatures, feature) } diff --git a/apps/daemon/internal/agent/codex/programmatic_tools_test.go b/apps/daemon/internal/agent/codex/programmatic_tools_test.go index df9064dc4..fd0aeee8a 100644 --- a/apps/daemon/internal/agent/codex/programmatic_tools_test.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools_test.go @@ -3,7 +3,6 @@ package codex import ( "context" "encoding/json" - "reflect" "slices" "testing" "time" @@ -12,16 +11,12 @@ import ( ) func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { - plan := SessionPlan{EnableFeatures: []string{"code_mode", "unrelated", "code_mode_only", "code_mode_prewarm"}, ExtraConfig: [][2]string{{"features.code_mode", "true"}}} - before := slices.Clone(plan.EnableFeatures) + plan := SessionPlan{ExtraConfig: [][2]string{{"features.code_mode", "true"}}} disableProgrammaticTools(&plan, nil) - if !reflect.DeepEqual(plan.EnableFeatures, before) { + if len(plan.DisableFeatures) != 0 || len(plan.ExtraConfig) != 1 { t.Fatal("omission changed native configuration") } disableProgrammaticTools(&plan, &proto.ExecutionControls{DisableProgrammaticToolCalling: true}) - if !slices.Equal(plan.EnableFeatures, []string{"unrelated"}) { - t.Fatal(plan.EnableFeatures) - } for _, feature := range programmaticFeatures { if !slices.Contains(plan.DisableFeatures, feature) { t.Fatal("missing disable", feature) diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 2aa988740..462c10f3e 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -238,10 +238,7 @@ type TurnStartParams struct { type CollaborationModeKind string -const ( - CollaborationModePlan CollaborationModeKind = "plan" - CollaborationModeDefault CollaborationModeKind = "default" -) +const CollaborationModeDefault CollaborationModeKind = "default" type CollaborationMode struct { Mode CollaborationModeKind `json:"mode"` diff --git a/apps/daemon/internal/agent/codex/protocol_wire_test.go b/apps/daemon/internal/agent/codex/protocol_wire_test.go index 5a513043b..d32590d7c 100644 --- a/apps/daemon/internal/agent/codex/protocol_wire_test.go +++ b/apps/daemon/internal/agent/codex/protocol_wire_test.go @@ -106,13 +106,13 @@ func TestThreadStartParams_ModelProviderIsCamelCaseField(t *testing.T) { } } -func TestTurnStartParams_CollaborationModeUsesPlanWireShape(t *testing.T) { +func TestTurnStartParams_CollaborationModeWireShape(t *testing.T) { developerInstructions := "stay within the configured workspace" params := TurnStartParams{ ThreadID: "thread-1", Input: []UserInput{{Type: UserInputText, Text: "ask me a question"}}, CollaborationMode: &CollaborationMode{ - Mode: CollaborationModePlan, + Mode: CollaborationModeDefault, Settings: CollaborationModeSettings{ Model: "MiniMax-M3", DeveloperInstructions: &developerInstructions, @@ -124,7 +124,7 @@ func TestTurnStartParams_CollaborationModeUsesPlanWireShape(t *testing.T) { t.Fatalf("marshal: %v", err) } body := string(raw) - if !strings.Contains(body, `"collaborationMode":{"mode":"plan","settings":{"model":"MiniMax-M3","developer_instructions":"stay within the configured workspace"}}`) { + if !strings.Contains(body, `"collaborationMode":{"mode":"default","settings":{"model":"MiniMax-M3","developer_instructions":"stay within the configured workspace"}}`) { t.Fatalf("collaboration mode missing or malformed: %s", body) } if strings.Contains(body, `"collaboration_mode"`) { diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index 49713413e..97eff6936 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -47,7 +47,7 @@ func TestRequiredHistoryResolution(t *testing.T) { case "wrong-home": row["path"] = "/another/sessions/rollout.jsonl" } - req := proto.PromptRequestPayload{StrictResume: true, RequireExistingNativeSession: true} + req := proto.PromptRequestPayload{RequireExistingNativeSession: true} if scenario == "fresh" { req.RequireExistingNativeSession = false } @@ -197,14 +197,12 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { } } -func TestRecoveryRequiresStrictPrivateExecution(t *testing.T) { - for _, mode := range []string{"non-strict", "no-state", "read-only"} { +func TestRecoveryRequiresWritableAgentState(t *testing.T) { + for _, mode := range []string{"no-state", "read-only"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.RequireExistingNativeSession = true switch mode { - case "non-strict": - req.StrictResume = false case "no-state": req.AgentStateKey = "" case "read-only": diff --git a/apps/daemon/internal/agent/codex/resume.go b/apps/daemon/internal/agent/codex/resume.go index 6ee852b1c..2bc3172e1 100644 --- a/apps/daemon/internal/agent/codex/resume.go +++ b/apps/daemon/internal/agent/codex/resume.go @@ -9,18 +9,12 @@ import ( func (s *Session) resolveThread(req proto.PromptRequestPayload, plan SessionPlan) error { if strings.TrimSpace(req.AgentSessionID) != "" { - if err := s.resumeThread(req.AgentSessionID, plan); err == nil { - return nil - } else if req.StrictResume { + if err := s.resumeThread(req.AgentSessionID, plan); err != nil { return fmt.Errorf("codex: thread/resume: %w", err) - } else { - s.cfg.logger.Warn("codex: thread/resume failed; starting fresh", "run_id", s.runID, "thread_id", req.AgentSessionID, "err", err) } + return nil } if req.RequireExistingNativeSession { - if !req.StrictResume { - return fmt.Errorf("codex: recovery requires strict resume") - } id, err := s.recoverRoot(plan) if err != nil { return err diff --git a/apps/daemon/internal/agent/codex/resume_test.go b/apps/daemon/internal/agent/codex/resume_test.go index 1da859d76..698eb2cc7 100644 --- a/apps/daemon/internal/agent/codex/resume_test.go +++ b/apps/daemon/internal/agent/codex/resume_test.go @@ -10,53 +10,38 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) -func TestStrictResumeDoesNotStartFresh(t *testing.T) { - for _, strict := range []bool{false, true} { - t.Run(map[bool]string{false: "legacy", true: "strict"}[strict], func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "resume-test")}} - result := make(chan error, 1) - go func() { - result <- s.resolveThread(proto.PromptRequestPayload{AgentSessionID: "existing", StrictResume: strict}, SessionPlan{}) - }() - var req struct { - ID string `json:"id"` - Method string `json:"method"` - } - decoder := json.NewDecoder(server.FromClient) - encoder := json.NewEncoder(server.ToClient) - if err := decoder.Decode(&req); err != nil { - t.Fatal(err) - } - if req.Method != "thread/resume" { - t.Fatal(req.Method) - } - if err := encoder.Encode(map[string]any{"id": req.ID, "error": map[string]any{"code": -32600, "message": "native history unavailable"}}); err != nil { - t.Fatal(err) - } - if !strict { - if err := decoder.Decode(&req); err != nil { - t.Fatal(err) - } - if req.Method != "thread/start" { - t.Fatal(req.Method) - } - if err := encoder.Encode(map[string]any{"id": req.ID, "result": map[string]any{"thread": map[string]string{"id": "fresh"}}}); err != nil { - t.Fatal(err) - } - } - select { - case err := <-result: - if (err != nil) != strict { - t.Fatalf("strict=%v err=%v", strict, err) - } - case <-ctx.Done(): - t.Fatal("thread resolution did not finish") - } - }) +func TestFailedResumeDoesNotStartFresh(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "resume-test")}} + result := make(chan error, 1) + go func() { + result <- s.resolveThread(proto.PromptRequestPayload{AgentSessionID: "existing"}, SessionPlan{}) + }() + var req struct { + ID string `json:"id"` + Method string `json:"method"` + } + decoder := json.NewDecoder(server.FromClient) + encoder := json.NewEncoder(server.ToClient) + if err := decoder.Decode(&req); err != nil { + t.Fatal(err) + } + if req.Method != "thread/resume" { + t.Fatal(req.Method) + } + if err := encoder.Encode(map[string]any{"id": req.ID, "error": map[string]any{"code": -32600, "message": "native history unavailable"}}); err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err == nil { + t.Fatal("failed resume started a fresh thread") + } + case <-ctx.Done(): + t.Fatal("thread resolution did not finish") } } diff --git a/apps/daemon/internal/agent/codex/rpc.go b/apps/daemon/internal/agent/codex/rpc.go index 2d2eceb56..f3a99cf41 100644 --- a/apps/daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -186,7 +186,7 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (Initi } process, err := launch(clirunner.StartOptions{ Parent: ctx, Binary: c.cfg.Binary, Args: args, Dir: c.cfg.Cwd, Env: c.cfg.Env, - NeedStdin: true, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, + NeedStdin: true, KillTimeout: 250 * time.Millisecond, }) if err != nil { return InitializeResult{}, fmt.Errorf("codex rpc: spawn %q: %w", c.cfg.Binary, err) diff --git a/apps/daemon/internal/agent/codex/rpc_close_test.go b/apps/daemon/internal/agent/codex/rpc_close_test.go index 5fd3aaefd..fbd9c8175 100644 --- a/apps/daemon/internal/agent/codex/rpc_close_test.go +++ b/apps/daemon/internal/agent/codex/rpc_close_test.go @@ -15,7 +15,7 @@ import ( ) func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 0e5798581..27ab44063 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -40,9 +40,8 @@ func defaultSessionConfig() sessionConfig { } // Factory implements agent.Factory for agent_kind="codex". Spawns one -// codex app-server child per prompt. The run stream closes when the turn -// completes; the router retains the child until the conversation's idle -// window expires or cancellation shuts it down sooner. +// codex app-server child for one Turn. The run stream closes when the turn +// completes; the child remains until the caller cancels the Session. func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { return newSession(ctx, req, out, defaultSessionConfig()) } @@ -73,11 +72,10 @@ type Session struct { functions *functionCalls observeMessages bool - observeToolObservations bool - runID string - cfg sessionConfig - out chan<- proto.Envelope - rpc *JSONRPCClient + runID string + cfg sessionConfig + out chan<- proto.Envelope + rpc *JSONRPCClient cancelCtx context.Context cancelFn context.CancelFunc diff --git a/apps/daemon/internal/agent/codex/session_command_output.go b/apps/daemon/internal/agent/codex/session_command_output.go index 600e4fdad..1d5cd9f92 100644 --- a/apps/daemon/internal/agent/codex/session_command_output.go +++ b/apps/daemon/internal/agent/codex/session_command_output.go @@ -7,9 +7,6 @@ import ( ) func (s *Session) onCommandOutput(raw json.RawMessage) { - if !s.observeToolObservations { - return - } var p AgentMessageDeltaNotification if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.TurnID) || p.ItemID == "" || p.Delta == "" { return diff --git a/apps/daemon/internal/agent/codex/session_command_output_test.go b/apps/daemon/internal/agent/codex/session_command_output_test.go index 4f86ccbd0..a51fb6694 100644 --- a/apps/daemon/internal/agent/codex/session_command_output_test.go +++ b/apps/daemon/internal/agent/codex/session_command_output_test.go @@ -8,42 +8,34 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestCommandOutputRequiresOptInAndRootTurn(t *testing.T) { - for _, enabled := range []bool{false, true} { - out := make(chan proto.Envelope, 10) - s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{}), observeToolObservations: enabled} - s.registerHandlers() - s.setThreadID("root") - s.beginRootTurn("root", "turn") - for _, raw := range []string{ - `{"threadId":"child","turnId":"turn","itemId":"cmd","delta":"foreign"}`, - `{"threadId":"root","turnId":"old","itemId":"cmd","delta":"foreign"}`, - `{"threadId":"root","turnId":"turn","delta":"missing identity"}`, - `{"threadId":"root","turnId":"turn","itemId":"cmd","delta":null}`, - `{"threadId":42}`, `{}`, - } { - scopeNotification(t, s, "item/commandExecution/outputDelta", raw) - } - if len(out) != 0 { - t.Fatal("invalid output reached root") +func TestCommandOutputRequiresRootTurn(t *testing.T) { + out := make(chan proto.Envelope, 10) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{})} + s.registerHandlers() + s.setThreadID("root") + s.beginRootTurn("root", "turn") + for _, raw := range []string{ + `{"threadId":"child","turnId":"turn","itemId":"cmd","delta":"foreign"}`, + `{"threadId":"root","turnId":"old","itemId":"cmd","delta":"foreign"}`, + `{"threadId":"root","turnId":"turn","delta":"missing identity"}`, + `{"threadId":"root","turnId":"turn","itemId":"cmd","delta":null}`, + `{"threadId":42}`, `{}`, + } { + scopeNotification(t, s, "item/commandExecution/outputDelta", raw) + } + if len(out) != 0 { + t.Fatal("invalid output reached root") + } + for _, fragment := range []string{"same\n", "same\n", "结束\n"} { + raw, _ := json.Marshal(map[string]string{"threadId": "root", "turnId": "turn", "itemId": "cmd", "delta": fragment}) + scopeNotification(t, s, "item/commandExecution/outputDelta", string(raw)) + if len(out) != 1 { + t.Fatal("missing registered command notification") } - for _, fragment := range []string{"same\n", "same\n", "结束\n"} { - raw, _ := json.Marshal(map[string]string{"threadId": "root", "turnId": "turn", "itemId": "cmd", "delta": fragment}) - scopeNotification(t, s, "item/commandExecution/outputDelta", string(raw)) - if !enabled { - if len(out) != 0 { - t.Fatal("product frame sequence changed") - } - continue - } - if len(out) != 1 { - t.Fatal("missing registered command notification") - } - env := <-out - var p proto.CommandOutputPayload - if env.DecodePayload(&p) != nil || env.Type != proto.TypeCommandOutput || env.ID != "run" || p.ID != "cmd" || p.Delta != fragment { - t.Fatal("command fragment changed", env) - } + env := <-out + var p proto.CommandOutputPayload + if env.DecodePayload(&p) != nil || env.Type != proto.TypeCommandOutput || env.ID != "run" || p.ID != "cmd" || p.Delta != fragment { + t.Fatal("command fragment changed", env) } } } diff --git a/apps/daemon/internal/agent/codex/session_notifications_test.go b/apps/daemon/internal/agent/codex/session_notifications_test.go index 0df73e0e1..0ee2a9b94 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_test.go @@ -13,8 +13,7 @@ func TestRootNotificationIsolation(t *testing.T) { t.Run(map[bool]string{false: "child without thread started", true: "child thread started"}[childStarted], func(t *testing.T) { out := make(chan proto.Envelope, 64) s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), - rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true, - observeToolObservations: true} + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true} s.registerHandlers() s.setThreadID("root") notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index ab1cdc452..ea99f3174 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -13,8 +13,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err := validateNativeTransportEnvironment(); err != nil { return SessionPlan{}, nil, err } - _, err := runtimePermissionProfile(req) - if err != nil { + if err := validatePermissionProfile(req); err != nil { return SessionPlan{}, nil, err } mcpServers, mcpEnv, err := runtimeMCPServers(req) @@ -35,7 +34,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Sandbox = "danger-full-access" plan.Permissions = "" plan.ApprovalPolicy = AskForApproval{String: "never"} - } else if req.DisableExecutionEnvironment { + } else { // environment:none has no workspace; the Session's private home is its cwd. plan.Cwd = plan.home.View } diff --git a/apps/daemon/internal/agent/codex/session_policy_test.go b/apps/daemon/internal/agent/codex/session_policy_test.go index a96b5c5e5..86c5b3c74 100644 --- a/apps/daemon/internal/agent/codex/session_policy_test.go +++ b/apps/daemon/internal/agent/codex/session_policy_test.go @@ -16,7 +16,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { for _, method := range []string{"thread/start", "thread/resume"} { t.Run(method, func(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true}, sessionConfig{}) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, DisableExecutionEnvironment: true}, sessionConfig{}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session_run.go b/apps/daemon/internal/agent/codex/session_run.go index 9051ea3c4..7517bfb98 100644 --- a/apps/daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -47,23 +47,21 @@ func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.P ThreadID: s.currentThreadID(), Input: input, } - if plan.CollaborationMode != "" { - model := strings.TrimSpace(s.resolvedModel) - if model == "" { - return fmt.Errorf("codex: collaboration mode requires a resolved model") - } - var developerInstructions *string - if plan.SystemPrompt != "" { - developerInstructions = &plan.SystemPrompt - } - turnParams.CollaborationMode = &CollaborationMode{ - Mode: plan.CollaborationMode, - Settings: CollaborationModeSettings{ - ReasoningEffort: plan.ModelReasoningEffort, - Model: model, - DeveloperInstructions: developerInstructions, - }, - } + model := strings.TrimSpace(s.resolvedModel) + if model == "" { + return fmt.Errorf("codex: collaboration mode requires a resolved model") + } + var developerInstructions *string + if plan.SystemPrompt != "" { + developerInstructions = &plan.SystemPrompt + } + turnParams.CollaborationMode = &CollaborationMode{ + Mode: CollaborationModeDefault, + Settings: CollaborationModeSettings{ + ReasoningEffort: plan.ModelReasoningEffort, + Model: model, + DeveloperInstructions: developerInstructions, + }, } turnCtx, turnCancel := context.WithTimeout(ctx, 10*time.Second) _, ackErr := s.rpc.requestWithResult(turnCtx, "turn/start", turnParams, s.bindTurnResult) diff --git a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go index c886439de..9b2bf38f0 100644 --- a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -84,7 +84,7 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { defer cancel() out := make(chan proto.Envelope, 8) s := &Session{ - runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, + runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, resolvedModel: "synthetic", cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), cleanup: func() {}, bufs: NewItemBuffers(), interactions: newPendingCodexInteractions(), } diff --git a/apps/daemon/internal/agent/codex/session_tools.go b/apps/daemon/internal/agent/codex/session_tools.go index 5a40b45c3..d1d4b4c94 100644 --- a/apps/daemon/internal/agent/codex/session_tools.go +++ b/apps/daemon/internal/agent/codex/session_tools.go @@ -10,13 +10,11 @@ func (s *Session) sendItemEvents(events []proto.Envelope, notification json.RawM var native struct { Item json.RawMessage `json:"item"` } - if s.observeToolObservations { - if err := json.Unmarshal(notification, &native); err != nil { - return - } + if err := json.Unmarshal(notification, &native); err != nil { + return } for _, event := range events { - if (s.observeToolObservations) && event.Type == proto.TypeToolCall { + if event.Type == proto.TypeToolCall { var tool proto.ToolCallPayload if err := event.DecodePayload(&tool); err != nil { return diff --git a/apps/daemon/internal/agent/codex/session_tools_test.go b/apps/daemon/internal/agent/codex/session_tools_test.go index 2eff526bb..8d4e06c8d 100644 --- a/apps/daemon/internal/agent/codex/session_tools_test.go +++ b/apps/daemon/internal/agent/codex/session_tools_test.go @@ -21,47 +21,39 @@ func toolSnapshotFixtures() []string { } } -func TestToolObservationsOnlyWhenRequested(t *testing.T) { - for _, enabled := range []bool{false, true} { - for _, item := range toolSnapshotFixtures() { - var source struct{ ID string } - if err := json.Unmarshal([]byte(item), &source); err != nil { - t.Fatal(err) +func TestToolObservations(t *testing.T) { + for _, item := range toolSnapshotFixtures() { + var source struct{ ID string } + if err := json.Unmarshal([]byte(item), &source); err != nil { + t.Fatal(err) + } + t.Run(source.ID, func(t *testing.T) { + out := make(chan proto.Envelope, 4) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.setThreadID("private-thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) + raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) + s.onItemStarted(raw) + s.onItemCompleted(raw) + if len(out) != 2 { + t.Fatalf("tool event count changed: %d", len(out)) } - t.Run(source.ID, func(t *testing.T) { - out := make(chan proto.Envelope, 4) - s := &Session{runID: "run", observeToolObservations: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} - s.setThreadID("private-thread") - s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) - raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) - s.onItemStarted(raw) - s.onItemCompleted(raw) - if len(out) != 2 { - t.Fatalf("tool event count changed: %d", len(out)) + for _, stage := range []string{"before", "after"} { + event := <-out + var tool proto.ToolCallPayload + if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage { + t.Fatal(event) } - for _, stage := range []string{"before", "after"} { - event := <-out - var tool proto.ToolCallPayload - if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage { - t.Fatal(event) - } - if bytes.Contains(event.Payload, []byte("native_item")) { - t.Fatal("engine-specific snapshot escaped adapter") - } - if !enabled { - if tool.Observation != nil { - t.Fatal("unrequested observation") - } - continue - } - if tool.Observation == nil || stage == "before" && tool.Observation.Status != "in_progress" { - t.Fatal(tool.Observation) - } - if source.ID == "mcp" && !bytes.Contains(tool.Observation.Output, []byte("9007199254740993")) { - t.Fatal("structured output precision lost") - } + if bytes.Contains(event.Payload, []byte("native_item")) { + t.Fatal("engine-specific snapshot escaped adapter") } - }) - } + if tool.Observation == nil || stage == "before" && tool.Observation.Status != "in_progress" { + t.Fatal(tool.Observation) + } + if source.ID == "mcp" && !bytes.Contains(tool.Observation.Output, []byte("9007199254740993")) { + t.Fatal("structured output precision lost") + } + } + }) } } diff --git a/apps/daemon/internal/agent/codex/skills.go b/apps/daemon/internal/agent/codex/skills.go index 69994f5ee..f7bdfe04a 100644 --- a/apps/daemon/internal/agent/codex/skills.go +++ b/apps/daemon/internal/agent/codex/skills.go @@ -1,24 +1,6 @@ package codex -import ( - "context" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func effectiveAgentStateKey(req proto.PromptRequestPayload) string { - if strings.TrimSpace(req.AgentStateKey) != "" { - return req.AgentStateKey - } - if id := strings.TrimSpace(req.ConversationID); id != "" { - return "_legacy_conversation/" + id + "/codex" - } - if id := strings.TrimSpace(req.RunID); id != "" { - return "_legacy_run/" + id + "/codex" - } - return "" -} +import "context" func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { if len(roots) == 0 { diff --git a/apps/daemon/internal/agent/codex/skills_test.go b/apps/daemon/internal/agent/codex/skills_test.go index 1f90c574b..bc316eed9 100644 --- a/apps/daemon/internal/agent/codex/skills_test.go +++ b/apps/daemon/internal/agent/codex/skills_test.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { @@ -40,13 +38,3 @@ func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { t.Fatalf("setSkillExtraRoots: %v", err) } } - -func TestEffectiveAgentStateKeyFallsBackToConversation(t *testing.T) { - got := effectiveAgentStateKey(proto.PromptRequestPayload{ - ConversationID: "conv-legacy", - RunID: "run-ignored", - }) - if got != "_legacy_conversation/conv-legacy/codex" { - t.Fatalf("state key = %q", got) - } -} diff --git a/apps/daemon/internal/agent/codex/subagent_profile.go b/apps/daemon/internal/agent/codex/subagent_profile.go index 6bf395c68..68afc9626 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile.go +++ b/apps/daemon/internal/agent/codex/subagent_profile.go @@ -15,16 +15,12 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay return nil } for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { - plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { plan.DisableFeatures = append(plan.DisableFeatures, feature) } plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features." + feature, "false"}) } - plan.DisableFeatures = slices.DeleteFunc(plan.DisableFeatures, func(value string) bool { return value == "multi_agent" }) - if !slices.Contains(plan.EnableFeatures, "multi_agent") { - plan.EnableFeatures = append(plan.EnableFeatures, "multi_agent") - } + plan.EnableFeatures = append(plan.EnableFeatures, "multi_agent") plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.multi_agent", "true"}, [2]string{"agents.max_depth", "64"}) if req.MaxConcurrentSubagents != nil { if *req.MaxConcurrentSubagents < 1 { diff --git a/apps/daemon/internal/agent/codex/subagent_profile_test.go b/apps/daemon/internal/agent/codex/subagent_profile_test.go index 5e9df85a0..14ee5aa5a 100644 --- a/apps/daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/daemon/internal/agent/codex/subagent_profile_test.go @@ -10,7 +10,7 @@ import ( func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { limit := 6 - plan := SessionPlan{EnableFeatures: []string{"hooks", "code_mode", "plugins", "multi_agent_v2"}, DisableFeatures: []string{"multi_agent"}} + plan := SessionPlan{} if err := configureSubagentObservations(&plan, proto.PromptRequestPayload{ObserveSubagentIdentities: true, MaxConcurrentSubagents: &limit}); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/subagents.go b/apps/daemon/internal/agent/codex/subagents.go index 710e12215..1c273779d 100644 --- a/apps/daemon/internal/agent/codex/subagents.go +++ b/apps/daemon/internal/agent/codex/subagents.go @@ -3,9 +3,8 @@ package codex import "slices" func disableSubagents(plan *SessionPlan) { - // Native v1 and v2 controls must override operator feature preferences. + // Disable both native Subagent feature versions. for _, feature := range []string{"multi_agent", "multi_agent_v2"} { - plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { plan.DisableFeatures = append(plan.DisableFeatures, feature) } diff --git a/apps/daemon/internal/agent/codex/subagents_test.go b/apps/daemon/internal/agent/codex/subagents_test.go index 9e725e0cf..b70d17d7c 100644 --- a/apps/daemon/internal/agent/codex/subagents_test.go +++ b/apps/daemon/internal/agent/codex/subagents_test.go @@ -5,14 +5,10 @@ import ( "testing" ) -func TestDisableSubagentsOverridesNativeFeaturePreferences(t *testing.T) { - plan := SessionPlan{ - EnableFeatures: []string{"multi_agent", "unrelated", "multi_agent_v2"}, - DisableFeatures: []string{"another", "multi_agent"}, - } +func TestDisableSubagentsDisablesBothNativeFeatures(t *testing.T) { + plan := SessionPlan{DisableFeatures: []string{"another", "multi_agent"}} disableSubagents(&plan) - if !reflect.DeepEqual(plan.EnableFeatures, []string{"unrelated"}) || - !reflect.DeepEqual(plan.DisableFeatures, []string{"another", "multi_agent", "multi_agent_v2"}) { - t.Fatal(plan.EnableFeatures, plan.DisableFeatures) + if !reflect.DeepEqual(plan.DisableFeatures, []string{"another", "multi_agent", "multi_agent_v2"}) { + t.Fatal(plan.DisableFeatures) } } diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 499d847e1..c989cae89 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -144,7 +144,7 @@ func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg se if (req.LocalEnvironment == nil) != req.DisableExecutionEnvironment || !path.IsAbs(cwd) { return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace or with environment none", agent.ErrUnsupportedOperation) } - if _, err := runtimePermissionProfile(req); err != nil { + if err := validatePermissionProfile(req); err != nil { return SessionPlan{}, err } // The Harness runs each stdio alias without arguments, which the native diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index 7085e371d..024c61e34 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -27,11 +27,6 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { return nil, expected }) configuration.Providers[0].Protocol = "anthropic" - copy, err := registry.Configuration("fixture") - if err != nil { - t.Fatal(err) - } - copy.Providers[0].Protocol = "anthropic" factory, _ := registry.Resolve("fixture") executor, _ := registry.ResolveExecutor("fixture") entries := []func(proto.PromptRequestPayload) error{ @@ -58,9 +53,6 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { if calls != 2 { t.Fatal("unexpected native calls", calls) } - if _, err := registry.Configuration("missing"); err == nil { - t.Fatal("undeclared configuration inferred") - } } func TestRegistryRejectsInvalidConfigurationDeclaration(t *testing.T) { diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 0894a867e..7a080e164 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -298,8 +298,8 @@ type ViewSession struct { MCP []MCPBinding // Launch replaces clirunner.Start. Each call builds one view and runs // Binary, which must be a LocalExec path, in it. Dir is a world path, or - // the work directory in an empty-root view; OwnProcessGroup is true, and - // Env is the complete Harness environment. + // the work directory in an empty-root view, and Env is the complete + // Harness environment. // Cancel sends TERM to every process in the view and closes the view after // KillTimeout; a Cancel after the Harness exited leaves its exit as it was. // When the Harness exits while other processes remain, the view sends them @@ -565,8 +565,8 @@ type TurnSettlement struct { Reason string } -// Session is the cancellation and outcome surface shared by direct prompt runs -// and Turns. Every owner exposes observed state, including direct-call sessions. +// Session is the cancellation and outcome surface shared by direct-call +// sessions and Turns. Every owner exposes observed state. // For Executor-owned Turns, AwaitSettlement and Executor.Close define settlement // and resource retirement; Cancel alone does not transfer resource ownership. type Session interface { @@ -645,11 +645,12 @@ type WorkspaceWriter interface { WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) } -// Direct-call factory and registration. These use the existing Registry behavior. +// Kind registration and the direct-call factory. -// Factory builds a Session for one prompt_request. out is the upstream -// channel the agent writes into and closes exactly once after terminal output. -// ctx is cancelled by the router to wind the session down. +// Factory builds a Session that runs req.Input as req.RunID without an +// Executor; the router starts every Run through RegisterExecutor instead. out +// is the channel the agent writes into and closes exactly once after terminal +// output. ctx is cancelled to wind the session down. type Factory func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (Session, error) // RegisterKind installs f and the heartbeat descriptor for an diff --git a/apps/daemon/internal/agent/installroot/probe.go b/apps/daemon/internal/agent/installroot/probe.go index b185c31bc..21f50ed47 100644 --- a/apps/daemon/internal/agent/installroot/probe.go +++ b/apps/daemon/internal/agent/installroot/probe.go @@ -14,7 +14,7 @@ import ( func Probe(parent context.Context, binary string, args, env []string, dir string) (string, error) { ctx, cancel := context.WithTimeout(parent, 25*time.Second) defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, KillTimeout: 250 * time.Millisecond}) if err != nil { return "", errors.New("native component failed to start") } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index f8ea52093..5ca8565f9 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -98,7 +98,7 @@ func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T case "reserved": req.LocalEnvironment.MCP[0].Server.Name = "oac_workspace" } - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { + if _, err := prepareWorkspaceOptions(c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { t.Fatal("unqualified declaration accepted or credential exposed") } }) @@ -109,7 +109,6 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { c, req, _ := workspaceFixture(t) c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} - req.ObserveToolObservations = true script, err := os.ReadFile(c.Binary) if err != nil { t.Fatal(err) @@ -238,7 +237,7 @@ func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" token := "selected-public-vault-canary" req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", BearerToken: &token}} - opts, err := prepareWorkspaceOptions(t.Context(), c, req) + opts, err := prepareWorkspaceOptions(c, req) if err != nil { t.Fatal(err) } @@ -267,12 +266,12 @@ func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { } empty := []string{} (*req.MCPHTTPServers)[0].AllowedTools = &empty - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + if _, err := prepareWorkspaceOptions(c, req); err == nil { t.Fatal("empty allowlist silently treated as all") } (*req.MCPHTTPServers)[0].AllowedTools = nil (*req.MCPHTTPServers)[0].Required = true - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + if _, err := prepareWorkspaceOptions(c, req); err == nil { t.Fatal("required initialization silently ignored") } } diff --git a/apps/daemon/internal/agent/mcode/events.go b/apps/daemon/internal/agent/mcode/events.go index 0b04ce6dd..97bd1f8cd 100644 --- a/apps/daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -59,7 +59,7 @@ func (s *Session) emitTool(update toolUpdate) error { if update.ID == "" || s.completedTools[update.ID] { return nil } - previous, started := s.tools[update.ID] + previous := s.tools[update.ID] if update.Name == "" { update.Name = previous.Name } @@ -69,21 +69,18 @@ func (s *Session) emitTool(update toolUpdate) error { if update.RawInput == nil { update.RawInput = previous.RawInput } - if s.req.ObserveToolObservations { - update.mcp = previous.mcp - if update.mcp != nil && update.Name != previous.Name { - return fmt.Errorf("mcode: native MCP call identity changed") - } - if update.mcp == nil { - var err error - update.mcp, err = s.environmentMCPIdentity(update.Name) - if err != nil { - return err - } + update.mcp = previous.mcp + if update.mcp != nil && update.Name != previous.Name { + return fmt.Errorf("mcode: native MCP call identity changed") + } + if update.mcp == nil { + var err error + update.mcp, err = s.environmentMCPIdentity(update.Name) + if err != nil { + return err } - started = previous.mcp != nil || workspaceToolObservation(previous, "before") != nil } - if !started { + if previous.mcp == nil && workspaceToolObservation(previous, "before") == nil { if err := s.emitToolStage(update, "before"); err != nil { return err } diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index 2a1d36c14..7b2a93d93 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -7,7 +7,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// SupportsExecution is an operator opt-in, separate from ordinary product availability. +// SupportsExecution reports whether the daemon advertises MiniMax Code execution: +// the operator sets OAC_RUNTIME_MCODE_AGENTS_API=1 and the native version is the +// qualified one. Otherwise discovery reports only availability. func SupportsExecution(version string) bool { return os.Getenv("OAC_RUNTIME_MCODE_AGENTS_API") == "1" && version == SupportedVersion } @@ -37,12 +39,8 @@ func configureTextExecution(config map[string]any) { // Harness children use the daemon user's ordinary environment. func executionEnvironment() []string { return os.Environ() } -// ACP commands are only recognized for a single text block. Public input must -// remain user text; ordinary product Sessions retain their native command behavior. -func promptContent(text string, public bool) []map[string]string { - blocks := []map[string]string{{"type": "text", "text": text}} - if public { - blocks = append(blocks, map[string]string{"type": "text", "text": ""}) - } - return blocks +// ACP commands are only recognized for a single text block. A second, empty +// block keeps public input as user text. +func promptContent(text string) []map[string]string { + return []map[string]string{{"type": "text", "text": text}, {"type": "text", "text": ""}} } diff --git a/apps/daemon/internal/agent/mcode/execution_test.go b/apps/daemon/internal/agent/mcode/execution_test.go index a49d3e6f4..9f188f97f 100644 --- a/apps/daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -10,15 +10,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func executionRequest(t *testing.T) proto.PromptRequestPayload { - r := testRequest(t) - r.StrictResume, r.ReleaseOnCompletion, r.DisableExecutionEnvironment, r.DisableSubagents = true, true, true, true - r.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} - return r -} - func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { - r := executionRequest(t) + r := testRequest(t) t.Setenv("OAC_TEST_SECRET_CANARY", "secret") t.Setenv("NODE_OPTIONS", "--import=untrusted") opts, err := prepareOptions(r) @@ -53,7 +46,7 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "f"}} }, func(r *proto.PromptRequestPayload) { r.ExecutionControls.WebSearch = "enabled" }, } { - r := executionRequest(t) + r := testRequest(t) change(&r) if _, err := prepareOptions(r); err == nil { t.Fatal("unsupported execution accepted") @@ -63,12 +56,9 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { func TestPublicTextDoesNotInvokeACPCommands(t *testing.T) { for _, text := range []string{"/model", "/compact", "hello"} { - blocks := promptContent(text, true) + blocks := promptContent(text) if len(blocks) != 2 || blocks[0]["text"] != text || blocks[1]["text"] != "" { t.Fatal(blocks) } - if blocks = promptContent(text, false); len(blocks) != 1 || blocks[0]["text"] != text { - t.Fatal("product prompt changed") - } } } diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 519a71baf..ac22cced5 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -40,25 +40,25 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { if frozen != nil { binary = frozen.Binary } - return startExecutor(ctx, req, binary, func(ctx context.Context) (launchOptions, error) { + return startExecutor(ctx, req, binary, func() (launchOptions, error) { if frozen == nil { return prepareOptions(req) } - return prepareWorkspaceOptions(ctx, *frozen, req) + return prepareWorkspaceOptions(*frozen, req) }) } } // startExecutor prepares the native owner for req, which carries no Turn // input, and starts binary. -func startExecutor(ctx context.Context, req proto.PromptRequestPayload, binary string, prepare func(context.Context) (launchOptions, error)) (agent.Executor, error) { +func startExecutor(ctx context.Context, req proto.PromptRequestPayload, binary string, prepare func() (launchOptions, error)) (agent.Executor, error) { if ctx == nil { ctx = context.Background() } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") } - opts, err := prepare(ctx) + opts, err := prepare() if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go index 637f32c4f..0daeb06e2 100644 --- a/apps/daemon/internal/agent/mcode/executor_native_test.go +++ b/apps/daemon/internal/agent/mcode/executor_native_test.go @@ -28,9 +28,8 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { if err != nil { t.Fatal("private provider options unavailable") } - req := executionRequest(t) - req.ReleaseOnCompletion = false - req.RunID, req.Input, req.ConversationID = "", nil, "" + req := testRequest(t) + req.RunID, req.Input = "", nil if json.Unmarshal(raw, &req) != nil { t.Fatal("invalid private provider options") } diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index 3c949ab0a..a7aba62ed 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -15,8 +15,8 @@ import ( func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { t.Helper() - r := executionRequest(t) - r.RunID, r.Input, r.ConversationID = "", nil, "" + r := testRequest(t) + r.RunID, r.Input = "", nil r.DisableExecutionEnvironment = false r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} record := filepath.Join(t.TempDir(), "calls") @@ -36,7 +36,6 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { t.Helper() config, req, record := workspaceFixture(t) - req.ReleaseOnCompletion = false script, err := os.ReadFile(config.Binary) if err != nil { t.Fatal(err) @@ -48,9 +47,8 @@ func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, if workspace { factory = NewExecutorFactory(&config) } else { - req = executionRequest(t) - req.ReleaseOnCompletion = false - req.RunID, req.Input, req.ConversationID = "", nil, "" + req = testRequest(t) + req.RunID, req.Input = "", nil t.Setenv("OAC_RUNTIME_MCODE_BIN", config.Binary) factory = NewExecutorFactory(nil) } @@ -283,7 +281,6 @@ func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { func TestExecutorFactoryPreparationFailureHasNoTypedNilOwner(t *testing.T) { config, req, _ := workspaceFixture(t) - req.ReleaseOnCompletion = false if err := os.WriteFile(config.Binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/executor_turn.go b/apps/daemon/internal/agent/mcode/executor_turn.go index 3bf537374..1633f5f18 100644 --- a/apps/daemon/internal/agent/mcode/executor_turn.go +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -25,7 +25,7 @@ func (s *Session) runExecutorTurn() { // Written steering requests retain their original receipt owner even after // prompt completion. No successor starts until all callers have settled. s.operations.Wait() - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady { + if !s.req.DisableSubagents && s.subagentHistoryReady { childErr := s.settleSubagents() s.mu.Lock() s.subagentSettlementError = childErr @@ -82,7 +82,7 @@ func (s *Session) runExecutorTurn() { } func (s *Session) captureSubagentBaseline() error { - if !s.req.StrictResume || s.req.DisableSubagents { + if s.req.DisableSubagents { return nil } snapshot, err := s.readSubagents(s.ctx) @@ -135,7 +135,7 @@ func (s *Session) cancelTurn(ctx context.Context) error { err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", Method: "session/cancel", Params: raw}) } if first { - if err == nil && s.req.StrictResume && !s.req.DisableSubagents { + if err == nil && !s.req.DisableSubagents { err = s.stopSubagents(ctx) } if err != nil { diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index 240c8913d..78c94b467 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -16,7 +16,7 @@ func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { t.Helper() out := make(chan proto.Envelope, 16) s := &Session{ctx: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, - req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true, + req: proto.PromptRequestPayload{RunID: "run", LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, active: true, sessionID: "native-session"} resolveTestBindings(s) diff --git a/apps/daemon/internal/agent/mcode/native_history_test.go b/apps/daemon/internal/agent/mcode/native_history_test.go index 5019a4f56..98c7c6a29 100644 --- a/apps/daemon/internal/agent/mcode/native_history_test.go +++ b/apps/daemon/internal/agent/mcode/native_history_test.go @@ -24,7 +24,7 @@ func TestNativeMCodeHistoryIsolation(t *testing.T) { } for name, id := range map[string]string{"foreign": foreign, "missing": "00000000-0000-4000-8000-000000000000"} { t.Run(name, func(t *testing.T) { - req := executionRequest(t) + req := testRequest(t) if json.Unmarshal(raw, &req) != nil { t.Fatal("invalid private options") } diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index a2b6e148a..e6c1a6e9a 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -2,7 +2,6 @@ package mcode import ( "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -52,7 +51,7 @@ func prepareOptionsWithTools(req proto.PromptRequestPayload, tools *workspaceToo if err != nil { return result, err } - if req.StrictResume && !req.DisableSubagents { + if !req.DisableSubagents { if _, _, err := subagentReader(); err != nil { return result, err } @@ -91,10 +90,8 @@ func validateOptions(req proto.PromptRequestPayload) (harnessconfig.PreparedConf if prepared.Model == "" || prepared.Provider == nil { return prepared, fmt.Errorf("mcode: model and model provider are required") } - if req.StrictResume { - if err := validateExecutionRequest(req); err != nil { - return prepared, err - } + if err := validateExecutionRequest(req); err != nil { + return prepared, err } if req.Input.HasImages() { return prepared, fmt.Errorf("mcode: ACP does not support attachments") @@ -115,15 +112,13 @@ func writeNativeConfig(req proto.PromptRequestPayload, prepared harnessconfig.Pr } config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} config["custom_provider"] = map[string]any{"oac": modelProviderConfig(*prepared.Provider, prepared.Model)} - if req.StrictResume { - configureTextExecution(config) - if !req.DisableSubagents { - config["agents"] = map[string]any{"default": map[string]any{ - "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, - "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, - "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, - }} - } + configureTextExecution(config) + if !req.DisableSubagents { + config["agents"] = map[string]any{"default": map[string]any{ + "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, + "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, + "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, + }} } config["permissionMode"] = "auto" servers := map[string]any{} @@ -158,35 +153,23 @@ func writeNativeConfig(req proto.PromptRequestPayload, prepared harnessconfig.Pr if err := data.WriteFile("config.yaml", raw, 0o600); err != nil { return err } - if req.StrictResume { - if raw, err = json.Marshal(map[string]any{"mcpServers": servers}); err != nil { - return err - } - if err := data.WriteFile("mcp.json", raw, 0o600); err != nil { - return err - } + if raw, err = json.Marshal(map[string]any{"mcpServers": servers}); err != nil { + return err } - return nil + return data.WriteFile("mcp.json", raw, 0o600) } // nativeEnvironment is the adapter's own native environment, with dataDir as // the native process sees its data directory. The adapter owns the native // state location, including after cold resume. func nativeEnvironment(req proto.PromptRequestPayload, dataDir string) []string { - var env []string - if req.StrictResume { - env = append(env, "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") - if !req.DisableSubagents { - env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) - } else { - env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") - } + env := []string{"OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1"} + if !req.DisableSubagents { + env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) + } else { + env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") } - env = append(env, "MINIMAX_DATA_DIR="+dataDir) - if req.StrictResume { - env = append(env, "HOME="+dataDir, "USERPROFILE="+dataDir) - } - return env + return append(env, "MINIMAX_DATA_DIR="+dataDir, "HOME="+dataDir, "USERPROFILE="+dataDir) } // readData reads a file the native process wrote in its data directory, @@ -215,26 +198,16 @@ func dataDirectory(req proto.PromptRequestPayload) (string, error) { if err != nil { return "", fmt.Errorf("mcode: resolve data directory: %w", err) } - base := filepath.Join(root, "runtime", "mcode") - if key := strings.TrimSpace(req.AgentStateKey); key != "" { - parts := []string{base, "state"} - for _, part := range strings.Split(key, "/") { - if safe := safePathPart(part); safe != "" { - parts = append(parts, safe) - } + parts := []string{root, "runtime", "mcode", "state"} + for _, part := range strings.Split(req.AgentStateKey, "/") { + if safe := safePathPart(part); safe != "" { + parts = append(parts, safe) } - if len(parts) == 2 { - return "", fmt.Errorf("mcode: invalid agent state key %q", req.AgentStateKey) - } - return filepath.Join(parts...), nil - } - if id := safePathPart(req.ConversationID); id != "" { - return filepath.Join(base, "conv-"+id), nil } - if id := safePathPart(req.RunID); id != "" { - return filepath.Join(base, "run-"+id), nil + if len(parts) == 4 { + return "", fmt.Errorf("mcode: invalid agent state key %q", req.AgentStateKey) } - return "", errors.New("mcode: agent state key, conversation id, or run id is required") + return filepath.Join(parts...), nil } func safePathPart(value string) string { diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 9c11b545a..13099f019 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -11,6 +11,7 @@ import ( ) func TestOptionsRefreshManagedState(t *testing.T) { + t.Setenv("MINIMAX_DATA_DIR", "/wrong") req := testRequest(t) opts, err := prepareOptions(req) if err != nil { @@ -19,7 +20,13 @@ func TestOptionsRefreshManagedState(t *testing.T) { if !strings.HasPrefix(opts.Dir, os.Getenv("OAC_RUNTIME_HOME")+string(os.PathSeparator)) { t.Fatalf("workdir escaped managed state: %s", opts.Dir) } - if opts.Env[len(opts.Env)-1] != "MINIMAX_DATA_DIR="+opts.DataDir { + dataDir := "" + for _, entry := range opts.Env { + if value, ok := strings.CutPrefix(entry, "MINIMAX_DATA_DIR="); ok { + dataDir = value + } + } + if dataDir != opts.DataDir { t.Fatal("native data directory is not the adapter's") } req.SystemPrompt = "" @@ -97,11 +104,16 @@ func TestQuestionContentPreservesTypesAndValidates(t *testing.T) { } } -func TestDataDirectorySanitizesFallback(t *testing.T) { +func TestDataDirectoryRequiresAgentState(t *testing.T) { home := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", home) - got, err := dataDirectory(proto.PromptRequestPayload{ConversationID: "../conv name", RunID: "ignored"}) - if want := filepath.Join(home, "runtime", "mcode", "conv-.._conv_name"); err != nil || got != want { + for _, key := range []string{"", " ", "../.."} { + if _, err := dataDirectory(proto.PromptRequestPayload{AgentStateKey: key, RunID: "ignored"}); err == nil { + t.Fatalf("state key %q accepted", key) + } + } + got, err := dataDirectory(proto.PromptRequestPayload{AgentStateKey: "../session-1/a b"}) + if want := filepath.Join(home, "runtime", "mcode", "state", "session-1", "a_b"); err != nil || got != want { t.Fatalf("data directory = %q, %v; want %q", got, err, want) } } diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index fe6794a09..91766f1d5 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -87,7 +87,7 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti if opts.script != "" { args = []string{opts.script, "acp"} } - process, err := start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) + process, err := start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Dir: opts.Dir, Env: opts.Env, NeedStdin: true}) if err != nil { return nil, err } @@ -111,7 +111,7 @@ func (s *Session) run() { defer close(s.finished) err := s.prepareNative() if err == nil { - if s.req.StrictResume && !s.req.DisableSubagents { + if !s.req.DisableSubagents { var snapshot nativeSubagentSnapshot snapshot, err = s.readSubagents(s.ctx) s.subagentHistoryReady = err == nil @@ -128,7 +128,7 @@ func (s *Session) run() { if err == nil { err = s.executePrompt() } - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { + if !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { observationErr := s.settleSubagents() s.mu.Lock() s.subagentSettlementError = observationErr @@ -181,7 +181,7 @@ func (s *Session) prepareNative() error { if initialized.ProtocolVersion != 1 { return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) } - if s.req.StrictResume && !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { + if !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { return fmt.Errorf("mcode: native Subagent admission is unavailable") } params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} @@ -228,7 +228,7 @@ func (s *Session) executePrompt() error { var result struct { StopReason string `json:"stopReason"` } - err = s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(prompt, s.req.StrictResume)}, &result, true) + err = s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(prompt)}, &result, true) s.active = false s.mu.Lock() s.steeringReady = false @@ -367,16 +367,13 @@ func (s *Session) Cancel(ctx context.Context) error { if s.executor != nil { return s.cancelTurn(ctx) } - if s.req.StrictResume && !s.req.DisableSubagents { + if !s.req.DisableSubagents { if err := s.cancelSubagents(ctx); err != nil { s.process.Cancel() return err } } s.process.Cancel() - if !s.req.StrictResume { - return nil - } select { case <-s.exited: case <-ctx.Done(): diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index d075111a9..7e72086b8 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -19,17 +19,15 @@ import ( func testRequest(t *testing.T) proto.PromptRequestPayload { t.Helper() t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - return proto.PromptRequestPayload{RunID: "run-1", ConversationID: "conversation-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), + return proto.PromptRequestPayload{RunID: "run-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), Model: "fixture", SystemPrompt: "Current instructions", - ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example", APIKey: "fixture-key", ContextWindow: 64000, MaxOutputTokens: 4096}} + ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example", APIKey: "fixture-key", ContextWindow: 64000, MaxOutputTokens: 4096}, + DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} } func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { t.Helper() req := testRequest(t) - if scenario == "strict-cancel" { - req = executionRequest(t) - } if resume { req.AgentSessionID = "native-1" } @@ -273,7 +271,7 @@ func TestMCodeProcess(t *testing.T) { Prompt []map[string]string `json:"prompt"` } _ = json.Unmarshal(frame.Params, &input) - if strict := scenario == "strict-cancel" || (strings.HasPrefix(scenario, "prepared") || strings.HasPrefix(scenario, "executor")); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + if len(input.Prompt) != 2 { os.Exit(9) } if strings.HasPrefix(scenario, "executor") { @@ -287,7 +285,7 @@ func TestMCodeProcess(t *testing.T) { continue } update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) - update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "Read", "status": "in_progress", "rawInput": map[string]any{}}) + update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "true"}}) update("tool_call_update", map[string]any{"toolCallId": "repeated-call", "status": "completed"}) raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) @@ -303,7 +301,7 @@ func TestMCodeProcess(t *testing.T) { update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) continue } - if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { + if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "cancel-wait" { promptID = frame.ID update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) continue @@ -326,7 +324,7 @@ func TestMCodeProcess(t *testing.T) { } update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) - update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "Read", "status": "in_progress", "rawInput": map[string]any{"path": "fixture.txt"}}) + update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "cat fixture.txt"}}) for range 2 { update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) } diff --git a/apps/daemon/internal/agent/mcode/steering_test.go b/apps/daemon/internal/agent/mcode/steering_test.go index 2c4cc517a..1ecdb5af1 100644 --- a/apps/daemon/internal/agent/mcode/steering_test.go +++ b/apps/daemon/internal/agent/mcode/steering_test.go @@ -85,7 +85,7 @@ func TestTerminalFollowsAllNativeFrames(t *testing.T) { } func TestExecutionCancellationWaitsForOutputAndProcess(t *testing.T) { - s, out := helperSession(t, "strict-cancel", false) + s, out := helperSession(t, "cancel-wait", false) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() select { diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go index 557ff5dfa..ab6accd5a 100644 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -60,7 +60,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T if err != nil { t.Fatal(err) } - opts, err := prepareWorkspaceOptions(t.Context(), config, prepared) + opts, err := prepareWorkspaceOptions(config, prepared) if err != nil { t.Fatal(err) } @@ -125,7 +125,7 @@ func TestWorkspaceRejectsInvalidToolEnvironment(t *testing.T) { } t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) - if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { + if _, err := prepareWorkspaceOptions(config, req); err == nil { t.Fatal("invalid explicit tool configuration was ignored") } } diff --git a/apps/daemon/internal/agent/mcode/tool_observations.go b/apps/daemon/internal/agent/mcode/tool_observations.go index 7f6ba9f9c..1dbd6d643 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations.go +++ b/apps/daemon/internal/agent/mcode/tool_observations.go @@ -11,20 +11,18 @@ func (s *Session) emitToolStage(update toolUpdate, stage string) error { if stage == "after" { payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} } - if s.req.ObserveToolObservations { - payload.Observation = workspaceToolObservation(update, stage) - if payload.Observation == nil { - var err error - payload.Observation, err = environmentMCPObservation(update, stage) - if err != nil { - return err - } - } - // Native task/skill bookkeeping has no qualified public item mapping. - if payload.Observation == nil { - return nil + payload.Observation = workspaceToolObservation(update, stage) + if payload.Observation == nil { + var err error + payload.Observation, err = environmentMCPObservation(update, stage) + if err != nil { + return err } } + // Native task/skill bookkeeping has no qualified public item mapping. + if payload.Observation == nil { + return nil + } s.emit(proto.TypeToolCall, payload) return nil } diff --git a/apps/daemon/internal/agent/mcode/tool_observations_test.go b/apps/daemon/internal/agent/mcode/tool_observations_test.go index e9011d503..c7d1fa6da 100644 --- a/apps/daemon/internal/agent/mcode/tool_observations_test.go +++ b/apps/daemon/internal/agent/mcode/tool_observations_test.go @@ -12,7 +12,7 @@ func TestWorkspaceCommandObservationsWaitForArgumentsAndRetainOutcome(t *testing for _, status := range []string{"completed", "failed"} { t.Run(status, func(t *testing.T) { out := make(chan proto.Envelope, 8) - s := &Session{ctx: context.Background(), req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + s := &Session{ctx: context.Background(), req: proto.PromptRequestPayload{RunID: "run"}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} s.emitTool(toolUpdate{ID: "call", Name: "mcp__oac_workspace__workspace_bash"}) if len(out) != 0 { t.Fatal("command item emitted before native arguments") diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index da96527ac..f6058f1f2 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -145,8 +145,8 @@ func (i viewInstall) view() agent.View { } func (i viewInstall) executor(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { - return startExecutor(ctx, req, i.node, func(ctx context.Context) (launchOptions, error) { - return i.prepare(ctx, req, session) + return startExecutor(ctx, req, i.node, func() (launchOptions, error) { + return i.prepare(req, session) }) } @@ -155,9 +155,9 @@ func (i viewInstall) executor(ctx context.Context, req proto.PromptRequestPayloa // names and the MCP in session. The request's workspace is the sandbox's, and // the workspace tools present it; with environment none the CLI runs in the // work directory without them. -func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (launchOptions, error) { +func (i viewInstall) prepare(req proto.PromptRequestPayload, session agent.ViewSession) (launchOptions, error) { local := req.LocalEnvironment - if !req.StrictResume || (local == nil) != req.DisableExecutionEnvironment || req.WorkspaceReadOnly { + if (local == nil) != req.DisableExecutionEnvironment || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view runs Agents API execution in a writable Environment workspace or with environment none", agent.ErrUnsupportedOperation) } dir := path.Join(session.Home.View, agent.ViewWorkName) @@ -222,6 +222,6 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, } opts.Env = append(opts.Env, nativeEnvironment(private, dataDir)...) opts.spawn = session.Spawn - opts.reader = clirunner.StartOptions{Binary: i.node, Args: []string{path.Join(path.Dir(i.bridge), "subagent-snapshot.mjs"), dataDir}, Dir: dataDir, Env: opts.Env, OwnProcessGroup: true} + opts.reader = clirunner.StartOptions{Binary: i.node, Args: []string{path.Join(path.Dir(i.bridge), "subagent-snapshot.mjs"), dataDir}, Dir: dataDir, Env: opts.Env} return opts, nil } diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go index d6fd3d47a..7b6d3a953 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -56,8 +56,8 @@ func viewFixture(t *testing.T) (viewInstall, agent.View, proto.PromptRequestPayl t.Setenv("OAC_TEST_VIEW_SENTINEL", "daemon-only") t.Setenv("ANTHROPIC_API_KEY", viewRealKey) - req := executionRequest(t) - req.RunID, req.Input, req.ConversationID = "", nil, "" + req := testRequest(t) + req.RunID, req.Input = "", nil req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: "/workspace", NetworkAccess: "enabled"} req.ModelProvider = &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "http://127.0.0.1:4101", APIKey: modelprovider.Placeholder, ContextWindow: 64000, MaxOutputTokens: 4096} @@ -127,14 +127,14 @@ func TestViewRunsEnvironmentNoneAndStdioAliases(t *testing.T) { session := agent.ViewSession{Home: agent.ViewDir{Host: t.TempDir(), View: path.Join(agent.ViewPrivateRoot, agent.ViewHomeName)}} none := req none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true - opts, err := install.prepare(t.Context(), none, session) + opts, err := install.prepare(none, session) if err != nil || opts.Dir != "/.oac/home/work" || opts.MCP == nil || len(opts.MCP) != 0 { t.Fatalf("environment none runs in %q with MCP %v: %v", opts.Dir, opts.MCP, err) } session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} - if opts, err = install.prepare(t.Context(), req, session); err != nil || len(opts.MCP) != 2 || opts.MCP[0]["name"] != "oac_workspace" || opts.MCP[1]["command"] != agent.ViewAlias(0) { + if opts, err = install.prepare(req, session); err != nil || len(opts.MCP) != 2 || opts.MCP[0]["name"] != "oac_workspace" || opts.MCP[1]["command"] != agent.ViewAlias(0) { t.Fatalf("stdio MCP = %v: %v", opts.MCP, err) } if args, ok := opts.MCP[1]["args"].([]string); !ok || args == nil || len(args) != 0 { @@ -152,7 +152,7 @@ func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { spawned = options return clirunner.Start(clirunner.StartOptions{Parent: options.Parent, Binary: "/bin/echo", Args: []string{`{"version":1,"complete":true,"rootSessionId":"root"}`}}) } - opts, err := install.prepare(t.Context(), req, session) + opts, err := install.prepare(req, session) if err != nil { t.Fatal(err) } @@ -162,7 +162,7 @@ func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { } data := path.Join(session.Home.View, viewDataName) args := []string{"--disable-warning=ExperimentalWarning", path.Join(path.Dir(install.bridge), "subagent-snapshot.mjs"), data, "root"} - if spawned.Binary != install.node || !slices.Equal(spawned.Args, args) || spawned.Dir != data || !spawned.OwnProcessGroup || !slices.Contains(spawned.Env, "LD_LIBRARY_PATH="+install.loader.LibraryPath) { + if spawned.Binary != install.node || !slices.Equal(spawned.Args, args) || spawned.Dir != data || !slices.Contains(spawned.Env, "LD_LIBRARY_PATH="+install.loader.LibraryPath) { t.Fatalf("spawned %+v", spawned) } } diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index 459302897..61bcb719a 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -1,7 +1,6 @@ package mcode import ( - "context" "fmt" "os" "path/filepath" @@ -49,11 +48,11 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn return c, nil } -func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { +func prepareWorkspaceOptions(c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { if c.Network != "enabled" || len(c.AllowedDomains) != 0 { return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") } - if !req.StrictResume || req.LocalEnvironment == nil || req.LocalEnvironment.WorkspaceRoot != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { + if req.LocalEnvironment == nil || req.LocalEnvironment.WorkspaceRoot != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } servers, bindings, err := runtimeMCP(req) diff --git a/apps/daemon/internal/agent/mcode/workspace_network_test.go b/apps/daemon/internal/agent/mcode/workspace_network_test.go index a42d387cd..83b3156ee 100644 --- a/apps/daemon/internal/agent/mcode/workspace_network_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_network_test.go @@ -7,7 +7,7 @@ func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { c, req, _ := workspaceFixture(t) c.Network = access req.LocalEnvironment.NetworkAccess = access - if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + if _, err := prepareWorkspaceOptions(c, req); err == nil { t.Fatal("unsupported network isolation accepted") } } diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness.go b/apps/daemon/internal/agent/mcode/workspace_readiness.go index b730745b4..7f44d672a 100644 --- a/apps/daemon/internal/agent/mcode/workspace_readiness.go +++ b/apps/daemon/internal/agent/mcode/workspace_readiness.go @@ -16,7 +16,7 @@ import ( func CheckWorkspace(ctx context.Context, c WorkspaceConfig) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(filepath.Dir(c.Bridge), "check.mjs")}, Env: executionEnvironment(), OwnProcessGroup: true}) + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(filepath.Dir(c.Bridge), "check.mjs")}, Env: executionEnvironment()}) if err != nil { return err } diff --git a/apps/daemon/internal/agent/mcode/workspace_skills_test.go b/apps/daemon/internal/agent/mcode/workspace_skills_test.go index ed89e029f..0d7c9470d 100644 --- a/apps/daemon/internal/agent/mcode/workspace_skills_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_skills_test.go @@ -28,7 +28,7 @@ func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { Metadata: agentskill.Metadata{Name: "proof", Description: "Read a marker"}, }} for range 2 { - opts, err := prepareWorkspaceOptions(t.Context(), c, req) + opts, err := prepareWorkspaceOptions(c, req) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index cb1ab18b1..5dec68997 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -105,8 +105,6 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env return nil, unsupported("environment none") case local != nil && !isViewPath(local.WorkspaceDirectory): return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) - case !req.StrictResume: - return nil, unsupported("a Session without strict resume") case local != nil && local.Capabilities && local.CapabilityRoot == "": return nil, unsupported("installed Capabilities that no preparation resolved") case local != nil && len(local.Skills) > 0 && !caps.Skills.IsSupported(): diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index 054085479..cc49ca3fd 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -85,7 +85,6 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "relative workspace": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.WorkspaceDirectory = "workspace" }, []error{ErrInvalidSession}}, "no model provider": {"viewed", func(r *proto.PromptRequestPayload) { r.ModelProvider = nil }, []error{ErrUnsupported}}, // The typed rejections that hold whatever the view declares. - "no strict resume": {"supporting", func(r *proto.PromptRequestPayload) { r.StrictResume = false }, unsupported}, "restricted network": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, unsupported}, "allowed domains only": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, unsupported}, "unprepared Capabilities": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, unsupported}, diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index d0d47654e..ef0537d7f 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -89,7 +89,6 @@ func declared(s proto.CapabilitySupport) agent.ViewCapabilities { func request(kind, workspace, baseURL, key string) proto.PromptRequestPayload { return proto.PromptRequestPayload{ AgentKind: kind, - StrictResume: true, Model: "m", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: baseURL, APIKey: key}, LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}, diff --git a/apps/daemon/internal/agenthost/launch_linux.go b/apps/daemon/internal/agenthost/launch_linux.go index 3fde5e108..08456e20c 100644 --- a/apps/daemon/internal/agenthost/launch_linux.go +++ b/apps/daemon/internal/agenthost/launch_linux.go @@ -73,8 +73,6 @@ func (s *session) checkStart(opts clirunner.StartOptions) error { return fmt.Errorf("%w: %w: %q", ErrLaunch, agent.ErrNotLocalExec, opts.Binary) case !isViewPath(opts.Dir): return fmt.Errorf("%w: directory %q is not absolute and clean", ErrLaunch, opts.Dir) - case !opts.OwnProcessGroup: - return fmt.Errorf("%w: a view process runs in its own process group", ErrLaunch) } return nil } diff --git a/apps/daemon/internal/agenthost/session_linux_test.go b/apps/daemon/internal/agenthost/session_linux_test.go index 19ca04298..f705fcdc9 100644 --- a/apps/daemon/internal/agenthost/session_linux_test.go +++ b/apps/daemon/internal/agenthost/session_linux_test.go @@ -145,7 +145,7 @@ func TestSpawnKeepsItsErrors(t *testing.T) { s, _ := newOwnerSession(t) s.plan = &plan{view: agent.View{LocalExec: []string{"/bin/true"}}} s.live = &liveView{view: &fakeView{exit: make(chan struct{}), spawnErr: c.err}} - _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/true", Dir: "/", OwnProcessGroup: true}) + _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/true", Dir: "/"}) if !errors.Is(err, c.err) || errors.Is(err, agent.ErrNoLiveView) != c.ended { t.Errorf("Spawn failing with %v = %v; want that error, and ErrNoLiveView only for an ended view", c.err, err) } diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index d08db8c6b..5412d2a6e 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -159,10 +159,10 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { } // The Turn waited for its Harness, so no view runs. s := d.session(b) - if _, err := s.spawn(clirunner.StartOptions{Binary: harnessPath, Dir: workspace, OwnProcessGroup: true}); !errors.Is(err, agent.ErrNoLiveView) { + if _, err := s.spawn(clirunner.StartOptions{Binary: harnessPath, Dir: workspace}); !errors.Is(err, agent.ErrNoLiveView) { t.Errorf("Spawn after the Harness exited = %v, want ErrNoLiveView", err) } - if _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/sh", Dir: workspace, OwnProcessGroup: true}); !errors.Is(err, agent.ErrNotLocalExec) { + if _, err := s.spawn(clirunner.StartOptions{Binary: "/bin/sh", Dir: workspace}); !errors.Is(err, agent.ErrNotLocalExec) { t.Errorf("Spawn of a binary outside LocalExec = %v, want ErrNotLocalExec", err) } select { @@ -266,7 +266,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { until(t, "the Harness to run", beating) // A Subagent runs in the live view. p, err := d.session(waiting).spawn(clirunner.StartOptions{Binary: harnessPath, Args: []string{"touch"}, Dir: workspace, - Env: []string{harnessEnv + "=1"}, OwnProcessGroup: true}) + Env: []string{harnessEnv + "=1"}}) if err != nil { t.Fatalf("Spawn in the live view: %v", err) } @@ -667,7 +667,7 @@ type testExecutor struct { func (e *testExecutor) StartTurn(_ context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { mode := *input[0].Content[0].Text p, err := e.session.Launch(clirunner.StartOptions{Binary: harnessPath, Args: []string{mode}, Dir: e.dir, Env: e.env, - OwnProcessGroup: true, KillTimeout: time.Second}) + KillTimeout: time.Second}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index a6466e6f2..f7e4b915d 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -144,7 +144,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, } - configuration := proto.PromptRequestPayload{AgentKind: kind, StrictResume: true, DisableSubagents: true, + configuration := proto.PromptRequestPayload{AgentKind: kind, DisableSubagents: true, Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}} if caps.FunctionTools.IsSupported() { diff --git a/apps/daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go index 0bef9bbf6..ec636f67e 100644 --- a/apps/daemon/internal/auth/store.go +++ b/apps/daemon/internal/auth/store.go @@ -1,6 +1,7 @@ -// Package auth reads the operator device profile that oac-core-device prints -// into ~/.oac/daemon//auth.json: the server URL, the device ID and -// its runner credential. +// Package auth reads the daemon credential profile written by +// oac-core-device: server URL, runtime row id (= device_id), and the +// long-lived runner_credential. Stored as JSON per-profile at +// ~/.oac/daemon//auth.json (0o600). package auth import ( @@ -12,15 +13,15 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) -// Profile is the on-disk representation of one device profile. +// Profile is the on-disk representation of one daemon credential. type Profile struct { // ServerURL is the absolute base URL the daemon dials (no // trailing slash). The daemon joins this with paths like // /agent-daemon/bootstrap. ServerURL string `json:"server_url"` - // RuntimeID is the device ID. The gateway uses it verbatim as - // device_id on WS upgrade. + // RuntimeID is the runtimes row id. The gateway uses it verbatim + // as device_id on WS upgrade. RuntimeID string `json:"runtime_id"` // RunnerCredential is the bearer presented on every @@ -32,11 +33,11 @@ type Profile struct { DeviceName string `json:"device_name,omitempty"` } -// ErrNoProfile is returned by Load when no auth.json exists for the +// ErrNotPaired is returned by Load when no auth.json exists for the // requested profile. -var ErrNoProfile = errors.New("auth: no device profile — provision one with oac-core-device") +var ErrNotPaired = errors.New("auth: no daemon credential profile — create one with oac-core-device") -// Load reads the profile's auth.json. Returns ErrNoProfile wrapping +// Load reads the profile's auth.json. Returns ErrNotPaired wrapping // fs.ErrNotExist when the file is missing. func Load(profile string) (Profile, error) { authPath, err := paths.AuthFile(profile) @@ -46,9 +47,9 @@ func Load(profile string) (Profile, error) { raw, err := os.ReadFile(authPath) if err != nil { if errors.Is(err, os.ErrNotExist) { - // Multi-%w so errors.Is matches both ErrNoProfile AND + // Multi-%w so errors.Is matches both ErrNotPaired AND // fs.ErrNotExist. - return Profile{}, fmt.Errorf("%w (looked at %s): %w", ErrNoProfile, authPath, err) + return Profile{}, fmt.Errorf("%w (looked at %s): %w", ErrNotPaired, authPath, err) } return Profile{}, fmt.Errorf("auth: read: %w", err) } diff --git a/apps/daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go index bc764daff..1d3bfffcc 100644 --- a/apps/daemon/internal/auth/store_test.go +++ b/apps/daemon/internal/auth/store_test.go @@ -1,6 +1,7 @@ package auth_test import ( + "encoding/json" "errors" "io/fs" "os" @@ -9,57 +10,117 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) -func writeProfile(t *testing.T, raw string) { +func withTempHome(t *testing.T) string { t.Helper() - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - path, err := paths.AuthFile("default") + dir := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", dir) + return dir +} + +func profileDir(t *testing.T, profile string) string { + t.Helper() + dir, err := paths.ProfileDir(profile) + if err != nil { + t.Fatalf("ProfileDir: %v", err) + } + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + t.Fatalf("EnsurePrivateDir: %v", err) + } + return dir +} + +func writeProfile(t *testing.T, profile string, p auth.Profile) { + t.Helper() + dir := profileDir(t, profile) + raw, err := json.Marshal(p) if err != nil { - t.Fatal(err) + t.Fatalf("marshal profile: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.json"), raw, 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } +} + +func TestLoadReadsProfile(t *testing.T) { + _ = withTempHome(t) + want := auth.Profile{ + ServerURL: "https://core.example.com", + RuntimeID: "rt_abc123", + RunnerCredential: "secret-credential", + DeviceName: "alice-mac", } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) + writeProfile(t, "test", want) + got, err := auth.Load("test") + if err != nil { + t.Fatalf("Load: %v", err) } - if raw != "" { - if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { - t.Fatal(err) - } + if got != want { + t.Fatalf("Load mismatch:\n got=%+v\nwant=%+v", got, want) } } -func TestLoadReadsTheDeviceProfile(t *testing.T) { - writeProfile(t, `{"server_url":"https://core.example.com/api/v1","runtime_id":"rt_abc123","runner_credential":"secret-credential","device_name":"engine host"}`) - got, err := auth.Load("default") - want := auth.Profile{ServerURL: "https://core.example.com/api/v1", RuntimeID: "rt_abc123", RunnerCredential: "secret-credential", DeviceName: "engine host"} - if err != nil || got != want { - t.Fatalf("Load = %+v, %v; want %+v", got, err, want) +func TestLoadIgnoresLegacyProfileFields(t *testing.T) { + _ = withTempHome(t) + raw := `{"server_url":"https://core.example.com/api/v1","runtime_id":"rt","runner_credential":"c","device_name":"d",` + + `"hostname":"h","paired_at":"2026-06-04T12:00:00Z","runner_public_key":"pub","runner_private_key":"priv"}` + if err := os.WriteFile(filepath.Join(profileDir(t, "legacy"), "auth.json"), []byte(raw), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } + got, err := auth.Load("legacy") + if err != nil { + t.Fatalf("Load: %v", err) + } + want := auth.Profile{ServerURL: "https://core.example.com/api/v1", RuntimeID: "rt", RunnerCredential: "c", DeviceName: "d"} + if got != want { + t.Fatalf("Load = %+v, want %+v", got, want) } } -func TestLoadMissingReturnsErrNoProfile(t *testing.T) { - writeProfile(t, "") +func TestLoadMissingReturnsErrNotPaired(t *testing.T) { + _ = withTempHome(t) _, err := auth.Load("default") - if !errors.Is(err, auth.ErrNoProfile) || !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("Load on missing profile returned %v, want ErrNoProfile wrapping fs.ErrNotExist", err) + if !errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load on missing profile returned %v, want ErrNotPaired", err) + } + // ErrNotPaired must also wrap fs.ErrNotExist for the canonical + // "missing file" check. + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("ErrNotPaired must wrap fs.ErrNotExist, got %v", err) } } func TestLoadCorruptJSONReturnsError(t *testing.T) { - writeProfile(t, "{not valid json") - if _, err := auth.Load("default"); err == nil || errors.Is(err, auth.ErrNoProfile) { - t.Fatalf("Load on corrupt JSON = %v, want a parse error", err) + _ = withTempHome(t) + dir := profileDir(t, "default") + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte("{not valid json"), 0o600); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + _, err := auth.Load("default") + if err == nil { + t.Fatal("Load returned nil error on corrupt JSON") + } + if errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load on corrupt JSON should not be ErrNotPaired: %v", err) } } func TestDeleteIsIdempotent(t *testing.T) { - writeProfile(t, `{"server_url":"https://x","runtime_id":"rt","runner_credential":"c"}`) - for range 2 { - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete: %v", err) - } - } - if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNoProfile) { - t.Fatalf("Load after Delete = %v, want ErrNoProfile", err) + _ = withTempHome(t) + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err) + } + writeProfile(t, "default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}) + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNotPaired) { + t.Fatalf("Load after Delete = %v, want ErrNotPaired", err) + } + // Second Delete must still succeed. + if err := auth.Delete("default"); err != nil { + t.Fatalf("Delete second call = %v, want nil", err) } } diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go index 445688eee..6dc66c293 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -86,14 +86,14 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { }() ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) defer cancel() - id := uuid.NewString() - request := proto.PromptRequestPayload{RunID: id, AgentKind: "claude_sdk", Input: proto.TextInput(prompt), AgentStateKey: "registered-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3"} + id, prepare := uuid.NewString(), uuid.NewString() + request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: "agents-api-registered-acceptance", AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3"} if callFunction { request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} } - handle := func(kind string, payload any) { + send := func(kind, envelopeID string, payload any) { t.Helper() - env, err := proto.NewEnvelope(kind, id, payload) + env, err := proto.NewEnvelope(kind, envelopeID, payload) if err != nil { t.Fatal(err) } @@ -101,7 +101,21 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { t.Fatal("registered router request failed", err) } } - handle(proto.TypePromptRequest, request) + handle := func(kind string, payload any) { send(kind, id, payload) } + send(proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: "registered-acceptance", Configuration: request}) + var ready proto.PreparationStatusPayload + for ready.State != "ready" { + var event proto.Envelope + select { + case event = <-sender: + case <-ctx.Done(): + t.Fatal("registered preparation timed out", ctx.Err()) + } + if event.Type != proto.TypePreparationStatus || event.ID != prepare || event.DecodePayload(&ready) != nil || ready.State != "preparing" && ready.State != "ready" { + t.Fatalf("registered preparation failed: %s", event.Payload) + } + } + send(proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: id, Input: proto.TextInput(prompt)}) proof := execution{} defer func() { data, _ := json.MarshalIndent(proof, "", " ") @@ -117,6 +131,13 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { case <-ctx.Done(): t.Fatal("registered execution timed out", ctx.Err()) } + if event.Type == proto.TypePreparationStatus && event.ID == prepare { + var status proto.PreparationStatusPayload + if event.DecodePayload(&status) != nil || status.RunID != id || status.State != "starting" && status.State != "started" { + t.Fatalf("registered start failed: %s", event.Payload) + } + continue + } if event.ID != id { t.Fatal("event identity changed") } diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index f8718358d..a0d6abed1 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -30,9 +30,12 @@ const ( // runConnect dials /agent-daemon/bootstrap, opens /agent-daemon/ws, // wires the dispatch router, and routes Envelope traffic both ways -// until either SIGINT/SIGTERM or a permanent credential rejection. It -// authenticates with a Runtime bootstrap file, self-hosted Environment -// enrollment or the profile's operator device profile. +// until either SIGINT/SIGTERM or a permanent credential rejection. +// +// The daemon credential comes from a Provider bootstrap file +// (--bootstrap-file), self-hosted Environment enrollment +// (--remote/--environment-id/--credential-file) or the saved profile +// written by oac-core-device. // // -b re-execs the binary in the background with stdio redirected to // connect.log and the child PID written to connect.pid. The child @@ -40,7 +43,7 @@ const ( func runConnect(ctx *runContext, args []string) error { fs := newFlagSet("connect") var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name for the device profile and pid/log files") + profile = fs.String("profile", paths.DefaultProfile, "profile name for daemon credentials and pid/log files") background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") @@ -74,7 +77,7 @@ func runConnect(ctx *runContext, args []string) error { } if *remote != "" || *environment != "" || *credentialFile != "" { if fs.NArg() != 0 { - return errors.New("connect: Environment enrollment takes no positional arguments") + return errors.New("connect: Environment enrollment cannot use positional arguments") } connectCtx, stop := daemonize.NotifyContext(context.Background()) defer stop() @@ -92,11 +95,11 @@ func runConnect(ctx *runContext, args []string) error { return fmt.Errorf("connect: %w", err) } } - return spawnBackground(context.Background(), ctx, *profile) + return spawnBackground(context.Background(), ctx, *profile, os.Args) } // Self-check before loading credentials so a machine with no - // supported agent CLI fails before it connects. + // supported agent CLI fails fast. agentCLIs, err := preflightAgentCLIs(context.Background(), ctx, *profile) if err != nil { return err @@ -105,8 +108,11 @@ func runConnect(ctx *runContext, args []string) error { var prof auth.Profile if bootstrapped != nil { prof = *bootstrapped - } else if prof, err = auth.Load(*profile); err != nil { - return fmt.Errorf("connect: %w", err) + } else { + prof, err = auth.Load(*profile) + if err != nil { + return fmt.Errorf("connect: %w", err) + } } return mainLoop(ctx, *profile, prof, agentCLIs) @@ -116,7 +122,7 @@ func runConnect(ctx *runContext, args []string) error { // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either // mode. -func spawnBackground(ctx context.Context, rc *runContext, profile string) error { +func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string) error { logPath, err := paths.LogFile(profile) if err != nil { return fmt.Errorf("connect: %w", err) @@ -155,7 +161,10 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string) error if err := ctx.Err(); err != nil { return err } - pid, err := daemonize.Spawn(os.Args, daemonize.ReExecOptions{LogPath: logPath, PIDPath: pidPath}) + pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + }) if err != nil { return fmt.Errorf("connect: spawn background: %w", err) } @@ -273,7 +282,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof return nil } if errors.Is(err, transport.ErrPermanent) { - return fmt.Errorf("connect: permanent error (re-pair the daemon): %w", err) + return fmt.Errorf("connect: permanent error (reissue the daemon credential): %w", err) } return fmt.Errorf("connect: dial: %w", err) } @@ -298,7 +307,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof // Permanent error (e.g. runtime deleted) → exit instead of // reconnecting. if pumpErr != nil && errors.Is(pumpErr, transport.ErrPermanent) { - return fmt.Errorf("connect: runtime deleted (re-pair the daemon): %w", pumpErr) + return fmt.Errorf("connect: runtime deleted (reissue the daemon credential): %w", pumpErr) } // Small breather before redialing so a flapping server doesn't // get a tight loop of upgrade requests. diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go index 4092479dc..1e7848808 100644 --- a/apps/daemon/internal/cli/connect_bootstrap.go +++ b/apps/daemon/internal/cli/connect_bootstrap.go @@ -8,7 +8,7 @@ import ( ) // The launch file is the sole credential source for this connection. Reopening -// it on process restart neither pairs again nor overwrites an auth profile. +// it on process restart never reads or overwrites an auth profile. func bootstrapProfile(path string) (*auth.Profile, error) { raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes) if err != nil { diff --git a/apps/daemon/internal/cli/connect_bootstrap_test.go b/apps/daemon/internal/cli/connect_bootstrap_test.go index be7cfad32..7f510d699 100644 --- a/apps/daemon/internal/cli/connect_bootstrap_test.go +++ b/apps/daemon/internal/cli/connect_bootstrap_test.go @@ -1,28 +1,34 @@ package cli import ( - "bytes" + "encoding/json" "io" "os" "path/filepath" "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - profile, err := paths.AuthFile("default") + prior := auth.Profile{ServerURL: "https://other.example/api/v1", RuntimeID: "retained", RunnerCredential: "retained-secret"} + profileDir, err := paths.ProfileDir("default") if err != nil { t.Fatal(err) } - prior := []byte(`{"server_url":"https://other.example/api/v1","runtime_id":"retained","runner_credential":"retained-secret"}`) - if err = os.MkdirAll(filepath.Dir(profile), 0o700); err != nil { + if err = runtimefs.EnsurePrivateDir(profileDir); err != nil { t.Fatal(err) } - if err = os.WriteFile(profile, prior, 0o600); err != nil { + priorRaw, err := json.Marshal(prior) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(profileDir, "auth.json"), priorRaw, 0600); err != nil { t.Fatal(err) } input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret"} @@ -40,8 +46,8 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { t.Fatal("failed bootstrap/restart", err) } } - got, err := os.ReadFile(profile) - if err != nil || !bytes.Equal(got, prior) { + got, err := auth.Load("default") + if err != nil || got != prior { t.Fatal("private profile modified", err) } if err = os.WriteFile(path, []byte("bootstrap-secret"), 0600); err != nil { diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index e8ef2d21e..c8f92a00e 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -148,7 +148,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { } defer peer.Close() env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", - Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", StrictResume: true, DisableExecutionEnvironment: true}}) + Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", DisableExecutionEnvironment: true}}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go index 6eea718d7..0b1c3b4b1 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" @@ -160,7 +161,7 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(parent, rc, profile) + return spawnBackground(parent, rc, profile, os.Args) } // Discovery consumes the immutable Runtime binding; it must follow enrollment. discovery, err := preflightAgentCLIs(parent, rc, profile) diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 28999d0be..cd77a7d2b 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -41,7 +41,7 @@ var commands = []command{ {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Open the reverse WebSocket and start serving prompts", run: runConnect}, - {name: "status", summary: "Print the device profile and daemon state", run: runStatus}, + {name: "status", summary: "Print the credential profile and daemon state", run: runStatus}, {name: "stop", summary: "Stop a background `connect -b` daemon", run: runStop}, {name: "logs", summary: "Tail the background daemon's log file", run: runLogs}, {name: "logout", summary: "Forget the credential for a profile", run: runLogout}, diff --git a/apps/daemon/internal/cli/status.go b/apps/daemon/internal/cli/status.go index 319f62f5b..beb9e444d 100644 --- a/apps/daemon/internal/cli/status.go +++ b/apps/daemon/internal/cli/status.go @@ -31,12 +31,12 @@ func runStatus(ctx *runContext, args []string) error { prof, err := auth.Load(*profile) switch { - case errors.Is(err, auth.ErrNoProfile): - fmt.Fprintln(ctx.stdout, "device : no device profile; check Host connection in Core for a self-hosted Runtime") + case errors.Is(err, auth.ErrNotPaired): + fmt.Fprintln(ctx.stdout, "paired : no saved credential profile; check Host connection in Core for a self-hosted Runtime") case err != nil: - fmt.Fprintf(ctx.stdout, "device : ERROR — %v\n", err) + fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) default: - fmt.Fprintln(ctx.stdout, "device : profile present") + fmt.Fprintln(ctx.stdout, "paired : yes") fmt.Fprintf(ctx.stdout, "server_url : %s\n", prof.ServerURL) fmt.Fprintf(ctx.stdout, "runtime_id : %s\n", prof.RuntimeID) if prof.DeviceName != "" { @@ -46,7 +46,7 @@ func runStatus(ctx *runContext, args []string) error { // connect.pid existence is the cheap signal; the full liveness // check (kill -0) would be more accurate but a bare existence - // check is honest enough for the "provisioned but not connected" + // check is honest enough for the "paired but not connected" // diagnosis. pidPath, err := paths.PIDFile(*profile) if err != nil { diff --git a/apps/daemon/internal/daemonize/fork_test.go b/apps/daemon/internal/daemonize/fork_test.go index 7693a4918..a8f4fc5a6 100644 --- a/apps/daemon/internal/daemonize/fork_test.go +++ b/apps/daemon/internal/daemonize/fork_test.go @@ -12,12 +12,18 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { dir := privateTempDir(t) logPath := filepath.Join(dir, "child.log") pidPath := filepath.Join(dir, "child.pid") + t.Setenv(spawnTestChildEnv, "1") // argv[0] is ignored (Spawn uses os.Executable()); argv[1:] // becomes child args. Placeholder subcommand so flag parsing // wouldn't choke — runSpawnTestChild short-circuits anyway. - t.Setenv(spawnTestChildEnv, "1") - pid, err := Spawn([]string{"oac-daemon", "child-mode"}, ReExecOptions{LogPath: logPath, PIDPath: pidPath}) + pid, err := Spawn( + []string{"oac-daemon", "child-mode"}, + ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + }, + ) if err != nil { t.Fatalf("Spawn: %v", err) } diff --git a/apps/daemon/internal/dispatch/cancellation.go b/apps/daemon/internal/dispatch/cancellation.go index 4e65412d9..c21ff4b3e 100644 --- a/apps/daemon/internal/dispatch/cancellation.go +++ b/apps/daemon/internal/dispatch/cancellation.go @@ -2,22 +2,10 @@ package dispatch import ( "context" - "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (r *Router) releaseCompletedSession(state *sessionState) error { - r.mu.Lock() - state.retain = false - r.mu.Unlock() - receiptErr := r.finishSteering(state) - err := state.session.Cancel(context.Background()) - state.ctxCancel() - return errors.Join(receiptErr, err) -} - func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) error { var request proto.PromptCancelPayload if err := env.DecodeRequest(&request); err != nil { @@ -29,39 +17,18 @@ func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) err return ErrRouterClosed } state := r.sessions[env.ID] - if state != nil { - state.retain = false - } - var owner agent.Session - if state != nil && state.preparedHandoff != nil { - handoff := state.preparedHandoff - release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) - if request.DeliveryID != "" { - r.shutdownWG.Add(1) - go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) - } + if state == nil { r.mu.Unlock() - return nil + return r.sendCancellationAck(ctx, env, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"}) } - if state != nil && state.session != nil { - owner = state.session + handoff := state.preparedHandoff + release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) + if request.DeliveryID != "" { + r.shutdownWG.Add(1) + go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) } r.mu.Unlock() - ack := proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"} - if state != nil && owner == nil { - ack.ErrorCode = "not_ready" - } else if owner != nil { - if err := owner.Cancel(ctx); err != nil { - r.log.WarnContext(ctx, "session.Cancel failed", "run_id", env.ID, "err", err) - ack.ErrorCode = "cancel_failed" - } else { - ack.Applied, ack.ErrorCode = true, "" - outcome := owner.CancellationOutcome() - ack.Outcome = &outcome - } - state.ctxCancel() - } - return r.sendCancellationAck(ctx, env, ack) + return nil } func (r *Router) sendCancellationAck(ctx context.Context, env proto.Envelope, ack proto.InteractionDecisionAckPayload) error { diff --git a/apps/daemon/internal/dispatch/cancellation_test.go b/apps/daemon/internal/dispatch/cancellation_test.go index f4273d5ef..d43722800 100644 --- a/apps/daemon/internal/dispatch/cancellation_test.go +++ b/apps/daemon/internal/dispatch/cancellation_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -25,40 +23,41 @@ func (s *cancelReceiptSession) CancellationOutcome() proto.DonePayload { return s.outcome } +func (s *cancelReceiptSession) Cancel(ctx context.Context) error { + if s.entered != nil { + close(s.entered) + <-s.release + s.entered = nil + } + _ = s.fakeSession.Cancel(ctx) + return s.err +} + +func registerCancelReceiptKind(h *harness, sess *cancelReceiptSession) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} + return sess, nil + })) +} + func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} - return sess, nil - }) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "release", proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + registerCancelReceiptKind(h, sess) + startRun(t, h.router, h.sender, "release", proto.PromptRequestPayload{AgentKind: "codex"}) sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) <-sess.entered - if len(h.sender.snapshot()) != 0 { + if len(h.sender.typesFor("release")) != 0 { t.Fatal("completion acknowledged before native writer was released") } close(sess.release) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "release completion") - frames := h.sender.snapshot() - if len(frames) != 1 || frames[0].Type != proto.TypeDone || sess.cancels() != 1 { + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "release") }, "release completion") + if frames := h.sender.typesFor("release"); len(frames) != 1 || frames[0] != proto.TypeDone || sess.cancels() != 1 { t.Fatal("completion or native release missing") } } -func (s *cancelReceiptSession) Cancel(ctx context.Context) error { - if s.entered != nil { - close(s.entered) - <-s.release - s.entered = nil - } - _ = s.fakeSession.Cancel(ctx) - return s.err -} - func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { observed := proto.DonePayload{Content: "partial output", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} for _, test := range []struct { @@ -76,47 +75,31 @@ func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{}), outcome: test.outcome, err: test.err} - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} - return sess, nil - }) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + registerCancelReceiptKind(h, sess) + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "codex"}) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})); err != nil { t.Fatal(err) } - done := make(chan error, 1) - go func() { - done <- h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})) - }() <-sess.entered - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - t.Fatal("cancellation acknowledged before adapter returned") - } + if len(cancellationAcks(h.sender)) != 0 { + t.Fatal("cancellation acknowledged before adapter returned") } close(sess.release) - if err := <-done; err != nil { - t.Fatal(err) + waitFor(t, func() bool { return len(cancellationAcks(h.sender)) != 0 }, "cancellation receipt") + acks := cancellationAcks(h.sender) + if len(acks) != 1 { + t.Fatalf("cancellation receipts: %+v", acks) } - found := false - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - found = true - var ack proto.InteractionDecisionAckPayload - _ = env.DecodePayload(&ack) - if ack.Applied != (test.err == nil) || ack.DeliveryID != "cancel-1" { - t.Fatalf("wrong receipt: %+v", ack) - } - if test.err == nil { - if ack.ErrorCode != "" || ack.Outcome == nil || !reflect.DeepEqual(*ack.Outcome, test.outcome) { - t.Fatalf("cancellation receipt changed observed evidence: %+v", ack) - } - } else if ack.ErrorCode != "cancel_failed" || ack.Outcome != nil { - t.Fatalf("failed cancellation supplied a success outcome: %+v", ack) - } - } + ack := acks[0] + if ack.Applied != (test.err == nil) || ack.DeliveryID != "cancel-1" { + t.Fatalf("wrong receipt: %+v", ack) } - if !found { - t.Fatal("missing cancellation receipt") + if test.err == nil { + if ack.ErrorCode != "" || ack.Outcome == nil || !reflect.DeepEqual(*ack.Outcome, test.outcome) { + t.Fatalf("cancellation receipt changed observed evidence: %+v", ack) + } + } else if ack.ErrorCode != "cancel_failed" || ack.Outcome != nil { + t.Fatalf("failed cancellation supplied a success outcome: %+v", ack) } }) } @@ -125,14 +108,13 @@ func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "legacy", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess sess.closeOutOnCancel = true if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "legacy", proto.PromptCancelPayload{})); err != nil { t.Fatal(err) } + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "legacy") }, "cancellation settlement") for _, env := range h.sender.snapshot() { if env.Type == proto.TypeInteractionDecisionAck { t.Fatal("legacy cancellation emitted new receipt") diff --git a/apps/daemon/internal/dispatch/capability_admission_test.go b/apps/daemon/internal/dispatch/capability_admission_test.go index 0a9fde120..744d60ff6 100644 --- a/apps/daemon/internal/dispatch/capability_admission_test.go +++ b/apps/daemon/internal/dispatch/capability_admission_test.go @@ -9,7 +9,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementation(t *testing.T) { @@ -18,20 +17,18 @@ func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementatio h := newHarness(t) defer h.router.Shutdown(context.Background()) var calls atomic.Int32 - factory := func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + factory := sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { calls.Add(1) return fmt.Errorf("%w: fixture has no active input", agent.ErrUnsupportedOperation) }}, nil - } - info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilityFromBool(supported)})} - h.reg.RegisterKind(info, harnessconfig.Configuration{}, factory) - if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "fixture"})); err != nil { - t.Fatal(err) - } + }) + info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilityFromBool(supported)})} + registerExecutorKind(h.reg, info, factory) + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "fixture"}) // A new registration cannot rewrite the already admitted owner's contract. info.Capabilities.Steering = proto.CapabilityFromBool(!supported) - h.reg.RegisterKind(info, harnessconfig.Configuration{}, factory) + registerExecutorKind(h.reg, info, factory) for range 2 { if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("hello")})); err != nil { t.Fatal(err) @@ -63,14 +60,12 @@ func TestInteractionDeclarationPrecedesResponderMethods(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) var session *fakeSession - info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Permissions: proto.CapabilityFromBool(supported)})} - h.reg.RegisterKind(info, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + info := proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Permissions: proto.CapabilityFromBool(supported)})} + registerExecutorKind(h.reg, info, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true, submitErr: agent.ErrUnsupportedOperation, askErr: agent.ErrUnsupportedOperation} return session, nil - }) - if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "fixture"})); err != nil { - t.Fatal(err) - } + })) + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "fixture"}) event := mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "interaction", Tool: "fixture"}) decision := mustEnv(t, proto.TypePermissionDecision, "interaction", proto.PermissionDecisionPayload{DeliveryID: "decision", Approved: true}) if ask { @@ -78,7 +73,7 @@ func TestInteractionDeclarationPrecedesResponderMethods(t *testing.T) { decision = mustEnv(t, proto.TypePromptForUserChoiceDecision, "interaction", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "decision"}) } session.out <- event - waitFor(t, func() bool { return len(h.sender.snapshot()) > 0 }, "interaction indexed") + waitFor(t, func() bool { return hasFrame(h.sender, event.Type, "run") }, "interaction indexed") if err := h.router.Handle(t.Context(), decision); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index 3b0cb4b91..3f8991935 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -7,13 +7,16 @@ import ( ) func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if (req.LocalEnvironment != nil) == req.DisableExecutionEnvironment { + return errors.New("execution requires exactly one of local_environment and disable_execution_environment") + } if err := req.ValidateProgrammaticToolCallingDisable(caps.ProgrammaticToolCallingDisable.IsSupported()); err != nil { return err } if err := req.ValidateToolSearch(caps.ToolSearch.IsSupported()); err != nil { return err } - if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || !caps.LocalEnvironment.IsSupported()) { + if req.LocalEnvironment != nil && !caps.LocalEnvironment.IsSupported() { return errors.New("engine does not support this local Environment configuration") } if req.DisableExecutionEnvironment && !caps.EnvironmentNone.IsSupported() { diff --git a/apps/daemon/internal/dispatch/environment_test.go b/apps/daemon/internal/dispatch/environment_test.go index 0e72f9020..c021ec3cb 100644 --- a/apps/daemon/internal/dispatch/environment_test.go +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -1,10 +1,9 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" + "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -12,21 +11,36 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) +// assertPreparationOutcome waits for the terminal admission status of id. An +// admitted request fails in the controlled factory; a rejected one sends only +// its rejection. +func assertPreparationOutcome(t *testing.T, sender *recSender, id string, admitted bool) proto.PreparationStatusPayload { + t.Helper() + state, frames := "rejected", 1 + if admitted { + state, frames = "failed", 2 + } + status := waitPreparationStatus(t, sender, id, state, "") + if got := sender.typesFor(id); len(got) != frames { + t.Fatalf("preparation %s frames = %v, want %d status frames", id, got, frames) + } + return status +} + func TestNoEnvironmentRejectsOtherEngineBeforeFactory(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("controlled factory stop") }) - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "fake_alpha", DisableExecutionEnvironment: true})) - if err == nil || called { - t.Fatal("unsupported engine was started", err) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeExecutionPrepare, "none", noEnvironmentPreparation("none", proto.PromptRequestPayload{AgentKind: "fake_alpha"}))) + if err == nil { + t.Fatal("unsupported engine was admitted") } - frames := h.sender.snapshot() - if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal(frames) + if status := assertPreparationOutcome(t, h.sender, "none", false); status.ErrorCode != "unsupported_configuration" || called.Load() { + t.Fatalf("unsupported engine was started: status=%+v called=%t", status, called.Load()) } } @@ -34,14 +48,15 @@ func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { for _, available := range []bool{false, true} { h := newHarness(t) defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) return nil, errors.New("controlled factory stop") }) - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "sdk", proto.PromptRequestPayload{AgentKind: "claude_sdk", DisableExecutionEnvironment: true})) - if called != available { - t.Fatalf("factory called=%t, available=%t", called, available) + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "sdk", noEnvironmentPreparation("sdk", proto.PromptRequestPayload{AgentKind: "claude_sdk"}))) + assertPreparationOutcome(t, h.sender, "sdk", available) + if called.Load() != available { + t.Fatalf("factory called=%t, available=%t", called.Load(), available) } } } @@ -51,26 +66,26 @@ func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { t.Run(mode, func(t *testing.T) { h := localPreparationHarness(t) defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(mode != "unsupported")})}, - harnessconfig.Configuration{}, func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - called = true + func(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) if req.LocalEnvironment == nil || req.LocalEnvironment.ID != preparationEnvironmentID { t.Error("local descriptor lost before factory") } return nil, errors.New("controlled factory stop") }) - req := preparationRequest().Configuration - req.AgentKind = "codex" - req.DisableExecutionEnvironment = mode == "none conflict" - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "local", req)) - if called != (mode == "supported") { - t.Fatalf("unexpected factory call for %s", mode) + req := preparationRequest() + req.Configuration.AgentKind = "codex" + req.Configuration.DisableExecutionEnvironment = mode == "none conflict" + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local", req)) + if (err == nil) != (mode == "supported") { + t.Fatalf("wrong admission for %s: %v", mode, err) } - frames := h.sender.snapshot() - if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal("missing terminal error frames") + assertPreparationOutcome(t, h.sender, "local", mode == "supported") + if called.Load() != (mode == "supported") { + t.Fatalf("unexpected factory call for %s", mode) } }) } diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 1cade41ee..1c7bff5e7 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -38,14 +38,13 @@ func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { req.RunID, req.Input = "", nil req.AgentSessionID = "" req.RequireExistingNativeSession = false - req.ReleaseOnCompletion = false data, err := json.Marshal(req) return sha256.Sum256(data), err } func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, input proto.ExecutionPreparePayload) error { req := input.Configuration - if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.AgentStateKey != "agents-api-"+input.SessionID || !req.StrictResume { + if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.AgentStateKey != "agents-api-"+input.SessionID { return r.rejectPreparation(env, "invalid_configuration") } caps, available := r.availableCapabilities(req.AgentKind) @@ -147,7 +146,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) } operation, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} state := "preparing" if reused { state = "ready" @@ -279,19 +278,22 @@ func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { owner.idleLease++ lease := owner.idleLease r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) - owner.timer = time.AfterFunc(r.idleTimeout, func() { - r.mu.Lock() - if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { - r.mu.Unlock() - return - } - owner.invalid = true - owner.closeReason = "idle_expired" - r.shutdownWG.Add(1) + owner.timer = time.AfterFunc(r.idleTimeout, func() { r.expireIdleExecutor(owner, lease) }) +} + +// expireIdleExecutor closes owner only while lease is its current idle lease. +func (r *Router) expireIdleExecutor(owner *executorState, lease uint64) { + r.mu.Lock() + if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { r.mu.Unlock() - defer r.shutdownWG.Done() - _ = r.closeExecutor(owner) - }) + return + } + owner.invalid = true + owner.closeReason = "idle_expired" + r.shutdownWG.Add(1) + r.mu.Unlock() + defer r.shutdownWG.Done() + _ = r.closeExecutor(owner) } // Close failures keep the exact owner; a later call retries only settled failures. diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index b0fc793fa..60f194351 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -150,7 +150,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { } } } - request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} + request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", DisableExecutionEnvironment: true}} admit := func(id string) proto.PreparationStatusPayload { t.Helper() handle(proto.TypeExecutionPrepare, id, request) diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index 7167ac02c..f7ba3cfef 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -69,17 +69,27 @@ func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlemen } } +// noEnvironmentPreparation prepares config for session without an execution environment. +func noEnvironmentPreparation(session string, config proto.PromptRequestPayload) proto.ExecutionPreparePayload { + config.AgentStateKey, config.DisableExecutionEnvironment = "agents-api-"+session, true + return proto.ExecutionPreparePayload{SessionID: session, Configuration: config} +} func executorRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "reusable", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} + return noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "reusable"}) +} + +// registerExecutorKind registers info for prepared execution only. +func registerExecutorKind(reg *agent.Registry, info proto.SupportedAgentKind, factory agent.ExecutorFactory) { + reg.RegisterKind(info, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("prepared execution must not use the direct Session factory") + }) + reg.RegisterExecutor(info.Kind, factory) } func executorRouter(t *testing.T, owner *reusableExecutor, idle time.Duration) (*dispatch.Router, *recSender, *atomic.Int32) { t.Helper() calls := &atomic.Int32{} reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary factory is forbidden") - }) - reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { calls.Add(1) return owner, nil }) @@ -294,6 +304,18 @@ func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { } } +func TestExecutorRejectsMissingEnvironmentBeforeFactory(t *testing.T) { + r, s, calls := executorRouter(t, &reusableExecutor{}, time.Minute) + req := executorRequest() + req.Configuration.DisableExecutionEnvironment = false + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", req)); err == nil { + t.Fatal("unsupported configuration accepted") + } + if status := waitPreparationStatus(t, s, "request", "rejected", ""); status.ErrorCode != "unsupported_configuration" || calls.Load() != 0 { + t.Fatalf("status=%+v factory calls=%d", status, calls.Load()) + } +} + func TestExecutorRejectsOutputFromAnotherTurn(t *testing.T) { e := &reusableExecutor{starts: make(chan *reusableTurn, 1)} r, s, _ := executorRouter(t, e, time.Minute) diff --git a/apps/daemon/internal/dispatch/export_test.go b/apps/daemon/internal/dispatch/export_test.go index 4c5e2a361..05d7011d7 100644 --- a/apps/daemon/internal/dispatch/export_test.go +++ b/apps/daemon/internal/dispatch/export_test.go @@ -36,3 +36,11 @@ func (r *Router) SteeringClosedForTest(runID string) bool { state := r.sessions[runID] return state != nil && state.steeringClosed } + +// RunStartedForTest reports whether runID's Turn accepts operations. +func (r *Router) RunStartedForTest(runID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + state := r.sessions[runID] + return state != nil && state.session != nil +} diff --git a/apps/daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go index 63fd1c462..47f8a9de6 100644 --- a/apps/daemon/internal/dispatch/functions_native_test.go +++ b/apps/daemon/internal/dispatch/functions_native_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "encoding/json" @@ -111,13 +109,8 @@ func TestNativeFunctionBridge(t *testing.T) { })) defer model.Close() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, codex.Factory) + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}, codex.NewExecutorFactory()) sender := make(nativeFunctionSender, 256) - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - if err != nil { - t.Fatal(err) - } - defer router.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) defer cancel() await := func(kind string) proto.Envelope { @@ -136,14 +129,48 @@ func TestNativeFunctionBridge(t *testing.T) { } } } + awaitReady := func(id string) proto.PreparationStatusPayload { + t.Helper() + for { + env := await(proto.TypePreparationStatus) + var status proto.PreparationStatusPayload + if env.ID != id { + continue + } + if err := env.DecodePayload(&status); err != nil { + t.Fatal(env, err) + } + if status.State == "ready" { + return status + } + if status.State != "preparing" { + t.Fatalf("preparation %s: %+v", id, status) + } + } + } nativeID := "" - for index := 0; index < 3; index++ { + // Each Run owns a fresh Router, so every resume starts a new native process. + run := func(index int) { + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := router.Shutdown(ctx); err != nil { + t.Error(err) + } + }() run := fmt.Sprintf("run-%d", index) - request := proto.PromptRequestPayload{AgentKind: "codex", Input: proto.TextInput("Look up ticket 42."), RunID: run, AgentStateKey: "native-functions", AgentSessionID: nativeID, StrictResume: true, ReleaseOnCompletion: true, DisableExecutionEnvironment: true, ObserveToolObservations: true, + request := noEnvironmentPreparation("native-functions", proto.PromptRequestPayload{AgentKind: "codex", AgentSessionID: nativeID, FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}, - Model: "gpt-5.5", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: model.URL + "/v1", APIKey: "synthetic-local-token"}} - env, _ := proto.NewEnvelope(proto.TypePromptRequest, run, request) - if err := router.Handle(ctx, env); err != nil { + Model: "gpt-5.5", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: model.URL + "/v1", APIKey: "synthetic-local-token"}}) + prepare, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, run, request) + if err := router.Handle(ctx, prepare); err != nil { + t.Fatal(err) + } + ready := awaitReady(run) + start, _ := proto.NewEnvelope(proto.TypeExecutionStart, run, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: run, Input: proto.TextInput("Look up ticket 42.")}) + if err := router.Handle(ctx, start); err != nil { t.Fatal(err) } call := await(proto.TypeFunctionCall) @@ -170,7 +197,7 @@ func TestNativeFunctionBridge(t *testing.T) { if receipt.Applied || receipt.ErrorCode != "not_pending" { t.Fatal(receipt) } - break + return } result, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: index == 0, Content: functionResultContent("TICKET-RESULT"), DeliveryID: "result"}) if err := router.Handle(ctx, result); err != nil { @@ -191,5 +218,8 @@ func TestNativeFunctionBridge(t *testing.T) { } nativeID = id } + for index := range 3 { + run(index) + } t.Logf("Native function success/failure, fresh-process resume, cancellation and late-result rejection passed; evidence %s", home) } diff --git a/apps/daemon/internal/dispatch/functions_test.go b/apps/daemon/internal/dispatch/functions_test.go index 59be84409..db0bc5d56 100644 --- a/apps/daemon/internal/dispatch/functions_test.go +++ b/apps/daemon/internal/dispatch/functions_test.go @@ -1,16 +1,17 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "bytes" "context" "encoding/base64" "encoding/json" + "errors" + "fmt" "image" "image/color" "image/png" "sync" + "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -38,21 +39,18 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { reg := agent.NewRegistry() sender := &recSender{} sessions := map[string]*functionSession{} - reg.RegisterKind(proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, p proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - s := &functionSession{fakeSession: &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}} - sessions[p.RunID] = s + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, runID string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + s := &functionSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}} + sessions[runID] = s return s, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) if err != nil { t.Fatal(err) } defer router.Shutdown(context.Background()) for _, id := range []string{"one", "two"} { - env, _ := proto.NewEnvelope(proto.TypePromptRequest, id, proto.PromptRequestPayload{AgentKind: "function-test", Input: proto.TextInput("lookup"), FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) - if err := router.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } + startRun(t, router, sender, id, proto.PromptRequestPayload{AgentKind: "function-test", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) } submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { t.Helper() @@ -137,17 +135,20 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { func TestFunctionToolsRequireAdvertisedSupport(t *testing.T) { reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: "unsupported", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil + var called atomic.Bool + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "unsupported", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("unexpected executor preparation") }) sender := &recSender{} router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) defer router.Shutdown(context.Background()) - env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) - if err := router.Handle(t.Context(), env); err == nil || called { - t.Fatal("unsupported engine silently ignored tools", err) + env, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, "run", noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}})) + if err := router.Handle(t.Context(), env); err == nil { + t.Fatal("unsupported engine silently ignored tools") + } + if status := waitPreparationStatus(t, sender, "run", "rejected", ""); status.ErrorCode != "unsupported_configuration" || called.Load() { + t.Fatalf("status=%+v executor called=%t", status, called.Load()) } } @@ -165,17 +166,22 @@ func functionResultContent(text string) []proto.InputContent { func TestDiscoveryCannotReachAnEagerOnlyAdapter(t *testing.T) { reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil + var called atomic.Bool + registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) + return nil, errors.New("unexpected executor preparation") }) - router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: &recSender{}}) + sender := &recSender{} + router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) defer router.Shutdown(context.Background()) for _, search := range []bool{false, true} { - env, _ := proto.NewEnvelope(proto.TypePromptRequest, "discovery", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}}) - if err := router.Handle(t.Context(), env); err == nil || called { - t.Fatal("deferred definitions reached an eager-only adapter", err) + id := fmt.Sprint("discovery-", search) + env, _ := proto.NewEnvelope(proto.TypeExecutionPrepare, id, noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}})) + if err := router.Handle(t.Context(), env); err == nil { + t.Fatal("deferred definitions reached an eager-only adapter") + } + if status := waitPreparationStatus(t, sender, id, "rejected", ""); status.ErrorCode != "unsupported_configuration" || called.Load() { + t.Fatalf("status=%+v executor called=%t", status, called.Load()) } } } diff --git a/apps/daemon/internal/dispatch/interaction_decisions.go b/apps/daemon/internal/dispatch/interaction_decisions.go index 3de5b1186..31c147ddb 100644 --- a/apps/daemon/internal/dispatch/interaction_decisions.go +++ b/apps/daemon/internal/dispatch/interaction_decisions.go @@ -101,7 +101,7 @@ func (r *Router) dropPermission(s *sessionState, permissionID string) { // those entries linger until cleanupSession — acceptable because they // can't double-fire (the session's own pendingAskTable.Take already // guards that); cleanupSession removes the routing entry when the run -// stream closes, even if the underlying CLI remains in the idle pool. +// stream closes. func (r *Router) handlePromptForUserChoiceDecision(ctx context.Context, env proto.Envelope) error { if env.ID == "" { return errors.New("dispatch: prompt_for_user_choice_decision missing ask id (Envelope.ID empty)") diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 467a3c13f..b10dee5d3 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -43,7 +43,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing t.Fatal(err) } t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) - request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, WorkspaceReadOnly: true} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil { t.Fatal(err) } @@ -123,7 +123,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = r.Shutdown(context.Background()) }) - request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} + request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, WorkspaceReadOnly: true} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/mcp_http_test.go b/apps/daemon/internal/dispatch/mcp_http_test.go index 45ae2fe80..794b596ec 100644 --- a/apps/daemon/internal/dispatch/mcp_http_test.go +++ b/apps/daemon/internal/dispatch/mcp_http_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "strings" + "sync/atomic" "testing" "time" @@ -16,33 +17,38 @@ import ( ) func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { - for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { + for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "no declaration", "required", "required old peer", "optional old peer"} { t.Run(mode, func(t *testing.T) { - h := newHarness(t) + // Service-origin servers need a service execution host, never a local Environment. + var h *harness + prepare := noEnvironmentPreparation("mcp-bearer", proto.PromptRequestPayload{AgentKind: "codex"}) + if strings.HasSuffix(mode, "local") { + h = localPreparationHarness(t) + prepare = preparationRequest() + prepare.Configuration.AgentKind = "codex" + } else { + h = newHarness(t) + } defer h.router.Shutdown(context.Background()) token := "synthetic-private-token" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} - req := proto.PromptRequestPayload{AgentKind: "codex", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} - caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported}) + req := &prepare.Configuration + req.MCPHTTPServers = &servers + caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported}) switch mode { case "claude", "claude old peer", "claude local": req.AgentKind = "claude_sdk" caps.MCPHTTPBearerAuth = proto.CapabilityFromBool(mode != "claude old peer") - if mode == "claude local" { - req.DisableExecutionEnvironment = false - } case "required", "required old peer", "optional old peer": servers[0].Required = mode != "optional old peer" servers[0].BearerToken = nil caps.MCPHTTPRequired = proto.CapabilityFromBool(mode == "required") - case "no bearer capability", "credential-free", "product": + case "no bearer capability", "credential-free", "no declaration": caps.MCPHTTPBearerAuth = proto.CapabilityUnsupported case "no MCP capability": caps.MCPHTTPTools = proto.CapabilityUnsupported case "no none capability": caps.EnvironmentNone = proto.CapabilityUnsupported - case "local": - req.DisableExecutionEnvironment = false case "other engine": req.AgentKind = "other" case "HTTP": @@ -51,13 +57,13 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { if mode == "credential-free" { servers[0].BearerToken = nil } - if mode == "product" { - req.MCPHTTPServers, req.DisableExecutionEnvironment = nil, false + if mode == "no declaration" { + req.MCPHTTPServers = nil } - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, - harnessconfig.Configuration{}, func(_ context.Context, got proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - called = true + var called atomic.Bool + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, + func(_ context.Context, got proto.PromptRequestPayload) (agent.Executor, error) { + called.Store(true) if mode == "required" && !(*got.MCPHTTPServers)[0].Required { t.Error("required initialization lost before adapter") } @@ -66,14 +72,15 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { } return nil, errors.New("controlled factory stop") }) - err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) - if called != (mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { - t.Fatal("wrong factory admission") + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "mcp-bearer", prepare)) + admitted := mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "no declaration" || mode == "required" || mode == "optional old peer" + assertPreparationOutcome(t, h.sender, "mcp-bearer", admitted) + if called.Load() != admitted || (err == nil) != admitted { + t.Fatal("wrong factory admission", err) } - frames := h.sender.snapshot() - raw, _ := json.Marshal(frames) - if err == nil || strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) || len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal("terminal rejection missing or exposed credential") + raw, _ := json.Marshal(h.sender.snapshot()) + if err != nil && strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) { + t.Fatal("terminal status exposed credential") } }) } diff --git a/apps/daemon/internal/dispatch/optional_interactions_test.go b/apps/daemon/internal/dispatch/optional_interactions_test.go index dc63be1eb..37d22eaae 100644 --- a/apps/daemon/internal/dispatch/optional_interactions_test.go +++ b/apps/daemon/internal/dispatch/optional_interactions_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "testing" @@ -20,14 +18,12 @@ func TestOptionalInteractionResponders(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) var output chan<- proto.Envelope - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "minimal", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "minimal", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { output = out s := &fakeSession{out: out, closeOutOnCancel: true} return lifecycleOnly{Session: s}, nil - }) - if err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "minimal"})); err != nil { - t.Fatal(err) - } + })) + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "minimal"}) event := mustEnv(t, proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "interaction", Tool: "fixture"}) decision := mustEnv(t, proto.TypePermissionDecision, "interaction", proto.PermissionDecisionPayload{DeliveryID: "decision", Approved: true}) if ask { @@ -37,7 +33,7 @@ func TestOptionalInteractionResponders(t *testing.T) { // An inconsistent adapter emitted an interaction it cannot answer: reject it, // never acknowledge application or call a fabricated responder. output <- event - waitFor(t, func() bool { return len(h.sender.snapshot()) > 0 }, "interaction indexed") + waitFor(t, func() bool { return hasFrame(h.sender, event.Type, "run") }, "interaction indexed") if err := h.router.Handle(t.Context(), decision); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/output.go b/apps/daemon/internal/dispatch/output.go deleted file mode 100644 index e0e96dca9..000000000 --- a/apps/daemon/internal/dispatch/output.go +++ /dev/null @@ -1,103 +0,0 @@ -package dispatch - -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -// pump forwards every Envelope the session writes onto out to the -// upstream sender, then cleans up when the session closes out. -func (r *Router) pump(s *sessionState) { - defer r.shutdownWG.Done() - defer r.cleanupSession(s) - _ = r.forwardSessionOutput(s, true) -} - -// wait is false only after a failed Start and its cancellation have returned: -// no Session owns the output sink, so only already queued frames can be read. -func (r *Router) forwardSessionOutput(s *sessionState, wait bool) error { - // Logging-only ctx carrying the run's trace; sends use their own - // ctx tied to shutdownCh. - pumpCtx := context.Background() - if s.traceparent != "" { - if carrier, err := obslog.ParseTraceparent(s.traceparent); err == nil { - pumpCtx = obslog.WithTrace(pumpCtx, carrier) - } - } - r.log.InfoContext(pumpCtx, "pump: started", "run_id", s.runID) - - // Long-lived send ctx — must keep forwarding even after the - // session's ctx is cancelled (session might emit a final "done" - // in response to cancel). Stops on out close or router shutdown. - for { - if !wait && len(s.out) == 0 { - return nil - } - select { - case env, ok := <-s.out: - if !ok { - r.log.InfoContext(pumpCtx, "pump: out channel closed", "run_id", s.runID) - return nil - } - if s.session != nil { - r.indexPermissionFrame(s, env) - } - if env.Type == proto.TypeDone && s.releaseOnCompletion { - if err := r.releaseCompletedSession(s); err != nil { - sendCtx, stop := r.shutdownContext(pumpCtx) - r.emitTerminalError(sendCtx, s.runID, "failed to release completed executor") - stop() - continue - } - } - r.log.InfoContext(pumpCtx, "pump: forwarding envelope", "run_id", s.runID, "type", env.Type, "env_id", env.ID) - // Stamp the run's trace onto outbound frames so the - // gateway attributes daemon-emitted lines to the same - // trace_id as the original prompt_request. - if env.Trace == "" && s.traceparent != "" { - env.Trace = s.traceparent - } - // Short-ish send ctx that respects router shutdown — if - // the transport is wedged we don't want to block forever. - sendCtx, cancel := context.WithCancel(context.Background()) - stopOnShutdown := make(chan struct{}) - go func() { - select { - case <-r.shutdownCh: - cancel() - case <-stopOnShutdown: - } - }() - err := r.sender.Send(sendCtx, env) - close(stopOnShutdown) - cancel() - if err != nil { - // Sender failed — log, ask the session to wind down, - // but KEEP draining out so the agent's goroutines - // don't block on a full channel. - r.log.ErrorContext(pumpCtx, "send envelope failed", "type", env.Type, "run_id", env.ID, "err", err) - r.mu.Lock() - s.retain = false - r.mu.Unlock() - s.ctxCancel() - if wait { - r.drain(s.out) - } - return err - } - case <-r.shutdownCh: - // Router shutdown — cancel + drain so the session's - // goroutines unblock and close out cleanly. - r.mu.Lock() - s.retain = false - r.mu.Unlock() - s.ctxCancel() - if wait { - r.drain(s.out) - } - return ErrRouterClosed - } - } -} diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index 9f72d48d1..db060552a 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -31,7 +31,6 @@ type preparationState struct { ctx context.Context cancel context.CancelFunc prepared io.Closer - stateKey string environmentID string busy bool owns bool @@ -63,7 +62,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if err != nil { return r.rejectPreparation(env, "invalid_configuration") } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" || !req.StrictResume || !req.ReleaseOnCompletion { + if req.RunID != "" || len(req.Input) != 0 || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" { return r.rejectPreparation(env, "invalid_configuration") } if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported()) { @@ -105,7 +104,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "preparation_capacity") } owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) diff --git a/apps/daemon/internal/dispatch/preparation_start.go b/apps/daemon/internal/dispatch/preparation_start.go index d1bd51d76..c330d46fa 100644 --- a/apps/daemon/internal/dispatch/preparation_start.go +++ b/apps/daemon/internal/dispatch/preparation_start.go @@ -70,7 +70,7 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err } p.status.State, p.status.RunID, p.status.Revision = "starting", input.RunID, p.status.Revision+1 p.startFingerprint, p.busy = fingerprint, true - state := &sessionState{capabilities: p.capabilities, runID: input.RunID, stateKey: p.stateKey, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), traceparent: env.Trace} + state := &sessionState{capabilities: p.capabilities, runID: input.RunID, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), traceparent: env.Trace} state.preparedHandoff = newPreparedHandoff(p, owner.native) p.handoff, owner.run = state.preparedHandoff, state r.sessions[input.RunID] = state diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 056deeaab..da3bebaf6 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -113,7 +113,7 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { @@ -368,14 +368,12 @@ func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ - "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, - "input": func(p *proto.PromptRequestPayload) { p.Input = proto.TextInput("input") }, - "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, + "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, + "input": func(p *proto.PromptRequestPayload) { p.Input = proto.TextInput("input") }, "attachment": func(p *proto.PromptRequestPayload) { p.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} }, "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, - "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, } { t.Run(name, func(t *testing.T) { r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { diff --git a/apps/daemon/internal/dispatch/prepared_handoff.go b/apps/daemon/internal/dispatch/prepared_handoff.go index 966f93d36..a0db582a1 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff.go +++ b/apps/daemon/internal/dispatch/prepared_handoff.go @@ -77,14 +77,11 @@ func newPreparedHandoff(p *preparationState, target agent.Executor) *preparedHan // preparedOperationLocked admits one mutation at the same linearization point // used by release. The returned function must run after the native call, replay // bookkeeping and receipt send have all finished. Router.mu must be held. -func (r *Router) preparedOperationLocked(state *sessionState) (agent.Session, func(), bool) { +func (r *Router) preparedOperationLocked(state *sessionState) (agent.Turn, func(), bool) { if state == nil || state.session == nil || state.steeringClosed { return nil, nil, false } handoff := state.preparedHandoff - if handoff == nil { - return state.session, func() {}, true - } if handoff.release != nil { return nil, nil, false } @@ -93,13 +90,7 @@ func (r *Router) preparedOperationLocked(state *sessionState) (agent.Session, fu } func (r *Router) interactionRouteOpenLocked(state *sessionState) bool { - if state == nil || r.closed || state.ctx.Err() != nil || state.steeringClosed { - return false - } - if handoff := state.preparedHandoff; handoff != nil { - return handoff.release == nil - } - return state.session != nil + return state != nil && !r.closed && state.ctx.Err() == nil && !state.steeringClosed && state.preparedHandoff.release == nil } // claimPreparedReleaseLocked closes admission permanently and returns the @@ -111,7 +102,6 @@ func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, fai if release == nil { release = &preparedRelease{abort: make(chan struct{}), failure: failure, settled: make(chan struct{})} handoff.release = release - state.retain = false state.steeringClosed = true state.session = nil r.clearInteractionRoutesLocked(state) @@ -272,6 +262,11 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof } if !owner.invalid { r.scheduleExecutorIdleLocked(owner) + } else if owner.closeDone == nil { + // Shutdown invalidated this owner after the reuse decision above and + // left its close to this Run, which held it. + r.shutdownWG.Add(1) + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() } r.mu.Unlock() diff --git a/apps/daemon/internal/dispatch/prompt.go b/apps/daemon/internal/dispatch/prompt.go deleted file mode 100644 index 7cfc864c6..000000000 --- a/apps/daemon/internal/dispatch/prompt.go +++ /dev/null @@ -1,148 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelope) error { - r.log.InfoContext(callerCtx, "handlePromptRequest: decoding payload", "env_id", env.ID, "env_type", env.Type) - var req proto.PromptRequestPayload - if err := env.DecodeRequest(&req); err != nil { - r.log.ErrorContext(callerCtx, "handlePromptRequest: decode failed", "env_id", env.ID, "err", err) - return fmt.Errorf("dispatch: decode prompt_request: %w", err) - } - // Envelope.ID is the run id; payload mirrors it but envelope wins. - runID := env.ID - if runID == "" { - runID = req.RunID - } - if runID == "" { - r.log.ErrorContext(callerCtx, "handlePromptRequest: missing run id") - return errors.New("dispatch: prompt_request missing run id (Envelope.ID and Payload.RunID both empty)") - } - req.RunID = runID - var err error - if req, err = r.localWorkspace.Configure(req); err != nil { - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if req.AgentKind == "" { - r.log.ErrorContext(callerCtx, "handlePromptRequest: missing agent_kind", "run_id", runID) - return errors.New("dispatch: prompt_request missing agent_kind") - } - if req.WorkspaceReadOnly { - err := errors.New("read-only preparation cannot accept a prompt") - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - caps, available := r.availableCapabilities(req.AgentKind) - if !available { - _, err := r.registry.Resolve(req.AgentKind) - if err == nil { - err = errors.New("engine is unavailable on this runtime") - } - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported() { - err := errors.New("engine does not support function tools") - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if err := validateExecutionEnvironment(req, caps); err != nil { - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - r.log.InfoContext(callerCtx, "handlePromptRequest: decoded", - "run_id", runID, "agent_kind", req.AgentKind, - "message_count", len(req.Input), - "agent_session_id", req.AgentSessionID, - "agent_state_key", req.AgentStateKey) - - factory, err := r.registry.Resolve(req.AgentKind) - if err != nil { - r.log.ErrorContext(callerCtx, "handlePromptRequest: registry.Resolve failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) - // Synthesize error+done so the server-side stream closes - // cleanly instead of waiting for a done that never comes. - r.emitTerminalError(callerCtx, runID, fmt.Sprintf("unsupported agent_kind %q on this daemon", req.AgentKind)) - return err - } - r.log.InfoContext(callerCtx, "handlePromptRequest: factory resolved", "run_id", runID, "agent_kind", req.AgentKind) - - // Lock-protect duplicate-run check + insert so two prompt_requests - // with the same RunID can't both start sessions. - r.mu.Lock() - if r.closed { - r.mu.Unlock() - r.log.ErrorContext(callerCtx, "handlePromptRequest: router closed", "run_id", runID) - return ErrRouterClosed - } - if r.runtimePreparation != nil || r.workspaceWrite != nil || r.workspaceExport != nil { - r.mu.Unlock() - err := errors.New("local workspace has an unsettled write") - r.emitTerminalError(callerCtx, runID, err.Error()) - return err - } - if _, dup := r.sessions[runID]; dup { - r.mu.Unlock() - r.log.WarnContext(callerCtx, "ignoring duplicate prompt_request", "run_id", runID) - return nil - } - stateKey := sessionStateKey(req) - r.touchIdleLocked(stateKey) - sessionCtx, sessionCancel := context.WithCancel(context.Background()) - // Re-attach the inbound trace so every log under this run shows - // the same trace_id as the prompt_request that started it. - if carrier, ok := obslog.TraceFromContext(callerCtx); ok { - sessionCtx = obslog.WithTrace(sessionCtx, carrier) - } - out := make(chan proto.Envelope, 64) - state := &sessionState{ - runID: runID, - capabilities: caps, - environmentID: req.EnvironmentID(), - stateKey: stateKey, - out: out, - ctx: sessionCtx, - ctxCancel: sessionCancel, - pendingIDs: make(map[string]struct{}), - pendingAsks: make(map[string]struct{}), - traceparent: env.Trace, - retain: stateKey != "", - releaseOnCompletion: req.ReleaseOnCompletion, - } - r.sessions[runID] = state - r.mu.Unlock() - - r.log.InfoContext(callerCtx, "handlePromptRequest: calling factory", "run_id", runID) - req, err = r.localWorkspace.Prepare(sessionCtx, req) - var sess agent.Session - if err == nil { - sess, err = factory(sessionCtx, req, out) - } - if err != nil { - r.log.ErrorContext(callerCtx, "handlePromptRequest: factory call failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) - // Roll back the registration, cancel ctx, surface error+done - // so the server doesn't hang on a phantom run. - r.mu.Lock() - delete(r.sessions, runID) - r.mu.Unlock() - sessionCancel() - r.emitTerminalError(callerCtx, runID, fmt.Sprintf("agent factory failed: %v", err)) - return fmt.Errorf("dispatch: factory %q: %w", req.AgentKind, err) - } - r.log.InfoContext(callerCtx, "handlePromptRequest: session created, starting pump", "run_id", runID) - r.mu.Lock() - state.session = sess - r.mu.Unlock() - - r.shutdownWG.Add(1) - go r.pump(state) - return nil -} diff --git a/apps/daemon/internal/dispatch/receipt_order_test.go b/apps/daemon/internal/dispatch/receipt_order_test.go index 8fb22b4c4..898889a4b 100644 --- a/apps/daemon/internal/dispatch/receipt_order_test.go +++ b/apps/daemon/internal/dispatch/receipt_order_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -52,7 +50,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { registry := agent.NewRegistry() var session *fakeSession var calls atomic.Int32 - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true} return &steeringSession{fakeSession: session, steer: func(context.Context, proto.PromptSteerPayload) error { calls.Add(1) @@ -61,7 +59,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { } return nil }}, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) if err != nil { t.Fatal(err) @@ -75,18 +73,20 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { t.Fatal(err) } } - handle(proto.TypePromptRequest, proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true}) + startRun(t, router, &sender.recSender, "ordered", proto.PromptRequestPayload{AgentKind: "codex"}) + base := len(sender.snapshot()) input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("original")} handle(proto.TypePromptSteer, input) <-sender.entered session.out <- mustEnv(t, proto.TypeDone, "ordered", proto.DonePayload{Content: "finished"}) waitFor(t, func() bool { return router.SteeringClosedForTest("ordered") }, "closed steering admission") - if session.cancels() != 0 || len(sender.snapshot()) != 0 { - t.Fatal("native release or Done overtook receipt") + // Native settlement precedes the receipt join; TestPreparedHandoffReleaseWaitsForMutationReceipt asserts that order. + if len(sender.snapshot()) != base { + t.Fatal("Done overtook receipt") } if mode != "send_failure" { handle(proto.TypePromptSteer, input) - frames := sender.snapshot() + frames := sender.snapshot()[base:] var ack proto.PromptSteerAckPayload if len(frames) != 1 || frames[0].DecodePayload(&ack) != nil || ack.Accepted != (mode == "consumed") { t.Fatalf("cached receipt lost: %+v", ack) @@ -98,7 +98,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { handle(proto.TypePromptSteer, input) input.InputID = "new" handle(proto.TypePromptSteer, input) - frames = sender.snapshot() + frames = sender.snapshot()[base:] for i, want := range []string{"input_conflict", "run_inactive"} { if frames[i+1].DecodePayload(&ack) != nil || ack.ErrorCode != want { t.Fatalf("receipt %d: %+v", i, ack) @@ -106,8 +106,8 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { } } release.Do(func() { close(sender.release) }) - waitFor(t, func() bool { return router.ActiveRuns() == 0 }, "durable completion") - frames := sender.snapshot() + waitFor(t, func() bool { return hasFrame(&sender.recSender, proto.TypeDone, "ordered") }, "durable completion") + frames := sender.snapshot()[base:] if len(frames) == 0 || frames[len(frames)-1].Type != proto.TypeDone || calls.Load() != 1 || session.cancels() != 1 { t.Fatalf("invalid terminal order/calls: %+v, calls=%d cancels=%d", frames, calls.Load(), session.cancels()) } @@ -118,14 +118,14 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { } } -func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { +func TestShutdownReleasesSteeringWorkerAndReceiptJoin(t *testing.T) { for _, phase := range []string{"native", "receipt_send"} { t.Run(phase, func(t *testing.T) { sender := &blockedReceiptSender{entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} registry := agent.NewRegistry() entered, exited := make(chan struct{}), make(chan struct{}) var session *fakeSession - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true} return &steeringSession{fakeSession: session, steer: func(ctx context.Context, _ proto.PromptSteerPayload) error { close(entered) @@ -136,15 +136,13 @@ func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { } return nil }}, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) if err != nil { t.Fatal(err) } defer router.Shutdown(context.Background()) - if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "shutdown", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + startRun(t, router, &sender.recSender, "shutdown", proto.PromptRequestPayload{AgentKind: "codex"}) if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptSteer, "shutdown", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text")})); err != nil { t.Fatal(err) } @@ -152,7 +150,7 @@ func TestShutdownReleasesSteeringWorkerAndCompletionBarrier(t *testing.T) { if phase == "receipt_send" { <-sender.entered session.out <- mustEnv(t, proto.TypeDone, "shutdown", proto.DonePayload{}) - waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown") }, "completion barrier") + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown") }, "receipt join") } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() diff --git a/apps/daemon/internal/dispatch/receipt_shutdown_test.go b/apps/daemon/internal/dispatch/receipt_shutdown_test.go index 4455f59ac..e736bf867 100644 --- a/apps/daemon/internal/dispatch/receipt_shutdown_test.go +++ b/apps/daemon/internal/dispatch/receipt_shutdown_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -47,23 +45,21 @@ func TestShutdownCancelsCompletionErrorSend(t *testing.T) { sender := &shutdownAllSendsBlockSender{entered: make(chan struct{}), terminal: make(chan context.Context, 1), rescue: make(chan struct{})} registry := agent.NewRegistry() var session *fakeSession - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(registry, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true} return &steeringSession{fakeSession: session, steer: func(context.Context, proto.PromptSteerPayload) error { return nil }}, nil - }) + })) router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) if err != nil { t.Fatal(err) } - if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "shutdown-terminal", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + startRun(t, router, &sender.recSender, "shutdown-terminal", proto.PromptRequestPayload{AgentKind: "codex"}) if err = router.Handle(context.Background(), mustEnv(t, proto.TypePromptSteer, "shutdown-terminal", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text")})); err != nil { t.Fatal(err) } <-sender.entered session.out <- mustEnv(t, proto.TypeDone, "shutdown-terminal", proto.DonePayload{}) - waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown-terminal") }, "completion barrier") + waitFor(t, func() bool { return router.SteeringClosedForTest("shutdown-terminal") }, "receipt join") ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() shutdownErr := router.Shutdown(ctx) diff --git a/apps/daemon/internal/dispatch/router.go b/apps/daemon/internal/dispatch/router.go index 5cbb71ea9..c1bec714a 100644 --- a/apps/daemon/internal/dispatch/router.go +++ b/apps/daemon/internal/dispatch/router.go @@ -1,6 +1,6 @@ // Package dispatch wires inbound WebSocket frames to the agent layer. -// It owns one Session per active RunID, a per-session pump goroutine -// that forwards the agent's events to the transport, and a +// It owns one Turn per active RunID, a per-Turn output goroutine that +// forwards the agent's events to the transport, and a // permission_id → run_id index so permission_decision frames route // back to the right session. // @@ -38,9 +38,8 @@ type Router struct { suspension *proto.EnvironmentSuspendPayload mu sync.Mutex sessions map[string]*sessionState // RunID → state - idle map[string]map[*sessionState]struct{} - permIndex map[string]string // permID → RunID - askIndex map[string]string // askID → RunID + permIndex map[string]string // permID → RunID + askIndex map[string]string // askID → RunID applied map[string]appliedInteractionDecision shutdownAttempt *shutdownAttempt shutdownCh chan struct{} // closed by Shutdown @@ -67,31 +66,24 @@ type appliedInteractionDecision struct { } // sessionState is the dispatcher's per-run bookkeeping. The agent -// owns the close of out; the dispatcher cancels ctxCancel to wind -// down. traceparent captures the prompt_request's W3C trace so every -// outbound frame stamps env.Trace with the same value, completing +// owns the close of out; preparedHandoff owns release. traceparent +// captures the execution_start's W3C trace so every outbound frame +// stamps env.Trace with the same value, completing // frontend → server → daemon → agent → server attribution. type sessionState struct { - capabilities proto.AgentKindCapabilities - runID string - environmentID string - stateKey string - session agent.Session - out chan proto.Envelope - ctx context.Context - ctxCancel context.CancelFunc - pendingIDs map[string]struct{} - pendingAsks map[string]struct{} - traceparent string - idleTimer *time.Timer - idleLease uint64 - retain bool - releaseOnCompletion bool - steering map[string]steeringReceipt - steerBusy bool - steeringClosed bool - steeringDone chan struct{} - preparedHandoff *preparedHandoff + capabilities proto.AgentKindCapabilities + runID string + environmentID string + session agent.Turn + out chan proto.Envelope + ctx context.Context + pendingIDs map[string]struct{} + pendingAsks map[string]struct{} + traceparent string + steering map[string]steeringReceipt + steerBusy bool + steeringClosed bool + preparedHandoff *preparedHandoff } // Config is the constructor input. Registry and Sender are required; @@ -140,7 +132,6 @@ func New(cfg Config) (*Router, error) { sender: cfg.Sender, log: log, sessions: make(map[string]*sessionState), - idle: make(map[string]map[*sessionState]struct{}), permIndex: make(map[string]string), askIndex: make(map[string]string), applied: make(map[string]appliedInteractionDecision), @@ -192,8 +183,6 @@ func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { return r.handleExecutionStart(ctx, env) case proto.TypeExecutionRelease: return r.handleExecutionRelease(ctx, env) - case proto.TypePromptRequest: - return r.handlePromptRequest(ctx, env) case proto.TypePromptCancel: return r.handlePromptCancel(ctx, env) case proto.TypeFunctionResult: @@ -244,8 +233,8 @@ func (r *Router) drain(ch <-chan proto.Envelope) { } } -// cleanupSession removes the session from registry maps. Called from -// pump's defer so it runs exactly once. +// cleanupSession removes the session from registry maps. Called once +// when its prepared release settles. func (r *Router) cleanupSession(s *sessionState) { r.mu.Lock() delete(r.sessions, s.runID) @@ -255,84 +244,5 @@ func (r *Router) cleanupSession(s *sessionState) { for askID := range s.pendingAsks { delete(r.askIndex, askID) } - if !s.retain || s.session == nil || s.stateKey == "" || r.closed { - r.mu.Unlock() - return - } - states := r.idle[s.stateKey] - if states == nil { - states = make(map[*sessionState]struct{}) - r.idle[s.stateKey] = states - } - states[s] = struct{}{} - r.scheduleIdleLocked(s) - r.mu.Unlock() -} - -func sessionStateKey(req proto.PromptRequestPayload) string { - return req.AgentStateKey -} - -func (r *Router) touchIdleLocked(stateKey string) { - if stateKey == "" { - return - } - for state := range r.idle[stateKey] { - r.scheduleIdleLocked(state) - } -} - -func (r *Router) scheduleIdleLocked(state *sessionState) { - if state.idleTimer != nil { - state.idleTimer.Stop() - } - state.idleLease++ - lease := state.idleLease - state.idleTimer = time.AfterFunc(r.idleTimeout, func() { - r.expireIdle(state, lease) - }) -} - -func (r *Router) expireIdle(state *sessionState, lease uint64) { - r.mu.Lock() - states := r.idle[state.stateKey] - if _, ok := states[state]; !ok || state.idleLease != lease || r.suspension != nil { - r.mu.Unlock() - return - } - r.shutdownWG.Add(1) - defer r.shutdownWG.Done() - delete(states, state) - if len(states) == 0 { - delete(r.idle, state.stateKey) - } - state.retain = false r.mu.Unlock() - - state.ctxCancel() - if err := state.session.Cancel(context.Background()); err != nil { - r.log.Warn("idle session cancel failed", "run_id", state.runID, "state_key", state.stateKey, "err", err) - } -} - -// emitTerminalError synthesises error + done for a run that couldn't -// even be started. Stamps env.Trace from ctx so the gateway can -// attribute these frames to the same trace_id. -func (r *Router) emitTerminalError(ctx context.Context, runID, msg string) { - traceparent := "" - if carrier, ok := obslog.TraceFromContext(ctx); ok { - traceparent = carrier.String() - } - errEnv, err := proto.NewEnvelopeWithTrace(proto.TypeError, runID, proto.ErrorPayload{Error: msg}, traceparent) - if err == nil { - if sendErr := r.sender.Send(ctx, errEnv); sendErr != nil { - r.log.ErrorContext(ctx, "emit terminal error frame failed", "run_id", runID, "err", sendErr) - } - } - doneEnv, err := proto.NewEnvelopeWithTrace(proto.TypeDone, runID, proto.DonePayload{}, traceparent) - if err == nil { - if sendErr := r.sender.Send(ctx, doneEnv); sendErr != nil { - r.log.ErrorContext(ctx, "emit terminal done frame failed", "run_id", runID, "err", sendErr) - } - } } diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 20a1a9400..22122b499 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -60,6 +58,8 @@ func (s *recSender) typesFor(runID string) []string { // fakeSession is a controllable session. The test owns its out // channel and manipulates outbound traffic / cancel observability. type fakeSession struct { + runID string + input proto.MessageInput cancelCalls int submitCalls []permCall askCalls []askCall @@ -133,36 +133,28 @@ func (s *fakeSession) submissions() []permCall { // helpers // --------------------------------------------------------------------- -// newHarness builds a Router whose registry exposes a single -// fake_alpha factory that records inputs and exposes the in-flight -// session. +// harness is a Router whose fake_alpha kind starts a recorded fakeSession +// for each Turn. type harness struct { router *dispatch.Router sender *recSender reg *agent.Registry - gotReq chan proto.PromptRequestPayload gotSess chan *fakeSession } func newHarness(t *testing.T) *harness { - return newHarnessWithIdleTimeout(t, time.Hour) -} - -func newHarnessWithIdleTimeout(t *testing.T, idleTimeout time.Duration) *harness { t.Helper() h := &harness{ sender: &recSender{}, reg: agent.NewRegistry(), - gotReq: make(chan proto.PromptRequestPayload, 16), gotSess: make(chan *fakeSession, 16), } - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Permissions: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess := &fakeSession{out: out, ctx: ctx} - h.gotReq <- req + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Permissions: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + sess := &fakeSession{runID: runID, input: input, out: out} h.gotSess <- sess return sess, nil - }) - r, err := dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, IdleTimeout: idleTimeout}) + })) + r, err := dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender}) if err != nil { t.Fatalf("dispatch.New: %v", err) } @@ -170,58 +162,20 @@ func newHarnessWithIdleTimeout(t *testing.T, idleTimeout time.Duration) *harness return h } -func TestCompletedSessionCancelsAfterIdleTimeout(t *testing.T) { - h := newHarnessWithIdleTimeout(t, 40*time.Millisecond) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_idle", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", ConversationID: "conv-idle", AgentStateKey: "conv-idle/agent/fake_alpha", +// sessionExecutor runs each Turn through start on a disposable Executor. +func sessionExecutor(start func(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error)) agent.ExecutorFactory { + return preparationExecutorFixture(func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { + return &controlledPreparation{start: start}, nil }) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("Handle prompt_request: %v", err) - } - sess := <-h.gotSess - close(sess.out) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active run cleanup") - - select { - case <-sess.ctx.Done(): - t.Fatal("completed session cancelled before idle timeout") - case <-time.After(15 * time.Millisecond): - } - waitFor(t, func() bool { return sess.cancels() == 1 }, "idle session cancellation") } -func TestNewPromptResetsCompletedSessionIdleTimeout(t *testing.T) { - h := newHarnessWithIdleTimeout(t, 80*time.Millisecond) - defer h.router.Shutdown(context.Background()) - - stateKey := "conv-renew/agent/fake_alpha" - first := mustEnv(t, proto.TypePromptRequest, "run_first", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", ConversationID: "conv-renew", AgentStateKey: stateKey, - }) - if err := h.router.Handle(context.Background(), first); err != nil { - t.Fatalf("Handle first prompt: %v", err) - } - firstSession := <-h.gotSess - close(firstSession.out) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "first run cleanup") - time.Sleep(50 * time.Millisecond) - - second := mustEnv(t, proto.TypePromptRequest, "run_second", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", ConversationID: "conv-renew", AgentStateKey: stateKey, - }) - if err := h.router.Handle(context.Background(), second); err != nil { - t.Fatalf("Handle second prompt: %v", err) - } - secondSession := <-h.gotSess - - time.Sleep(45 * time.Millisecond) - if firstSession.cancels() != 0 { - t.Fatal("new prompt did not renew the completed session idle timeout") - } - close(secondSession.out) - waitFor(t, func() bool { return firstSession.cancels() == 1 }, "renewed idle session cancellation") +// startRun prepares a dedicated Executor for run, starts the Run with input +// "input" and waits until it accepts operations. +func startRun(t *testing.T, r *dispatch.Router, s *recSender, run string, config proto.PromptRequestPayload) { + t.Helper() + ready := executorAdmission(t, r, s, "prepare-"+run, noEnvironmentPreparation("session-"+run, config)) + startExecutorTurn(t, r, s, "prepare-"+run, run, ready) + waitFor(t, func() bool { return r.RunStartedForTest(run) }, "run "+run+" to accept operations") } func mustEnv(t *testing.T, typ, id string, payload any) proto.Envelope { @@ -237,22 +191,15 @@ func mustEnv(t *testing.T, typ, id string, payload any) proto.Envelope { // tests // --------------------------------------------------------------------- -func TestHandlePromptRequestInvokesFactoryAndForwardsOutput(t *testing.T) { +func TestExecutionStartRunsInputAndForwardsOutput(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_1", proto.PromptRequestPayload{ - AgentKind: "fake_alpha", Input: proto.TextInput("hi"), ConversationID: "c1", - }) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("Handle prompt_request: %v", err) - } - - req := <-h.gotReq - if req.RunID != "run_1" || req.AgentKind != "fake_alpha" || *req.Input[0].Content[0].Text != "hi" { - t.Errorf("factory got %+v, want run_1/fake_alpha/hi", req) - } + startRun(t, h.router, h.sender, "run_1", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess + if sess.runID != "run_1" || len(sess.input) != 1 || *sess.input[0].Content[0].Text != "input" { + t.Errorf("Turn got run %q input %+v, want run_1/input", sess.runID, sess.input) + } // Session emits a delta + done; both should reach the sender. sess.out <- mustEnv(t, proto.TypeDelta, "run_1", proto.DeltaPayload{Delta: "hello", Sequence: 1}) @@ -261,69 +208,70 @@ func TestHandlePromptRequestInvokesFactoryAndForwardsOutput(t *testing.T) { waitForTypes(t, h.sender, "run_1", []string{proto.TypeDelta, proto.TypeDone}) - // Pump should have removed the session. + // Settlement should have removed the Run. waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active runs to drop to 0") } -func TestHandlePromptRequestRejectsDuplicateRunID(t *testing.T) { +func TestExecutionStartRejectsDuplicateRunID(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("first Handle: %v", err) - } - <-h.gotReq // drain first + startRun(t, h.router, h.sender, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess - // Second prompt_request with same RunID should NOT spin up a second factory call. - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("duplicate Handle: %v", err) + // Another Executor must not start a second Turn for the same RunID. + second := executorAdmission(t, h.router, h.sender, "prepare-dup", noEnvironmentPreparation("session-dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + start := mustEnv(t, proto.TypeExecutionStart, "prepare-dup", proto.ExecutionStartPayload{Handle: second.Handle, ExecutorID: second.ExecutorID, RunID: "run_dup", Input: proto.TextInput("input")}) + if err := h.router.Handle(context.Background(), start); err == nil { + t.Fatal("duplicate Run started") + } + if status := waitPreparationStatus(t, h.sender, "prepare-dup", "rejected", ""); status.ErrorCode != "run_conflict" { + t.Fatalf("duplicate start status = %+v, want run_conflict", status) } select { - case extra := <-h.gotReq: - t.Fatalf("factory invoked twice for duplicate run, second req=%+v", extra) + case extra := <-h.gotSess: + t.Fatalf("Turn started twice for duplicate run, second run=%s", extra.runID) case <-time.After(50 * time.Millisecond): } close(sess.out) } -func TestHandlePromptRequestUnsupportedKindEmitsErrorDone(t *testing.T) { +func TestExecutionPrepareRejectsUnknownKind(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_x", proto.PromptRequestPayload{AgentKind: "fake_beta"}) - err := h.router.Handle(context.Background(), env) - if !errors.Is(err, agent.ErrUnsupportedKind) { - t.Errorf("Handle unsupported = %v, want ErrUnsupportedKind", err) + env := mustEnv(t, proto.TypeExecutionPrepare, "prepare_x", noEnvironmentPreparation("x", proto.PromptRequestPayload{AgentKind: "fake_beta"})) + if err := h.router.Handle(context.Background(), env); err == nil { + t.Error("unknown kind admitted") } - got := h.sender.typesFor("run_x") - want := []string{proto.TypeError, proto.TypeDone} - if !slices.Equal(got, want) { - t.Errorf("sender frames for run_x = %v, want %v", got, want) + if status := waitPreparationStatus(t, h.sender, "prepare_x", "rejected", ""); status.ErrorCode != "resource_unavailable" { + t.Errorf("unknown kind status = %+v, want resource_unavailable", status) + } + if got, want := h.sender.typesFor("prepare_x"), []string{proto.TypePreparationStatus}; !slices.Equal(got, want) { + t.Errorf("sender frames for prepare_x = %v, want %v", got, want) } } -func TestHandlePromptRequestMissingRunIDIsError(t *testing.T) { +func TestExecutionStartRequiresRunID(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err == nil { + ready := executorAdmission(t, h.router, h.sender, "prepare", noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + start := mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, Input: proto.TextInput("input")}) + if err := h.router.Handle(context.Background(), start); err == nil { t.Fatal("expected error on missing run id") } + if status := waitPreparationStatus(t, h.sender, "prepare", "rejected", ""); status.ErrorCode != "invalid_start" { + t.Fatalf("missing run id status = %+v, want invalid_start", status) + } } func TestHandlePromptCancelInvokesSessionCancel(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "run_2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess sess.closeOutOnCancel = true @@ -348,11 +296,7 @@ func TestPermissionRequestIsIndexedAndDecisionRoutes(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_p", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "run_p", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess // Session emits a permission_request; pump should index it. @@ -361,7 +305,7 @@ func TestPermissionRequestIsIndexedAndDecisionRoutes(t *testing.T) { }) sess.out <- permEnv // Wait until sender records — indexing happens before send. - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "permission_request to be forwarded") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypePermissionRequest, "run_p") }, "permission_request to be forwarded") dec := mustEnv(t, proto.TypePermissionDecision, "perm_abcd1234", proto.PermissionDecisionPayload{DeliveryID: "delivery-perm-1", Approved: true}) if err := h.router.Handle(context.Background(), dec); err != nil { @@ -389,32 +333,6 @@ func TestPermissionRequestIsIndexedAndDecisionRoutes(t *testing.T) { close(sess.out) } -func TestPermissionCancelDeindexes(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - env := mustEnv(t, proto.TypePromptRequest, "run_p2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - _ = h.router.Handle(context.Background(), env) - <-h.gotReq - sess := <-h.gotSess - - sess.out <- mustEnv(t, proto.TypePermissionRequest, "run_p2", proto.PermissionRequestPayload{RequestID: "perm_xx", Tool: "Bash"}) - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "perm forwarded") - sess.out <- mustEnv(t, proto.TypePermissionCancel, "perm_xx", nil) - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 2 }, "perm_cancel forwarded") - - // A decision for the cancelled perm should be a no-op (session never sees it). - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePermissionDecision, "perm_xx", proto.PermissionDecisionPayload{DeliveryID: "delivery-cancelled"})); err != nil { - t.Fatalf("decision: %v", err) - } - if calls := sess.submissions(); len(calls) != 0 { - t.Errorf("expected zero submissions after cancel, got %+v", calls) - } - assertDecisionAck(t, h.sender, "delivery-cancelled", false, "not_pending") - - close(sess.out) -} - func TestPermissionDecisionUnknownPermIsNoop(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) @@ -429,11 +347,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_ask", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "run_ask", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess // Envelope.ID is the run id (server-side dispatch fans on it); the @@ -445,7 +359,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { ToolUseID: "toolu_42", }) sess.out <- askEnv - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypePromptForUserChoice, "run_ask") }, "prompt_for_user_choice forwarded") dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_abcd1234", proto.PromptForUserChoiceDecisionPayload{ DeliveryID: "delivery-ask-1", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, @@ -486,11 +400,7 @@ func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_ask_u", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "run_ask_u", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess sess.askErr = agent.ErrUnknownAsk @@ -500,7 +410,7 @@ func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { ToolUseID: "toolu_y", }) sess.out <- askEnv - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypePromptForUserChoice, "run_ask_u") }, "prompt_for_user_choice forwarded") dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_xxxxxxxx", proto.PromptForUserChoiceDecisionPayload{ DeliveryID: "delivery-ask-gone", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, @@ -524,18 +434,14 @@ func TestPromptForUserChoiceDecisionKeepsIndexOnTransientAgentError(t *testing.T h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_ask_retry", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - if err := h.router.Handle(context.Background(), env); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "run_ask_retry", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess sess.askErr = errors.New("temporary stdin failure") sess.out <- mustEnv(t, proto.TypePromptForUserChoice, "run_ask_retry", proto.PromptForUserChoicePayload{ AskID: "ask_retry", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Question: "Retry?"}}, }) - waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypePromptForUserChoice, "run_ask_retry") }, "prompt_for_user_choice forwarded") decision := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_retry", proto.PromptForUserChoiceDecisionPayload{ DeliveryID: "delivery-ask-retry", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, @@ -607,7 +513,7 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { if err := h.router.Shutdown(context.Background()); err != nil { t.Fatalf("Shutdown: %v", err) } - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "r", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypeExecutionPrepare, "r", noEnvironmentPreparation("session", proto.PromptRequestPayload{AgentKind: "fake_alpha"}))) if !errors.Is(err, dispatch.ErrRouterClosed) { t.Errorf("post-shutdown Handle = %v, want ErrRouterClosed", err) } @@ -616,10 +522,7 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { func TestShutdownWaitsForPumpDrain(t *testing.T) { h := newHarness(t) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "rs", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatalf("prompt_request: %v", err) - } - <-h.gotReq + startRun(t, h.router, h.sender, "rs", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) sess := <-h.gotSess // Background: emit one frame then close out shortly after diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index ba8866e43..88d21dba8 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -114,7 +114,7 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e } func (r *Router) runtimePreparationResourcesBusyLocked() bool { - if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.executors) != 0 { + if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.executors) != 0 { return true } for _, p := range r.preparations { diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index feb7fbf6e..d8a3399f9 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -169,7 +169,7 @@ func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { } func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { - for _, mode := range []string{"write", "export", "read", "run", "idle", "executor", "preparation"} { + for _, mode := range []string{"write", "export", "read", "run", "executor", "preparation"} { t.Run(mode, func(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) switch mode { @@ -181,8 +181,6 @@ func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { r.workspaceReads = map[string]struct{}{"read": {}} case "run": r.sessions["run"] = &sessionState{} - case "idle": - r.idle["state"] = map[*sessionState]struct{}{} case "executor": r.executors[session] = &executorState{} case "preparation": @@ -202,7 +200,6 @@ func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { r.workspaceExport = nil r.workspaceReads = nil clear(r.sessions) - clear(r.idle) clear(r.executors) clear(r.preparations) shutdownCapabilitiesRouter(t, r) diff --git a/apps/daemon/internal/dispatch/shutdown.go b/apps/daemon/internal/dispatch/shutdown.go index 585b806be..ef27133b4 100644 --- a/apps/daemon/internal/dispatch/shutdown.go +++ b/apps/daemon/internal/dispatch/shutdown.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) type shutdownAttempt struct { @@ -30,32 +28,12 @@ func (r *Router) Shutdown(ctx context.Context) error { } } - first := !r.closed - var victims []sessionCancellation - for _, state := range r.sessions { - state.retain = false - if state.preparedHandoff != nil { - release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) - victims = append(victims, sessionCancellation{runID: state.runID, handoff: state.preparedHandoff, release: release, attempt: attempt}) - } else if first { - victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) - } - } - if first { + victims := r.sessionCancellationsLocked() + if !r.closed { r.closed = true if r.runtimePreparation != nil { r.runtimePreparation.cancel() } - for _, states := range r.idle { - for state := range states { - state.retain = false - if state.idleTimer != nil { - state.idleTimer.Stop() - } - victims = append(victims, sessionCancellation{runID: state.runID, ctxCancel: state.ctxCancel, session: state.session}) - } - } - r.idle = make(map[string]map[*sessionState]struct{}) // Prepared release claims exist before this signal can interrupt output. close(r.shutdownCh) } @@ -78,19 +56,10 @@ func (r *Router) Shutdown(ctx context.Context) error { func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionCancellation) { var releaseErr error for _, victim := range victims { - if victim.handoff != nil { - // The cleanup operation has its own fixed native deadline. The - // Shutdown caller's deadline only bounds its wait for this attempt. - if err := r.awaitPreparedNativeRelease(context.Background(), victim.release, victim.attempt); err != nil { - releaseErr = errors.Join(releaseErr, fmt.Errorf("dispatch: prepared run %s: %w", victim.runID, err)) - } - continue - } - victim.ctxCancel() - if victim.session != nil { - if err := victim.session.Cancel(context.Background()); err != nil { - r.log.Warn("session.Cancel failed", "run_id", victim.runID, "err", err) - } + // The cleanup operation has its own fixed native deadline. The + // Shutdown caller's deadline only bounds its wait for this attempt. + if err := r.awaitPreparedNativeRelease(context.Background(), victim.release, victim.attempt); err != nil { + releaseErr = errors.Join(releaseErr, fmt.Errorf("dispatch: prepared run %s: %w", victim.runID, err)) } } r.shutdownWG.Done() @@ -119,7 +88,11 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC } } for _, owner := range r.executors { - attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: executor %s cleanup unconfirmed: %w", owner.id, owner.closeErr)) + cause := owner.closeErr + if cause == nil { + cause = errors.New("cleanup has not settled") + } + attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: executor %s: %w", owner.id, cause)) } close(attempt.done) r.mu.Unlock() @@ -142,10 +115,19 @@ func waitShutdown(ctx context.Context, attempt *shutdownAttempt) error { } type sessionCancellation struct { - runID string - ctxCancel context.CancelFunc - session agent.Session - handoff *preparedHandoff - release *preparedRelease - attempt *preparedReleaseAttempt + runID string + handoff *preparedHandoff + release *preparedRelease + attempt *preparedReleaseAttempt +} + +// sessionCancellationsLocked claims release of every active Run, retrying a +// failed native attempt. Router.mu must be held. +func (r *Router) sessionCancellationsLocked() []sessionCancellation { + victims := make([]sessionCancellation, 0, len(r.sessions)) + for _, state := range r.sessions { + release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) + victims = append(victims, sessionCancellation{runID: state.runID, handoff: state.preparedHandoff, release: release, attempt: attempt}) + } + return victims } diff --git a/apps/daemon/internal/dispatch/steering.go b/apps/daemon/internal/dispatch/steering.go index df7b098c5..98e957efb 100644 --- a/apps/daemon/internal/dispatch/steering.go +++ b/apps/daemon/internal/dispatch/steering.go @@ -98,12 +98,7 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr } session := state.session steerer, supportsSteering := session.(agent.Steerer) - if input.DurableReceipt { - if _, ok := session.(agent.DurableSteerer); !ok || (!state.releaseOnCompletion && state.preparedHandoff == nil) { - ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported Turn settlement contract." - return &ack - } - } else if !supportsSteering { + if !input.DurableReceipt && !supportsSteering { ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." return &ack } @@ -120,8 +115,6 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr ack.ErrorCode, ack.Error = "in_flight", "This input is awaiting an engine receipt." state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} state.steerBusy = true - finished := make(chan struct{}) - state.steeringDone = finished r.shutdownWG.Add(1) go func() { defer r.shutdownWG.Done() @@ -129,7 +122,6 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr defer func() { r.mu.Lock() state.steerBusy = false - close(finished) r.mu.Unlock() }() ctx, stop := r.shutdownContext(ctx) @@ -180,25 +172,3 @@ func (r *Router) shutdownContext(parent context.Context) (context.Context, conte }() return ctx, cancel } - -// Close admission before taking the last worker: its lifetime includes the -// receipt send, so a durable Done cannot close the gateway subscription first. -func (r *Router) finishSteering(state *sessionState) error { - r.mu.Lock() - state.steeringClosed = true - finished := state.steeringDone - r.mu.Unlock() - if finished == nil { - return nil - } - timer := time.NewTimer(steeringCallTimeout + steeringSendTimeout) - defer timer.Stop() - select { - case <-finished: - return nil - case <-r.shutdownCh: - return context.Canceled - case <-timer.C: - return context.DeadlineExceeded - } -} diff --git a/apps/daemon/internal/dispatch/steering_lifetime.go b/apps/daemon/internal/dispatch/steering_lifetime.go index b696de6a4..adbfbe054 100644 --- a/apps/daemon/internal/dispatch/steering_lifetime.go +++ b/apps/daemon/internal/dispatch/steering_lifetime.go @@ -9,7 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, session agent.Session, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte) error { +func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, session agent.DurableSteerer, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte) error { ctx, cancel := context.WithCancel(state.ctx) defer cancel() stopShutdown := context.AfterFunc(sendCtx, cancel) @@ -17,7 +17,7 @@ func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, sess timer := time.AfterFunc(steeringCallTimeout, cancel) defer timer.Stop() var once sync.Once - return session.(agent.DurableSteerer).SteerWithReceipt(ctx, input, func() { + return session.SteerWithReceipt(ctx, input, func() { once.Do(func() { if !timer.Stop() || ctx.Err() != nil { return diff --git a/apps/daemon/internal/dispatch/steering_lifetime_test.go b/apps/daemon/internal/dispatch/steering_lifetime_test.go index c51b87b87..9a2419768 100644 --- a/apps/daemon/internal/dispatch/steering_lifetime_test.go +++ b/apps/daemon/internal/dispatch/steering_lifetime_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "sync/atomic" @@ -28,7 +26,7 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { var session *fakeSession var calls atomic.Int32 release := make(chan struct{}) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { session = &fakeSession{out: out, closeOutOnCancel: true} return &durableSteeringSession{steeringSession: &steeringSession{fakeSession: session}, phased: func(ctx context.Context, input proto.PromptSteerPayload, written func()) error { calls.Add(1) @@ -40,11 +38,9 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { return ctx.Err() } }}, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "durable", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + })) + startRun(t, h.router, h.sender, "durable", proto.PromptRequestPayload{AgentKind: "codex"}) + base := len(h.sender.snapshot()) input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("additional"), DurableReceipt: true} env := mustEnv(t, proto.TypePromptSteer, "durable", input) if err := handleSteeringAndWait(t, h, env); err != nil { @@ -54,7 +50,7 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { t.Fatalf("write phase: %+v", ack) } time.Sleep(11 * time.Second) - if len(h.sender.snapshot()) != 1 { + if len(h.sender.snapshot()) != base+1 { t.Fatal("native wait ended at transport deadline") } if err := handleSteeringAndWait(t, h, env); err != nil { @@ -64,12 +60,12 @@ func TestDurableSteeringWaitsBeyondTransportDeadline(t *testing.T) { t.Fatalf("cached phase: %+v", ack) } close(release) - waitFor(t, func() bool { return len(h.sender.snapshot()) == 3 }, "native acceptance") + waitFor(t, func() bool { return len(h.sender.snapshot()) == base+3 }, "native acceptance") if ack := lastSteeringAck(t, h.sender, "durable", "extra"); !ack.Accepted || ack.Written { t.Fatalf("final phase: %+v", ack) } session.out <- mustEnv(t, proto.TypeDone, "durable", proto.DonePayload{}) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "completion") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "durable") }, "completion") frames := h.sender.snapshot() if calls.Load() != 1 || frames[len(frames)-1].Type != proto.TypeDone { t.Fatal("replayed input or incorrect completion order") @@ -82,7 +78,7 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) exited := make(chan struct{}) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &durableSteeringSession{steeringSession: &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}}, phased: func(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { defer close(exited) if phase == "written" { @@ -91,11 +87,10 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { <-ctx.Done() return ctx.Err() }}, nil - }) + })) ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run", proto.PromptRequestPayload{AgentKind: "codex"}) + base := len(h.sender.snapshot()) if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text"), DurableReceipt: true})); err != nil { t.Fatal(err) } @@ -105,12 +100,12 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { case <-time.After(12 * time.Second): t.Fatal("blocked write was not bounded") } - waitFor(t, func() bool { return len(h.sender.snapshot()) == 1 }, "unknown receipt") + waitFor(t, func() bool { return len(h.sender.snapshot()) == base+1 }, "unknown receipt") if ack := lastSteeringAck(t, h.sender, "run", "one"); ack.Written || ack.Accepted || ack.ErrorCode != "outcome_unknown" { t.Fatalf("transport uncertainty: %+v", ack) } } else { - waitFor(t, func() bool { return len(h.sender.snapshot()) == 1 }, "written phase") + waitFor(t, func() bool { return len(h.sender.snapshot()) == base+1 }, "written phase") stopCtx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() if err := h.router.Shutdown(stopCtx); err != nil { @@ -125,32 +120,3 @@ func TestDurableSteeringTransportTimeoutAndShutdown(t *testing.T) { }) } } - -func TestDurableSteeringRequiresOptInAndAdapter(t *testing.T) { - for _, supported := range []bool{false, true} { - t.Run(map[bool]string{false: "old-adapter", true: "retained-run"}[supported], func(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(_ context.Context, _ proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - s := &steeringSession{fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { t.Error("unexpected legacy call"); return nil }} - if !supported { - return s, nil - } - return &durableSteeringSession{steeringSession: s, phased: func(context.Context, proto.PromptSteerPayload, func()) error { - t.Error("unexpected phased call") - return nil - }}, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex", ReleaseOnCompletion: !supported})); err != nil { - t.Fatal(err) - } - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("text"), DurableReceipt: true})); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run", "one"); ack.ErrorCode != "unsupported" { - t.Fatalf("capability gate: %+v", ack) - } - }) - } -} diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go index c989afd7c..bf5b214e9 100644 --- a/apps/daemon/internal/dispatch/steering_test.go +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -1,7 +1,5 @@ package dispatch_test -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" - import ( "context" "errors" @@ -33,7 +31,7 @@ func TestSteeringReceiptsAndRetries(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls, starts := 0, 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { starts++ return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, @@ -45,17 +43,15 @@ func TestSteeringReceiptsAndRetries(t *testing.T) { if input.InputID != "input-1" || *input.Input[0].Content[0].Text != "additional text" { t.Errorf("input lost: %+v", input) } - if len(h.sender.snapshot()) != 0 { + if hasFrame(h.sender, proto.TypePromptSteerAck, "run-1") { t.Error("ack sent before engine accepted input") } return engineError }, }, nil - }) + })) ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run-1", proto.PromptRequestPayload{AgentKind: "codex"}) input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("additional text")} env := mustEnv(t, proto.TypePromptSteer, "run-1", input) // An ack transport failure must not cause another native invocation. @@ -94,7 +90,7 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls := 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { @@ -105,11 +101,9 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { return nil }, }, nil - }) + })) ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run-1", proto.PromptRequestPayload{AgentKind: "codex"}) input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("extra")} env := mustEnv(t, proto.TypePromptSteer, "run-1", input) for _, expected := range []string{"not_ready", ""} { @@ -132,16 +126,14 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-1", nil)); err != nil { t.Fatal(err) } - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "cancel cleanup") + waitFor(t, func() bool { return hasFrame(h.sender, proto.TypeDone, "run-1") }, "cancel cleanup") if err := handleSteeringAndWait(t, h, env); err != nil { t.Fatal(err) } if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "run_inactive" { t.Fatalf("inactive: %+v", ack) } - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, "run-2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) session := <-h.gotSess defer close(session.out) if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { @@ -172,7 +164,7 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { defer h.router.Shutdown(context.Background()) entered, release := make(chan struct{}), make(chan struct{}) defer close(release) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { @@ -181,12 +173,10 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { return agent.ErrSteeringRejected }, }, nil - }) + })) ctx := context.Background() for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "fake_alpha"}} { - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, run.id, proto.PromptRequestPayload{AgentKind: run.engine})); err != nil { - t.Fatal(err) - } + startRun(t, h.router, h.sender, run.id, proto.PromptRequestPayload{AgentKind: run.engine}) } other := <-h.gotSess other.closeOutOnCancel = true @@ -206,9 +196,7 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { t.Fatal(err) } - if other.cancels() != 1 { - t.Fatal("other run cancellation blocked") - } + waitFor(t, func() bool { return other.cancels() == 1 }, "other run cancellation") } func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { @@ -235,7 +223,7 @@ func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) calls := 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &steeringSession{ fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, steer: func(context.Context, proto.PromptSteerPayload) error { @@ -243,11 +231,8 @@ func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { return nil }, }, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } + })) + startRun(t, h.router, h.sender, "run-1", proto.PromptRequestPayload{AgentKind: "codex"}) for i := range 257 { input := proto.PromptSteerPayload{InputID: fmt.Sprintf("input-%d", i), Input: proto.TextInput("extra")} if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { diff --git a/apps/daemon/internal/dispatch/suspend.go b/apps/daemon/internal/dispatch/suspend.go index 003c9ce2f..a5e024746 100644 --- a/apps/daemon/internal/dispatch/suspend.go +++ b/apps/daemon/internal/dispatch/suspend.go @@ -41,14 +41,6 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa return ErrRouterBusy } } - for _, states := range r.idle { - for state := range states { - if state.environmentID != request.EnvironmentID || state.steerBusy || len(state.pendingIDs) != 0 || len(state.pendingAsks) != 0 { - r.mu.Unlock() - return ErrRouterBusy - } - } - } for _, owner := range r.executors { if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil || owner.environmentID != request.EnvironmentID { r.mu.Unlock() @@ -61,14 +53,6 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa p.timer.Stop() } } - for _, states := range r.idle { - for state := range states { - state.idleLease++ - if state.idleTimer != nil { - state.idleTimer.Stop() - } - } - } for _, owner := range r.executors { owner.idleLease++ if owner.timer != nil { @@ -104,10 +88,5 @@ func (r *Router) Resume(request proto.EnvironmentSuspendPayload, sender Sender) for _, owner := range r.executors { r.scheduleExecutorIdleLocked(owner) } - for _, states := range r.idle { - for state := range states { - r.scheduleIdleLocked(state) - } - } return nil } diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index 64049b954..b713218ae 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -15,11 +15,13 @@ type suspendSender func(context.Context, proto.Envelope) error func (s suspendSender) Send(ctx context.Context, env proto.Envelope) error { return s(ctx, env) } -type suspendedSession struct{ cancelled atomic.Int32 } +type suspendedExecutor struct{ closed atomic.Int32 } -func (s *suspendedSession) CancellationOutcome() proto.DonePayload { return proto.DonePayload{} } +func (e *suspendedExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { + return nil, errors.New("suspended executor must not start a Turn") +} -func (s *suspendedSession) Cancel(context.Context) error { s.cancelled.Add(1); return nil } +func (e *suspendedExecutor) Close(context.Context) error { e.closed.Add(1); return nil } func suspensionRouter(t *testing.T, sender Sender) *Router { t.Helper() @@ -37,7 +39,7 @@ func suspensionRouter(t *testing.T, sender Sender) *Router { func TestQuiesceRejectsEveryUnsettledResource(t *testing.T) { cases := map[string]func(*Router){ - "active": func(r *Router) { r.sessions["run"] = &sessionState{ctxCancel: func() {}} }, + "active": func(r *Router) { r.sessions["run"] = &sessionState{} }, "preparing": func(r *Router) { r.preparations["p"] = &preparationState{owns: true} }, "receipt": func(r *Router) { r.preparations["p"] = &preparationState{busy: true} }, "read": func(r *Router) { r.workspaceReads = map[string]struct{}{"read": {}} }, @@ -90,7 +92,7 @@ func TestQuiesceDrainsPendingReceiptAndFencesConcurrentAdmission(t *testing.T) { } admitted := make(chan error, 1) go func() { - admitted <- r.Handle(context.Background(), proto.Envelope{Type: proto.TypePromptRequest, ID: "late"}) + admitted <- r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "late"}) }() select { case err := <-quiet: @@ -106,23 +108,23 @@ func TestQuiesceDrainsPendingReceiptAndFencesConcurrentAdmission(t *testing.T) { } } -func TestQuiescePreservesIdleOwnerAgainstExpiredTimerAndRequiresExactResume(t *testing.T) { +func TestQuiescePreservesIdleExecutorAgainstExpiredTimerAndRequiresExactResume(t *testing.T) { sender := suspendSender(func(context.Context, proto.Envelope) error { return nil }) r := suspensionRouter(t, sender) - session := &suspendedSession{} - state := &sessionState{runID: "run", environmentID: "env", stateKey: "state", session: session, ctxCancel: func() {}, retain: true} + native := &suspendedExecutor{} + owner := &executorState{id: "executor", sessionID: "session", environmentID: "env", native: native, cancel: func() {}} r.mu.Lock() - r.idle["state"] = map[*sessionState]struct{}{state: {}} - r.scheduleIdleLocked(state) - oldLease := state.idleLease + r.executors[owner.sessionID] = owner + r.scheduleExecutorIdleLocked(owner) + oldLease := owner.idleLease r.mu.Unlock() request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"} if err := r.Quiesce(context.Background(), request); err != nil { t.Fatal(err) } - r.expireIdle(state, oldLease) - if session.cancelled.Load() != 0 { - t.Fatal("pre-snapshot timer killed retained owner") + r.expireIdleExecutor(owner, oldLease) + if native.closed.Load() != 0 { + t.Fatal("pre-snapshot timer closed retained owner") } wrong := request wrong.SuspendID = "obsolete" @@ -133,10 +135,10 @@ func TestQuiescePreservesIdleOwnerAgainstExpiredTimerAndRequiresExactResume(t *t t.Fatal(err) } r.mu.Lock() - newLease := state.idleLease + newLease := owner.idleLease r.mu.Unlock() - r.expireIdle(state, newLease) - if session.cancelled.Load() != 1 { + r.expireIdleExecutor(owner, newLease) + if native.closed.Load() != 1 { t.Fatal("normal idle expiration was not restored") } } @@ -164,7 +166,7 @@ func TestQuiesceDrainDeadlineCannotReopenAdmission(t *testing.T) { if err := r.Quiesce(ctx, proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"}); !errors.Is(err, context.Canceled) { t.Fatalf("quiesce=%v", err) } - if err := r.Handle(context.Background(), proto.Envelope{Type: proto.TypePromptRequest, ID: "late"}); !errors.Is(err, ErrRouterQuiesced) { + if err := r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "late"}); !errors.Is(err, ErrRouterQuiesced) { t.Fatalf("deadline reopened admission: %v", err) } r.shutdownWG.Done() diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index 38a613278..0a5a8882c 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -58,7 +58,7 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e } return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("write_capacity")) } - if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.workspaceReads) != 0 { + if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.workspaceReads) != 0 { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index e747ea41e..5ee7fbf31 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -69,8 +69,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, nil } if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || - r.DisableExecutionEnvironment || - r.ConversationID != "" || !r.StrictResume { + r.DisableExecutionEnvironment { return r, errors.New("request does not match the dedicated local Environment") } if !r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "" || len(r.LocalEnvironment.AllowedDomains) > 0 { diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index 2beb90e46..6d5bc80a5 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -26,7 +26,7 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { } b.networkAccess = "disabled" b.capabilityRoot = t.TempDir() - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session} } func TestBindingRejectsScopeOverrides(t *testing.T) { @@ -40,9 +40,8 @@ func TestBindingRejectsScopeOverrides(t *testing.T) { "other Environment": func(r *proto.PromptRequestPayload) { r.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString()} }, - "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "agents-api-" + uuid.NewString() }, - "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, - "non-strict resume": func(r *proto.PromptRequestPayload) { r.StrictResume = false }, + "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "agents-api-" + uuid.NewString() }, + "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, } { t.Run(name, func(t *testing.T) { r := valid @@ -96,14 +95,6 @@ func TestBindingPrepareRejectsOtherWorkspaceRoot(t *testing.T) { } } -func TestBindingAllowsRetainedExecutor(t *testing.T) { - b, req := testBinding(t) - req.ReleaseOnCompletion = false - if _, err := b.Configure(req); err != nil { - t.Fatal(err) - } -} - func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { b, _ := testBinding(t) for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_process.go b/apps/daemon/internal/localworkspace/runtime_initialization_process.go index e98dc070b..68174485d 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_process.go @@ -110,7 +110,7 @@ func runInitializationProcess(ctx context.Context, binary string, args []string, return &InitializationFailure{} } process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, - Dir: directory, Env: env, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + Dir: directory, Env: env, KillTimeout: 250 * time.Millisecond}) if err != nil { return ErrInitializationUnconfirmed } diff --git a/apps/daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go index 50f01c100..6c835ae3d 100644 --- a/apps/daemon/internal/paths/paths.go +++ b/apps/daemon/internal/paths/paths.go @@ -1,6 +1,6 @@ // Package paths resolves on-disk locations for oac-daemon state under -// ~/.oac/daemon// — one subdir per profile so several -// devices on one host never collide. +// ~/.oac/daemon// — one subdir per profile so "test" +// and "prod" servers can be connected in parallel without colliding. // // Files are 0o600, parent dir 0o700. These functions only resolve // paths — callers do the I/O. @@ -50,7 +50,7 @@ func Root() (string, error) { return filepath.Join(home, ".oac"), nil } -// ProfileDir returns ~/.oac/daemon/. It is not created. +// ProfileDir returns ~/.oac/daemon/. It is not created here. func ProfileDir(profile string) (string, error) { if err := ValidateProfile(profile); err != nil { return "", err diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go index 8eedea2f5..e39411074 100644 --- a/apps/daemon/internal/transport/ws.go +++ b/apps/daemon/internal/transport/ws.go @@ -24,7 +24,7 @@ type DialOptions struct { // WSURL is the absolute ws://... or wss://... URL. WSURL string - // DeviceID is the runtime row id issued with the credential. Sent as + // DeviceID is the runtime row id from the daemon credential. Sent as // device_id query param; the gateway uses it as the session key. DeviceID string diff --git a/apps/daemon/internal/transport/ws_test.go b/apps/daemon/internal/transport/ws_test.go index 13d5e2390..163de36cb 100644 --- a/apps/daemon/internal/transport/ws_test.go +++ b/apps/daemon/internal/transport/ws_test.go @@ -186,9 +186,9 @@ func TestRecvDeliversServerSentFrames(t *testing.T) { } defer conn.Close() - // Server pushes a prompt_request down to the daemon. + // Server pushes a prompt_cancel down to the daemon. serverConn := <-gw.connCh - pushed, _ := proto.NewEnvelope("prompt_request", "run_abc", map[string]string{"prompt": "hi"}) + pushed, _ := proto.NewEnvelope(proto.TypePromptCancel, "run_abc", proto.PromptCancelPayload{DeliveryID: "cancel"}) raw, _ := json.Marshal(pushed) if err := serverConn.WriteMessage(websocket.TextMessage, raw); err != nil { t.Fatalf("server write: %v", err) @@ -196,8 +196,8 @@ func TestRecvDeliversServerSentFrames(t *testing.T) { select { case env := <-conn.Recv(): - if env.Type != "prompt_request" || env.ID != "run_abc" { - t.Errorf("received %+v, want prompt_request/run_abc", env) + if env.Type != proto.TypePromptCancel || env.ID != "run_abc" { + t.Errorf("received %+v, want prompt_cancel/run_abc", env) } case <-time.After(2 * time.Second): t.Fatal("never received the server-pushed frame") diff --git a/apps/daemon/internal/wireconformance/wire_test.go b/apps/daemon/internal/wireconformance/wire_test.go index 328737c23..9af45ecba 100644 --- a/apps/daemon/internal/wireconformance/wire_test.go +++ b/apps/daemon/internal/wireconformance/wire_test.go @@ -159,7 +159,7 @@ func connectRuntime(t *testing.T, peer *corePeer, setupErr error) *runtimeSide { kinds := agent.NewRegistry() kinds.RegisterKind(proto.SupportedAgentKind{Kind: prototest.HarnessKind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("prepared execution must not use prompt_request") + return nil, errors.New("prepared execution must not use the direct Session factory") }) kinds.RegisterExecutor(prototest.HarnessKind, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return rt.executor, setupErr diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 8ecc4dbaf..3252f0bdb 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -37,10 +37,10 @@ type harness struct { } func (h *harness) prepare(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + if req.RunID != "" || len(req.Input) != 0 { return nil, errors.New("preparation submitted fixture input") } - if !req.StrictResume || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { + if !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { return nil, errors.New("unsupported fixture operation") } h.mu.Lock() diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index d5a1a90ab..41149ef97 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -14,7 +14,7 @@ const browserStorage = (page: Page) => page.evaluate(() => JSON.stringify({ ...w test("signs in with the Core key, keeps it out of the browser, and signs out and back in", async ({ page, request }) => { await resetFixture(request, "login"); await recordV1Requests(page); - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.language", "en")); await page.goto("/"); await expect(page.getByRole("heading", { name: "Sign in to OpenAgentCore" })).toBeVisible(); @@ -47,7 +47,7 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and test("opens a fresh install on the Overview's Getting started: a project and its key shown once, then the step is done", async ({ page, request }) => { await resetFixture(request, "login", { fresh: true }); await recordV1Requests(page); - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.language", "en")); await page.goto("/"); await signIn(page, FIXTURE_CORE_KEY); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 78000a332..1e50de4db 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -40,7 +40,7 @@ export async function openConsole(page: Page, request: APIRequestContext, hash = await resetFixture(request, "authenticated", options); await recordV1Requests(page); await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]); - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.language", "en")); await page.goto(`/#${hash}`); } diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 721bcf6a0..f404a5de6 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -35,12 +35,12 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same const field = add.getByLabel("One-time enrollment command", { exact: true }); await expect(field).toHaveValue(/enroll_fixture_/); // The token goes on stdin to the checked installer, run with sudo unless the shell is root. - await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/); - await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); + await expect(field).toHaveValue(/^ \(umask 077\n/); + await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); // It downloads from, and names as its source, the public URL, not the loopback address this browser uses. await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /); await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /); - await expect(add.getByText("If the command is interrupted or the download stalls, run the same command again: the download resumes.")).toBeVisible(); + await expect(add.getByText("If the command is interrupted or the download stalls, run it again: unfinished downloads restart, and verified files are reused.")).toBeVisible(); await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/); const progress = add.getByRole("status", { name: "Registration progress" }); await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/); @@ -155,7 +155,7 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); // Removing the Core record leaves the system service for root/sudo to uninstall on its host. const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); - await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$installer" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); await expect(cleanup.getByText("Installed without sudo?")).toHaveCount(0); await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveCount(0); // Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone. diff --git a/apps/web/e2e/overview-readiness.spec.ts b/apps/web/e2e/overview-readiness.spec.ts index 5f22542d5..540a37405 100644 --- a/apps/web/e2e/overview-readiness.spec.ts +++ b/apps/web/e2e/overview-readiness.spec.ts @@ -164,7 +164,7 @@ test("failed summary and Session refreshes preserve their previous evidence unti }); test("installation failure cannot complete onboarding and its retry reveals local-only blockage", async ({ page, request }) => { - await page.addInitScript(() => window.localStorage.setItem("agents-core-web.getting-started.7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", "open")); + await page.addInitScript(() => window.localStorage.setItem("oac-web.getting-started.7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", "open")); await page.route("**/core/v1/installation", (route) => route.fulfill(reject)); await openConsole(page, request, "overview", { installation: "local" }); const step = page.locator(".getting-started-step").first(); diff --git a/apps/web/index.html b/apps/web/index.html index b65773249..4bd5a5edd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -13,7 +13,7 @@ OpenAgentCore