From 4f42bb6b06191f691599c363c34cb1bf3803b92c Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:33:40 +0800 Subject: [PATCH 1/6] feat(agents): add unified voice api mode --- cli/azd/extensions/azure.ai.agents/README.md | 28 +++++ .../internal/pkg/agents/agent_api/models.go | 29 +++++ .../pkg/agents/agent_api/operations.go | 87 ++++++++----- .../pkg/agents/agent_api/operations_test.go | 44 +++++++ .../internal/pkg/agents/agent_yaml/map.go | 70 ++++++++++- .../pkg/agents/agent_yaml/map_voice_test.go | 50 ++++++++ .../internal/project/service_target_agent.go | 114 +++++++++++++++--- .../project/service_target_agent_test.go | 38 ++++++ 8 files changed, 412 insertions(+), 48 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 80df72db3ac..5425af096d5 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -133,6 +133,34 @@ Details: > the other inline agent properties such as `codeConfiguration` and > `environmentVariables`. +## Prompt voice agent API mode + +Prompt voice agents (`kind: prompt-voice`) use the legacy `/voice_agents` API by +default while the unified `/agents` voice API rolls out across regions. To run +regression tests against the unified API, set `AZURE_VOICE_AGENT_API` before +`azd deploy`: + +```bash +# Default: legacy /voice_agents API +azd env set AZURE_VOICE_AGENT_API legacy + +# Unified /agents API using the current object-shaped audio.output.voice payload +azd env set AZURE_VOICE_AGENT_API unified + +# Unified /agents API using the TiP/spec flat audio.output.voice payload +azd env set AZURE_VOICE_AGENT_API unified-flat +``` + +Details: + +- `legacy` remains the default and preserves existing behavior. +- `unified` and `unified-flat` create voice agents through `/agents`; repeat + deploys update the existing agent and create a new version through + `/agents/{name}` when the agent name is already present in the azd environment. +- `unified-flat` is intended for TiP/new-service validation. Non-TiP regions may + still require `legacy` or `unified` until the flat output shape is fully + rolled out. + ## Session idle timeout A hosted agent's runtime session sandbox is suspended by Foundry after a period diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index a276ef6559c..96b33806e95 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -350,12 +350,28 @@ type VoiceOutputConfig struct { Voice *VoiceConfig `json:"voice,omitempty"` } +// VoiceOutputConfigFlat is the newer Voice Live output shape used by the +// unified /agents voice API in TiP. Older regions still accept/return the +// object-shaped VoiceOutputConfig above. +type VoiceOutputConfigFlat struct { + Format *VoiceAudioFormat `json:"format,omitempty"` + Voice string `json:"voice,omitempty"` + VoiceType string `json:"voice_type,omitempty"` + VoiceLocale string `json:"voice_locale,omitempty"` +} + // VoiceAudioConfig bundles the input and output audio configuration. type VoiceAudioConfig struct { Input *VoiceInputConfig `json:"input,omitempty"` Output *VoiceOutputConfig `json:"output,omitempty"` } +// VoiceAudioConfigFlat bundles voice audio config with the flat output shape. +type VoiceAudioConfigFlat struct { + Input *VoiceInputConfig `json:"input,omitempty"` + Output *VoiceOutputConfigFlat `json:"output,omitempty"` +} + // VoiceAgentDefinition is the data-plane definition body POSTed to the // /voice_agents collection for a declarative (managed) voice agent. Its Kind // is always AgentKindVoice ("voice"). @@ -369,6 +385,19 @@ type VoiceAgentDefinition struct { Store *bool `json:"store,omitempty"` } +// VoiceAgentDefinitionFlat is the voice definition shape aligned with the +// unified /agents TiP API, where audio.output.voice is a string and the voice +// provider details are sibling fields. +type VoiceAgentDefinitionFlat struct { + AgentDefinition + ModelType VoiceModelType `json:"model_type"` + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Audio *VoiceAudioConfigFlat `json:"audio,omitempty"` + OutputModalities []string `json:"output_modalities,omitempty"` + Store *bool `json:"store,omitempty"` +} + // CreateAgentVersionRequest represents a request to create an agent version type CreateAgentVersionRequest struct { Description *string `json:"description,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index c94ebc69294..46c0d9d5e44 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -167,47 +167,24 @@ func (c *AgentClient) CreateAgent(ctx context.Context, request *CreateAgentReque // header while voice agents remain a preview capability. const voiceAgentsPreviewFeature = "VoiceAgents=V1Preview" -// CreateVoiceAgent creates a new declarative (managed) voice agent. -// -// Voice agents live in a separate data-plane collection (/voice_agents), distinct -// from the /agents collection used by hosted/workflow agents. The request -// Definition must be a *VoiceAgentDefinition (service kind "voice"). -// -// overriddenHost, when non-empty, is sent as the x-ms-overridden-host header. -// This routes the request directly to the regional Hyena data-plane host, -// bypassing the public Foundry APIM (whose voice route may not yet be rolled -// out). Pass "" to use the default endpoint routing. -// -// Redeploy semantics: the voice data-plane exposes create-only POST /voice_agents -// with no version/upsert model (unlike hosted agents, which mint a new -// agent-version per deploy). A second `azd deploy` of the same voice service -// therefore re-POSTs with the same name and the service rejects it with a -// non-success status, which this method surfaces as a deploy error rather than -// silently overwriting the existing agent. Idempotent redeploy/update is tracked -// as a follow-up (see the PR "Follow-ups" section); until the service adds an -// update route, redeploy requires deleting the existing voice agent first. -func (c *AgentClient) CreateVoiceAgent( +func (c *AgentClient) doVoiceJSONAgentRequest( ctx context.Context, - request *CreateAgentRequest, - apiVersion string, + method string, + url string, + request any, overriddenHost string, ) (*AgentObject, error) { - url := fmt.Sprintf("%s/voice_agents?api-version=%s", c.endpoint, apiVersion) - payload, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } - req, err := runtime.NewRequest(ctx, http.MethodPost, url) + req, err := runtime.NewRequest(ctx, method, url) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - // Voice agents are a preview feature; the service rejects the request with - // 403 preview_feature_required unless this opt-in header is present. req.Raw().Header.Set("Foundry-Features", voiceAgentsPreviewFeature) - if overriddenHost != "" { req.Raw().Header.Set("x-ms-overridden-host", overriddenHost) } @@ -239,6 +216,60 @@ func (c *AgentClient) CreateVoiceAgent( return &agent, nil } +// CreateVoiceAgent creates a new declarative (managed) voice agent. +// +// Voice agents live in a separate data-plane collection (/voice_agents), distinct +// from the /agents collection used by hosted/workflow agents. The request +// Definition must be a *VoiceAgentDefinition (service kind "voice"). +// +// overriddenHost, when non-empty, is sent as the x-ms-overridden-host header. +// This routes the request directly to the regional Hyena data-plane host, +// bypassing the public Foundry APIM (whose voice route may not yet be rolled +// out). Pass "" to use the default endpoint routing. +// +// Redeploy semantics: the voice data-plane exposes create-only POST /voice_agents +// with no version/upsert model (unlike hosted agents, which mint a new +// agent-version per deploy). A second `azd deploy` of the same voice service +// therefore re-POSTs with the same name and the service rejects it with a +// non-success status, which this method surfaces as a deploy error rather than +// silently overwriting the existing agent. Idempotent redeploy/update is tracked +// as a follow-up (see the PR "Follow-ups" section); until the service adds an +// update route, redeploy requires deleting the existing voice agent first. +func (c *AgentClient) CreateVoiceAgent( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, + overriddenHost string, +) (*AgentObject, error) { + url := fmt.Sprintf("%s/voice_agents?api-version=%s", c.endpoint, apiVersion) + return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost) +} + +// CreateVoiceAgentUnified creates a voice agent through the unified /agents +// collection. This path is opt-in while regional rollout is still in progress. +func (c *AgentClient) CreateVoiceAgentUnified( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, + overriddenHost string, +) (*AgentObject, error) { + url := fmt.Sprintf("%s/agents?api-version=%s", c.endpoint, apiVersion) + return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost) +} + +// UpdateVoiceAgentUnified creates a new version for an existing voice agent +// through the unified /agents/{name} endpoint. +func (c *AgentClient) UpdateVoiceAgentUnified( + ctx context.Context, + agentName string, + request *UpdateAgentRequest, + apiVersion string, + overriddenHost string, +) (*AgentObject, error) { + url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion) + return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost) +} + // UpdateAgent updates an existing agent func (c *AgentClient) UpdateAgent(ctx context.Context, agentName string, request *UpdateAgentRequest, apiVersion string) (*AgentObject, error) { url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index 6eb2effae43..cf4d9a116b3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -948,6 +948,50 @@ func TestCreateVoiceAgent_PostsToVoiceCollectionWithPreviewHeader(t *testing.T) require.Contains(t, string(reqBody), `"name":"my-voice"`) } +func TestCreateVoiceAgentUnified_PostsToAgentsWithPreviewHeader(t *testing.T) { + body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{}}}` + client, transport := newCaptureClient(http.StatusOK, body) + + agent, err := client.CreateVoiceAgentUnified( + t.Context(), + &CreateAgentRequest{Name: "my-voice"}, + AgentEndpointAPIVersion, + "", + ) + + require.NoError(t, err) + require.Equal(t, "my-voice", agent.Name) + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Equal(t, http.MethodPost, req.Method) + require.Equal(t, "/api/projects/proj/agents", req.URL.Path) + require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version")) + require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) +} + +func TestUpdateVoiceAgentUnified_PostsToNamedAgentWithPreviewHeader(t *testing.T) { + body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{"version":"2"}}}` + client, transport := newCaptureClient(http.StatusOK, body) + + agent, err := client.UpdateVoiceAgentUnified( + t.Context(), + "my-voice", + &UpdateAgentRequest{}, + AgentEndpointAPIVersion, + "regional.hyena.example.com", + ) + + require.NoError(t, err) + require.Equal(t, "my-voice", agent.Name) + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Equal(t, http.MethodPost, req.Method) + require.Equal(t, "/api/projects/proj/agents/my-voice", req.URL.Path) + require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version")) + require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) + require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host")) +} + func TestCreateVoiceAgent_SetsOverriddenHostHeader(t *testing.T) { client, transport := newCaptureClient(http.StatusCreated, `{"name":"my-voice","versions":{"latest":{}}}`) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 8a385d27632..0404762cb43 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -553,10 +553,41 @@ func buildVoiceConfig(name string) *agent_api.VoiceConfig { return &agent_api.VoiceConfig{Type: "azure_standard", Name: trimmed} } +func flatVoiceType(voice *agent_api.VoiceConfig) string { + if voice == nil { + return "" + } + if voice.Type == "azure_standard" { + return "azure-standard" + } + return voice.Type +} + +func flatVoiceLocale(voice *agent_api.VoiceConfig) string { + if voice == nil || voice.Name == "" || isOpenAIVoice(voice.Name) { + return "" + } + parts := strings.SplitN(voice.Name, "-", 3) + if len(parts) < 2 { + return "" + } + return parts[0] + "-" + parts[1] +} + // CreateVoiceAgentAPIRequest builds a CreateAgentRequest for a declarative // voice agent. It translates the authoring kind "prompt-voice" into the // data-plane service kind "voice" and defaults the audio pipeline. func CreateVoiceAgentAPIRequest(voiceAgent VoiceAgent) (*agent_api.CreateAgentRequest, error) { + return createVoiceAgentAPIRequest(voiceAgent, false) +} + +// CreateVoiceAgentAPIRequestFlat builds a CreateAgentRequest using the newer +// TiP/unified API flat output voice shape. +func CreateVoiceAgentAPIRequestFlat(voiceAgent VoiceAgent) (*agent_api.CreateAgentRequest, error) { + return createVoiceAgentAPIRequest(voiceAgent, true) +} + +func createVoiceAgentAPIRequest(voiceAgent VoiceAgent, flatOutput bool) (*agent_api.CreateAgentRequest, error) { modelID := "" if voiceAgent.Model != nil { modelID = strings.TrimSpace(voiceAgent.Model.Id) @@ -590,6 +621,37 @@ func CreateVoiceAgentAPIRequest(voiceAgent VoiceAgent) (*agent_api.CreateAgentRe Rate: defaultVoiceAudioRate, } + input := &agent_api.VoiceInputConfig{ + Format: audioFormat, + TurnDetection: &agent_api.VoiceTurnDetection{Type: defaultVoiceTurnDetectionType}, + Transcription: &agent_api.VoiceTranscription{Model: defaultVoiceInputTranscriptionModel}, + } + voiceConfig := buildVoiceConfig(voiceName) + if flatOutput { + voiceDef := agent_api.VoiceAgentDefinitionFlat{ + AgentDefinition: agent_api.AgentDefinition{ + // Translate authoring kind prompt-voice -> service kind voice. + Kind: agent_api.AgentKindVoice, + }, + ModelType: modelType, + Model: modelID, + Instructions: instructions, + Audio: &agent_api.VoiceAudioConfigFlat{ + Input: input, + Output: &agent_api.VoiceOutputConfigFlat{ + Format: audioFormat, + Voice: voiceConfig.Name, + VoiceType: flatVoiceType(voiceConfig), + VoiceLocale: flatVoiceLocale(voiceConfig), + }, + }, + OutputModalities: []string{"audio"}, + Store: voiceAgent.Store, + } + + return createAgentAPIRequest(voiceAgent.AgentDefinition, voiceDef, nil, nil) + } + voiceDef := agent_api.VoiceAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ // Translate authoring kind prompt-voice -> service kind voice. @@ -599,14 +661,10 @@ func CreateVoiceAgentAPIRequest(voiceAgent VoiceAgent) (*agent_api.CreateAgentRe Model: modelID, Instructions: instructions, Audio: &agent_api.VoiceAudioConfig{ - Input: &agent_api.VoiceInputConfig{ - Format: audioFormat, - TurnDetection: &agent_api.VoiceTurnDetection{Type: defaultVoiceTurnDetectionType}, - Transcription: &agent_api.VoiceTranscription{Model: defaultVoiceInputTranscriptionModel}, - }, + Input: input, Output: &agent_api.VoiceOutputConfig{ Format: audioFormat, - Voice: buildVoiceConfig(voiceName), + Voice: voiceConfig, }, }, OutputModalities: []string{"audio"}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go index 137e9b6e847..2ce594e1fbf 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go @@ -172,6 +172,56 @@ func TestCreateVoiceAgentAPIRequest_Overrides(t *testing.T) { } } +func TestCreateVoiceAgentAPIRequestFlat_UsesFlatOutputShape(t *testing.T) { + t.Parallel() + voice := "alloy" + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "voice-flat"}, + Model: &Model{Id: "gpt-realtime"}, + Voice: &voice, + } + + req, err := CreateVoiceAgentAPIRequestFlat(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + def := req.Definition.(agent_api.VoiceAgentDefinitionFlat) + if def.Audio.Output.Voice != "alloy" { + t.Errorf("Voice = %q, want alloy", def.Audio.Output.Voice) + } + if def.Audio.Output.VoiceType != "openai" { + t.Errorf("VoiceType = %q, want openai", def.Audio.Output.VoiceType) + } + if def.Audio.Output.VoiceLocale != "" { + t.Errorf("VoiceLocale = %q, want empty", def.Audio.Output.VoiceLocale) + } +} + +func TestCreateVoiceAgentAPIRequestFlat_AzureVoiceLocale(t *testing.T) { + t.Parallel() + voice := "en-US-Ava:DragonHDLatestNeural" + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "voice-flat"}, + Model: &Model{Id: "gpt-realtime"}, + Voice: &voice, + } + + req, err := CreateVoiceAgentAPIRequestFlat(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + def := req.Definition.(agent_api.VoiceAgentDefinitionFlat) + if def.Audio.Output.Voice != voice { + t.Errorf("Voice = %q, want %q", def.Audio.Output.Voice, voice) + } + if def.Audio.Output.VoiceType != "azure-standard" { + t.Errorf("VoiceType = %q, want azure-standard", def.Audio.Output.VoiceType) + } + if def.Audio.Output.VoiceLocale != "en-US" { + t.Errorf("VoiceLocale = %q, want en-US", def.Audio.Output.VoiceLocale) + } +} + // TestCreateVoiceAgentAPIRequest_ExplicitManaged verifies that explicitly // setting model_type: managed is accepted (idempotent with the default). func TestCreateVoiceAgentAPIRequest_ExplicitManaged(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 3f4039392a2..676b1ecfcbe 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -2151,12 +2151,50 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( //nolint:gosec // env var key name, not a credential const voiceOverriddenHostEnvKey = "AZURE_VOICE_OVERRIDDEN_HOST" +// voiceAgentAPIEnvKey controls which voice deployment API azd uses. It defaults +// to the legacy /voice_agents path while the unified API rolls out regionally. +// Supported values: +// - legacy: POST /voice_agents with object-shaped audio.output.voice +// - unified: POST /agents or /agents/{name} with object-shaped audio.output.voice +// - unified-flat: POST /agents or /agents/{name} with flat audio.output.voice +// +//nolint:gosec // env var key name, not a credential +const voiceAgentAPIEnvKey = "AZURE_VOICE_AGENT_API" + +type voiceAgentAPIMode string + +const ( + voiceAgentAPIModeLegacy voiceAgentAPIMode = "legacy" + voiceAgentAPIModeUnified voiceAgentAPIMode = "unified" + voiceAgentAPIModeUnifiedFlat voiceAgentAPIMode = "unified-flat" +) + +func resolveVoiceAgentAPIMode(azdEnv map[string]string) (voiceAgentAPIMode, error) { + mode := strings.TrimSpace(azdEnv[voiceAgentAPIEnvKey]) + if mode == "" { + mode = strings.TrimSpace(os.Getenv(voiceAgentAPIEnvKey)) + } + mode = strings.ToLower(strings.ReplaceAll(mode, "_", "-")) + if mode == "" { + return voiceAgentAPIModeLegacy, nil + } + switch voiceAgentAPIMode(mode) { + case voiceAgentAPIModeLegacy, voiceAgentAPIModeUnified, voiceAgentAPIModeUnifiedFlat: + return voiceAgentAPIMode(mode), nil + default: + return "", fmt.Errorf( + "%s must be one of %q, %q, or %q", + voiceAgentAPIEnvKey, voiceAgentAPIModeLegacy, voiceAgentAPIModeUnified, voiceAgentAPIModeUnifiedFlat, + ) + } +} + // deployVoiceAgent deploys a declarative (managed) voice agent (kind: -// prompt-voice) to the Foundry service. Unlike hosted agents, voice agents are -// created synchronously via a single POST to /voice_agents that returns the -// created AgentObject directly — there is no container build, no agent-version -// object, and no active-state polling. This method is intentionally isolated -// from the container deploy path so the two contracts never entangle. +// prompt-voice) to the Foundry service. The legacy /voice_agents API remains +// the default while unified /agents rolls out regionally; AZURE_VOICE_AGENT_API +// can opt into unified modes for regression and TiP validation. This method is +// intentionally isolated from the container deploy path so the two contracts +// never entangle. func (p *AgentServiceTargetProvider) deployVoiceAgent( ctx context.Context, serviceConfig *azdext.ServiceConfig, @@ -2166,7 +2204,21 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( ) (*azdext.ServiceDeployResult, error) { progress("Deploying voice agent") - request, err := agent_yaml.CreateVoiceAgentAPIRequest(va) + apiMode, err := resolveVoiceAgentAPIMode(azdEnv) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + fmt.Sprintf("set %s to legacy, unified, or unified-flat", voiceAgentAPIEnvKey), + ) + } + + var request *agent_api.CreateAgentRequest + if apiMode == voiceAgentAPIModeUnifiedFlat { + request, err = agent_yaml.CreateVoiceAgentAPIRequestFlat(va) + } else { + request, err = agent_yaml.CreateVoiceAgentAPIRequest(va) + } if err != nil { return nil, exterrors.Validation( exterrors.CodeInvalidAgentManifest, @@ -2187,22 +2239,19 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( agentClient := agent_api.NewAgentClient(projectEndpoint, p.credential) - progress("Creating voice agent") - agentObject, err := agentClient.CreateVoiceAgent( - ctx, request, agent_api.AgentEndpointAPIVersion, azdEnv[voiceOverriddenHostEnvKey], + serviceKey := p.getServiceKey(serviceConfig.Name) + agentObject, err := p.deployVoiceAgentWithMode( + ctx, agentClient, request, apiMode, serviceKey, azdEnv, progress, ) if err != nil { return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) } - fmt.Fprintf(os.Stderr, "Voice agent '%s' created successfully!\n", agentObject.Name) + fmt.Fprintf(os.Stderr, "Voice agent '%s' deployed successfully!\n", agentObject.Name) // Persist NAME first and ENDPOINT last. ENDPOINT is used as the voice deploy // completion marker by other commands, so avoid writing it before NAME. - serviceKey := p.getServiceKey(serviceConfig.Name) - baseEndpoint := fmt.Sprintf( - "%s/voice_agents/%s", strings.TrimRight(projectEndpoint, "/"), agentObject.Name, - ) + baseEndpoint := voiceAgentEndpoint(projectEndpoint, agentObject.Name, apiMode) for _, envVar := range []struct{ key, value string }{ {fmt.Sprintf("AGENT_%s_NAME", serviceKey), agentObject.Name}, {fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), baseEndpoint}, @@ -2230,6 +2279,43 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( return &azdext.ServiceDeployResult{Artifacts: artifacts}, nil } +func (p *AgentServiceTargetProvider) deployVoiceAgentWithMode( + ctx context.Context, + agentClient *agent_api.AgentClient, + request *agent_api.CreateAgentRequest, + apiMode voiceAgentAPIMode, + serviceKey string, + azdEnv map[string]string, + progress azdext.ProgressReporter, +) (*agent_api.AgentObject, error) { + overriddenHost := azdEnv[voiceOverriddenHostEnvKey] + if apiMode == voiceAgentAPIModeLegacy { + progress("Creating voice agent using legacy API") + return agentClient.CreateVoiceAgent(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) + } + + if existingName := strings.TrimSpace(azdEnv[fmt.Sprintf("AGENT_%s_NAME", serviceKey)]); existingName != "" { + progress("Updating voice agent using unified API") + updateRequest := &agent_api.UpdateAgentRequest{ + CreateAgentVersionRequest: request.CreateAgentVersionRequest, + } + return agentClient.UpdateVoiceAgentUnified( + ctx, existingName, updateRequest, agent_api.AgentEndpointAPIVersion, overriddenHost, + ) + } + + progress("Creating voice agent using unified API") + return agentClient.CreateVoiceAgentUnified(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) +} + +func voiceAgentEndpoint(projectEndpoint string, agentName string, apiMode voiceAgentAPIMode) string { + trimmedEndpoint := strings.TrimRight(projectEndpoint, "/") + if apiMode == voiceAgentAPIModeLegacy { + return fmt.Sprintf("%s/voice_agents/%s", trimmedEndpoint, agentName) + } + return fmt.Sprintf("%s/agents/%s/endpoint/protocols/voice", trimmedEndpoint, agentName) +} + // packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, // and computes its SHA-256. Returns the temp file path and SHA-256 hex string. func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serviceConfig *azdext.ServiceConfig) (string, string, error) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 9e48effc3c0..4f2f230dafc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -668,6 +668,44 @@ func TestAdoptServiceConfigIgnoresNilAndKeepsResolvedState(t *testing.T) { require.False(t, provider.serviceConfigResolved) } +func TestResolveVoiceAgentAPIMode_DefaultsToLegacy(t *testing.T) { + t.Setenv(voiceAgentAPIEnvKey, "") + mode, err := resolveVoiceAgentAPIMode(map[string]string{}) + require.NoError(t, err) + require.Equal(t, voiceAgentAPIModeLegacy, mode) +} + +func TestResolveVoiceAgentAPIMode_EnvValues(t *testing.T) { + t.Setenv(voiceAgentAPIEnvKey, "unified_flat") + mode, err := resolveVoiceAgentAPIMode(map[string]string{}) + require.NoError(t, err) + require.Equal(t, voiceAgentAPIModeUnifiedFlat, mode) + + mode, err = resolveVoiceAgentAPIMode(map[string]string{voiceAgentAPIEnvKey: "unified"}) + require.NoError(t, err) + require.Equal(t, voiceAgentAPIModeUnified, mode) +} + +func TestResolveVoiceAgentAPIMode_Invalid(t *testing.T) { + _, err := resolveVoiceAgentAPIMode(map[string]string{voiceAgentAPIEnvKey: "future"}) + require.Error(t, err) + require.Contains(t, err.Error(), voiceAgentAPIEnvKey) +} + +func TestVoiceAgentEndpoint_ByMode(t *testing.T) { + projectEndpoint := "https://proj.services.ai.azure.com/api/projects/p/" + require.Equal( + t, + "https://proj.services.ai.azure.com/api/projects/p/voice_agents/my-agent", + voiceAgentEndpoint(projectEndpoint, "my-agent", voiceAgentAPIModeLegacy), + ) + require.Equal( + t, + "https://proj.services.ai.azure.com/api/projects/p/agents/my-agent/endpoint/protocols/voice", + voiceAgentEndpoint(projectEndpoint, "my-agent", voiceAgentAPIModeUnified), + ) +} + func createSymlinkOrSkip(t *testing.T, oldname, newname string) { t.Helper() From 27073e789f7ca71f63160c32b6b35cd0501cc915 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:15:42 +0800 Subject: [PATCH 2/6] docs(agents): clarify voice deploy routing comment --- .../internal/project/service_target_agent.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 676b1ecfcbe..d240f13545f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1376,13 +1376,11 @@ func (p *AgentServiceTargetProvider) Deploy( return nil, err } - // Voice agents (kind: prompt-voice) use a fundamentally different data-plane - // contract than hosted/workflow agents: a synchronous POST to /voice_agents - // that returns an AgentObject directly, with no version/polling model. Resolve - // the definition first — honoring the AGENT_DEFINITION_PATH override precedence - // so an override drives this dispatch just as it does the container path — and - // route voice to an isolated method so the container deploy path below stays - // byte-for-byte unchanged. + // Voice agents (kind: prompt-voice) use a different data-plane contract than + // hosted/workflow agents. Resolve the definition first — honoring the + // AGENT_DEFINITION_PATH override precedence so an override drives this dispatch + // just as it does the container path — and route voice to an isolated method so + // the container deploy path below stays byte-for-byte unchanged. if isVoice { return p.deployVoiceAgent(ctx, serviceConfig, voiceAgent, azdEnv, progress) } From 16e94ae2bac0d9af3b45b84c145612226e42a85f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:39:37 +0800 Subject: [PATCH 3/6] fix(agents): harden unified voice deploy --- cli/azd/extensions/azure.ai.agents/README.md | 9 ++-- .../pkg/agents/agent_api/operations.go | 43 +++++++++++++++ .../pkg/agents/agent_api/operations_test.go | 23 ++++++++ .../internal/project/service_target_agent.go | 52 +++++++++++++++---- .../project/service_target_agent_test.go | 14 ++++- 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 5425af096d5..6fecb7e17db 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -154,9 +154,12 @@ azd env set AZURE_VOICE_AGENT_API unified-flat Details: - `legacy` remains the default and preserves existing behavior. -- `unified` and `unified-flat` create voice agents through `/agents`; repeat - deploys update the existing agent and create a new version through - `/agents/{name}` when the agent name is already present in the azd environment. +- `unified` and `unified-flat` check `/agents/{name}` remotely before deploying: + `404` creates through `/agents`, while `200` updates through `/agents/{name}`. +- Unified modes write `AGENT__VERSION` and store the callable voice + WebSocket endpoint as `wss://.../agents/{name}/endpoint/protocols/voice?api-version=v1`. +- `legacy` clears any stale `AGENT__VERSION` value and preserves the + existing `/voice_agents/{name}` endpoint marker. - `unified-flat` is intended for TiP/new-service validation. Non-TiP regions may still require `legacy` or `unified` until the flat output shape is fully rolled out. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 46c0d9d5e44..fbd6f514ef3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -245,6 +245,49 @@ func (c *AgentClient) CreateVoiceAgent( return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost) } +// GetVoiceAgentUnified retrieves a voice agent through the unified /agents +// endpoint with the voice preview opt-in header. Use this instead of GetAgent +// when deciding whether to create or update a prompt voice agent. +func (c *AgentClient) GetVoiceAgentUnified( + ctx context.Context, + agentName string, + apiVersion string, + overriddenHost string, +) (*AgentObject, error) { + url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion) + req, err := runtime.NewRequest(ctx, http.MethodGet, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Foundry-Features", voiceAgentsPreviewFeature) + if overriddenHost != "" { + req.Raw().Header.Set("x-ms-overridden-host", overriddenHost) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &agent, nil +} + // CreateVoiceAgentUnified creates a voice agent through the unified /agents // collection. This path is opt-in while regional rollout is still in progress. func (c *AgentClient) CreateVoiceAgentUnified( diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index cf4d9a116b3..7aacd0d7ba5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -969,6 +969,29 @@ func TestCreateVoiceAgentUnified_PostsToAgentsWithPreviewHeader(t *testing.T) { require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) } +func TestGetVoiceAgentUnified_GetsNamedAgentWithPreviewHeader(t *testing.T) { + body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{"version":"3"}}}` + client, transport := newCaptureClient(http.StatusOK, body) + + agent, err := client.GetVoiceAgentUnified( + t.Context(), + "my-voice", + AgentEndpointAPIVersion, + "regional.hyena.example.com", + ) + + require.NoError(t, err) + require.Equal(t, "my-voice", agent.Name) + require.Equal(t, "3", agent.Versions.Latest.Version) + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "/api/projects/proj/agents/my-voice", req.URL.Path) + require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version")) + require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) + require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host")) +} + func TestUpdateVoiceAgentUnified_PostsToNamedAgentWithPreviewHeader(t *testing.T) { body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{"version":"2"}}}` client, transport := newCaptureClient(http.StatusOK, body) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index d240f13545f..6c0c15c8d15 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -130,6 +130,19 @@ func buildInvocationsWSProtocolURL(projectEndpoint, agentName string) string { ) } +func buildVoiceWSProtocolURL(projectEndpoint, agentName string) string { + projectEndpoint = strings.TrimSpace(projectEndpoint) + u, err := url.Parse(projectEndpoint) + if err != nil || u.Host == "" { + return "" + } + + return fmt.Sprintf( + "wss://%s%s/agents/%s/endpoint/protocols/voice?api-version=%s", + u.Host, strings.TrimRight(u.Path, "/"), agentName, agent_api.AgentEndpointAPIVersion, + ) +} + // ProtocolEnvSuffix pairs a user-facing label with the env var suffix // used in AGENT_{KEY}_{SUFFIX}_ENDPOINT variables. type ProtocolEnvSuffix struct { @@ -2238,11 +2251,11 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( agentClient := agent_api.NewAgentClient(projectEndpoint, p.credential) serviceKey := p.getServiceKey(serviceConfig.Name) - agentObject, err := p.deployVoiceAgentWithMode( - ctx, agentClient, request, apiMode, serviceKey, azdEnv, progress, + agentObject, deployOp, err := p.deployVoiceAgentWithMode( + ctx, agentClient, request, apiMode, azdEnv, progress, ) if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + return nil, exterrors.ServiceFromAzure(err, deployOp) } fmt.Fprintf(os.Stderr, "Voice agent '%s' deployed successfully!\n", agentObject.Name) @@ -2250,8 +2263,14 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( // Persist NAME first and ENDPOINT last. ENDPOINT is used as the voice deploy // completion marker by other commands, so avoid writing it before NAME. baseEndpoint := voiceAgentEndpoint(projectEndpoint, agentObject.Name, apiMode) + versionKey := fmt.Sprintf("AGENT_%s_VERSION", serviceKey) + versionValue := "" + if apiMode != voiceAgentAPIModeLegacy { + versionValue = agentObject.Versions.Latest.Version + } for _, envVar := range []struct{ key, value string }{ {fmt.Sprintf("AGENT_%s_NAME", serviceKey), agentObject.Name}, + {versionKey, versionValue}, {fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), baseEndpoint}, } { if _, setErr := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ @@ -2282,28 +2301,39 @@ func (p *AgentServiceTargetProvider) deployVoiceAgentWithMode( agentClient *agent_api.AgentClient, request *agent_api.CreateAgentRequest, apiMode voiceAgentAPIMode, - serviceKey string, azdEnv map[string]string, progress azdext.ProgressReporter, -) (*agent_api.AgentObject, error) { +) (*agent_api.AgentObject, string, error) { overriddenHost := azdEnv[voiceOverriddenHostEnvKey] if apiMode == voiceAgentAPIModeLegacy { progress("Creating voice agent using legacy API") - return agentClient.CreateVoiceAgent(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) + agentObject, err := agentClient.CreateVoiceAgent(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) + return agentObject, exterrors.OpCreateAgent, err } - if existingName := strings.TrimSpace(azdEnv[fmt.Sprintf("AGENT_%s_NAME", serviceKey)]); existingName != "" { + remoteAgent, getErr := agentClient.GetVoiceAgentUnified( + ctx, request.Name, agent_api.AgentEndpointAPIVersion, overriddenHost, + ) + if getErr == nil && remoteAgent != nil { progress("Updating voice agent using unified API") updateRequest := &agent_api.UpdateAgentRequest{ CreateAgentVersionRequest: request.CreateAgentVersionRequest, } - return agentClient.UpdateVoiceAgentUnified( - ctx, existingName, updateRequest, agent_api.AgentEndpointAPIVersion, overriddenHost, + agentObject, err := agentClient.UpdateVoiceAgentUnified( + ctx, request.Name, updateRequest, agent_api.AgentEndpointAPIVersion, overriddenHost, ) + return agentObject, exterrors.OpUpdateAgent, err + } + if getErr != nil { + var respErr *azcore.ResponseError + if !errors.As(getErr, &respErr) || respErr.StatusCode != http.StatusNotFound { + return nil, exterrors.OpCreateAgent, getErr + } } progress("Creating voice agent using unified API") - return agentClient.CreateVoiceAgentUnified(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) + agentObject, err := agentClient.CreateVoiceAgentUnified(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) + return agentObject, exterrors.OpCreateAgent, err } func voiceAgentEndpoint(projectEndpoint string, agentName string, apiMode voiceAgentAPIMode) string { @@ -2311,7 +2341,7 @@ func voiceAgentEndpoint(projectEndpoint string, agentName string, apiMode voiceA if apiMode == voiceAgentAPIModeLegacy { return fmt.Sprintf("%s/voice_agents/%s", trimmedEndpoint, agentName) } - return fmt.Sprintf("%s/agents/%s/endpoint/protocols/voice", trimmedEndpoint, agentName) + return buildVoiceWSProtocolURL(trimmedEndpoint, agentName) } // packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 4f2f230dafc..c6e339cd615 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -701,11 +701,23 @@ func TestVoiceAgentEndpoint_ByMode(t *testing.T) { ) require.Equal( t, - "https://proj.services.ai.azure.com/api/projects/p/agents/my-agent/endpoint/protocols/voice", + "wss://proj.services.ai.azure.com/api/projects/p/agents/my-agent/endpoint/protocols/voice?api-version=v1", voiceAgentEndpoint(projectEndpoint, "my-agent", voiceAgentAPIModeUnified), ) } +func TestBuildVoiceWSProtocolURL(t *testing.T) { + got := buildVoiceWSProtocolURL( + "https://acct.services.ai.azure.com/api/projects/proj/", + "voice-agent", + ) + require.Equal( + t, + "wss://acct.services.ai.azure.com/api/projects/proj/agents/voice-agent/endpoint/protocols/voice?api-version=v1", + got, + ) +} + func createSymlinkOrSkip(t *testing.T, oldname, newname string) { t.Helper() From e83f950447b6b083ca591b7c438166125938bcc7 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:58:08 +0800 Subject: [PATCH 4/6] fix(agents): validate unified voice response --- .../internal/project/service_target_agent.go | 16 ++++++++++ .../project/service_target_agent_test.go | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 6c0c15c8d15..128fa907d7f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -2257,6 +2257,9 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( if err != nil { return nil, exterrors.ServiceFromAzure(err, deployOp) } + if err := validateVoiceAgentDeployResponse(agentObject, apiMode); err != nil { + return nil, err + } fmt.Fprintf(os.Stderr, "Voice agent '%s' deployed successfully!\n", agentObject.Name) @@ -2296,6 +2299,19 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( return &azdext.ServiceDeployResult{Artifacts: artifacts}, nil } +func validateVoiceAgentDeployResponse(agentObject *agent_api.AgentObject, apiMode voiceAgentAPIMode) error { + if agentObject == nil { + return fmt.Errorf("malformed voice agent service response: missing agent object") + } + if strings.TrimSpace(agentObject.Name) == "" { + return fmt.Errorf("malformed voice agent service response: missing agent name") + } + if apiMode != voiceAgentAPIModeLegacy && strings.TrimSpace(agentObject.Versions.Latest.Version) == "" { + return fmt.Errorf("malformed voice agent service response: missing latest agent version") + } + return nil +} + func (p *AgentServiceTargetProvider) deployVoiceAgentWithMode( ctx context.Context, agentClient *agent_api.AgentClient, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index c6e339cd615..6c3fa573ed6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -718,6 +718,36 @@ func TestBuildVoiceWSProtocolURL(t *testing.T) { ) } +func TestValidateVoiceAgentDeployResponse(t *testing.T) { + t.Run("legacy requires name only", func(t *testing.T) { + err := validateVoiceAgentDeployResponse( + &agent_api.AgentObject{Name: "voice-agent"}, + voiceAgentAPIModeLegacy, + ) + require.NoError(t, err) + }) + + t.Run("unified requires latest version", func(t *testing.T) { + agent := &agent_api.AgentObject{Name: "voice-agent"} + agent.Versions.Latest.Version = "1" + err := validateVoiceAgentDeployResponse(agent, voiceAgentAPIModeUnifiedFlat) + require.NoError(t, err) + }) + + t.Run("missing name rejected", func(t *testing.T) { + err := validateVoiceAgentDeployResponse(&agent_api.AgentObject{}, voiceAgentAPIModeLegacy) + require.ErrorContains(t, err, "missing agent name") + }) + + t.Run("unified missing version rejected", func(t *testing.T) { + err := validateVoiceAgentDeployResponse( + &agent_api.AgentObject{Name: "voice-agent"}, + voiceAgentAPIModeUnified, + ) + require.ErrorContains(t, err, "missing latest agent version") + }) +} + func createSymlinkOrSkip(t *testing.T, oldname, newname string) { t.Helper() From 048ff7157ae2af1202b81e570658d12c6ca6e312 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:13:37 +0800 Subject: [PATCH 5/6] fix(agents): address unified voice review feedback --- .../internal/cmd/nextstep/state.go | 27 ++++++------ .../internal/cmd/nextstep/state_test.go | 23 +++++++++-- .../pkg/agents/agent_yaml/map_voice_test.go | 41 +++++++++++++++++++ .../internal/project/service_target_agent.go | 5 +-- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 5ffe129eeb1..afafade125c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -822,23 +822,22 @@ func isDeployed( *errs = append(*errs, fmt.Errorf("read %s: %w", key, err)) return false } - if value != "" { - return true - } - - // Voice agents (kind: prompt-voice) deploy without an agent-version object, - // so they never set AGENT__VERSION. Fall back to the base endpoint - // marker, which every voice deploy writes, so a successfully created voice - // agent is not reported as undeployed. Gate this on the service's actual - // declared kind: a hosted agent whose deploy partially failed can also - // present an empty VERSION with a lingering ENDPOINT, and must stay reported - // as not-deployed. This mirrors the kind gate in + if !isVoice { + return value != "" + } + + // Voice deploys use the base ENDPOINT env var as the completion marker. The + // legacy voice API does not produce AGENT__VERSION, and unified voice + // deploys write VERSION before ENDPOINT to keep ENDPOINT as the final marker. + // Require ENDPOINT for voice even when VERSION is present, otherwise a partial + // env write could be reported as deployed before the callable endpoint was + // persisted. Gate this on the service's actual declared kind: a hosted agent + // whose deploy partially failed can also present an empty VERSION with a + // lingering ENDPOINT, and must stay reported as not-deployed. This mirrors the + // kind gate in // AgentServiceTargetProvider.Endpoints (project package); the two live in // separate packages because project imports nextstep, so a literally shared // helper would create an import cycle. - if !isVoice { - return false - } endpointKey := fmt.Sprintf(agentEndpointVarFormat, serviceKey(serviceName)) endpointValue, err := src.EnvValue(ctx, envName, endpointKey) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index c62408b10bf..7f50fe6f190 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -739,10 +739,10 @@ func TestServiceKey(t *testing.T) { } } -// TestIsDeployed_VoiceEndpointFallback verifies that a voice agent — which sets -// only AGENT__NAME and AGENT__ENDPOINT, never AGENT__VERSION — is -// still reported as deployed via the base endpoint marker, while an agent with -// neither version nor endpoint is reported undeployed. +// TestIsDeployed_VoiceEndpointFallback verifies that voice readiness is based +// on the base endpoint marker. Legacy voice agents never set VERSION, while +// unified voice agents set VERSION before ENDPOINT; in both cases ENDPOINT is +// the deploy completion marker. func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { t.Parallel() @@ -757,6 +757,21 @@ func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { values: map[string]string{"env1/AGENT_VOICE_SVC_VERSION": "1"}, want: true, }, + { + name: "version set but endpoint missing: undeployed (voice agent partial write)", + values: map[string]string{"env1/AGENT_VOICE_SVC_VERSION": "1"}, + isVoice: true, + want: false, + }, + { + name: "version and endpoint set: deployed (unified voice agent)", + values: map[string]string{ + "env1/AGENT_VOICE_SVC_VERSION": "1", + "env1/AGENT_VOICE_SVC_ENDPOINT": "wss://x/agents/a/endpoint/protocols/voice?api-version=v1", + }, + isVoice: true, + want: true, + }, { name: "no version but base endpoint set: deployed (voice agent)", values: map[string]string{"env1/AGENT_VOICE_SVC_ENDPOINT": "https://x/voice_agents/a"}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go index 2ce594e1fbf..1e536b344da 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go @@ -4,6 +4,7 @@ package agent_yaml import ( + "encoding/json" "testing" "azureaiagent/internal/pkg/agents/agent_api" @@ -222,6 +223,46 @@ func TestCreateVoiceAgentAPIRequestFlat_AzureVoiceLocale(t *testing.T) { } } +func TestCreateVoiceAgentAPIRequestFlat_MarshalWireShape(t *testing.T) { + t.Parallel() + voice := "en-US-Ava:DragonHDLatestNeural" + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "voice-flat"}, + Model: &Model{Id: "gpt-realtime"}, + Voice: &voice, + } + + req, err := CreateVoiceAgentAPIRequestFlat(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + payload, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + var wire map[string]any + if err := json.Unmarshal(payload, &wire); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + definition := wire["definition"].(map[string]any) + audio := definition["audio"].(map[string]any) + output := audio["output"].(map[string]any) + + if got, ok := output["voice"].(string); !ok || got != voice { + t.Fatalf("audio.output.voice = %#v, want string %q", output["voice"], voice) + } + if got := output["voice_type"]; got != "azure-standard" { + t.Fatalf("audio.output.voice_type = %#v, want azure-standard", got) + } + if got := output["voice_locale"]; got != "en-US" { + t.Fatalf("audio.output.voice_locale = %#v, want en-US", got) + } + if _, exists := output["type"]; exists { + t.Fatalf("audio.output.type should not be present in flat wire shape: %#v", output) + } +} + // TestCreateVoiceAgentAPIRequest_ExplicitManaged verifies that explicitly // setting model_type: managed is accepted (idempotent with the default). func TestCreateVoiceAgentAPIRequest_ExplicitManaged(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 128fa907d7f..a4558598fcb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -2218,7 +2218,7 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( apiMode, err := resolveVoiceAgentAPIMode(azdEnv) if err != nil { return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, + exterrors.CodeInvalidParameter, err.Error(), fmt.Sprintf("set %s to legacy, unified, or unified-flat", voiceAgentAPIEnvKey), ) @@ -2341,8 +2341,7 @@ func (p *AgentServiceTargetProvider) deployVoiceAgentWithMode( return agentObject, exterrors.OpUpdateAgent, err } if getErr != nil { - var respErr *azcore.ResponseError - if !errors.As(getErr, &respErr) || respErr.StatusCode != http.StatusNotFound { + if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); !ok || respErr.StatusCode != http.StatusNotFound { return nil, exterrors.OpCreateAgent, getErr } } From 5d5daef9a6397a06a051a38252c878d174dd3358 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:30:00 +0800 Subject: [PATCH 6/6] fix(agents): address unified voice review feedback --- cli/azd/extensions/azure.ai.agents/README.md | 31 ------------------- .../internal/project/service_target_agent.go | 21 +++++++++---- .../project/service_target_agent_test.go | 27 ++++++++++++++++ 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 3108c24e5b7..4ddac596f9a 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -133,37 +133,6 @@ Details: > the other inline agent properties such as `codeConfiguration` and > `environmentVariables`. -## Prompt voice agent API mode - -Prompt voice agents (`kind: prompt-voice`) use the legacy `/voice_agents` API by -default while the unified `/agents` voice API rolls out across regions. To run -regression tests against the unified API, set `AZURE_VOICE_AGENT_API` before -`azd deploy`: - -```bash -# Default: legacy /voice_agents API -azd env set AZURE_VOICE_AGENT_API legacy - -# Unified /agents API using the current object-shaped audio.output.voice payload -azd env set AZURE_VOICE_AGENT_API unified - -# Unified /agents API using the TiP/spec flat audio.output.voice payload -azd env set AZURE_VOICE_AGENT_API unified-flat -``` - -Details: - -- `legacy` remains the default and preserves existing behavior. -- `unified` and `unified-flat` check `/agents/{name}` remotely before deploying: - `404` creates through `/agents`, while `200` updates through `/agents/{name}`. -- Unified modes write `AGENT__VERSION` and store the callable voice - WebSocket endpoint as `wss://.../agents/{name}/endpoint/protocols/voice?api-version=v1`. -- `legacy` clears any stale `AGENT__VERSION` value and preserves the - existing `/voice_agents/{name}` endpoint marker. -- `unified-flat` is intended for TiP/new-service validation. Non-TiP regions may - still require `legacy` or `unified` until the flat output shape is fully - rolled out. - ### Moderating invocations-protocol traffic For agents that expose the `invocations` protocol, the RAI policy alone is not diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index a4558598fcb..3b01eb4a811 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -2330,7 +2330,11 @@ func (p *AgentServiceTargetProvider) deployVoiceAgentWithMode( remoteAgent, getErr := agentClient.GetVoiceAgentUnified( ctx, request.Name, agent_api.AgentEndpointAPIVersion, overriddenHost, ) - if getErr == nil && remoteAgent != nil { + shouldUpdate, decisionErr := shouldUpdateVoiceAgent(remoteAgent, getErr) + if decisionErr != nil { + return nil, exterrors.OpCreateAgent, decisionErr + } + if shouldUpdate { progress("Updating voice agent using unified API") updateRequest := &agent_api.UpdateAgentRequest{ CreateAgentVersionRequest: request.CreateAgentVersionRequest, @@ -2340,17 +2344,22 @@ func (p *AgentServiceTargetProvider) deployVoiceAgentWithMode( ) return agentObject, exterrors.OpUpdateAgent, err } - if getErr != nil { - if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); !ok || respErr.StatusCode != http.StatusNotFound { - return nil, exterrors.OpCreateAgent, getErr - } - } progress("Creating voice agent using unified API") agentObject, err := agentClient.CreateVoiceAgentUnified(ctx, request, agent_api.AgentEndpointAPIVersion, overriddenHost) return agentObject, exterrors.OpCreateAgent, err } +func shouldUpdateVoiceAgent(remoteAgent *agent_api.AgentObject, getErr error) (bool, error) { + if getErr == nil { + return remoteAgent != nil, nil + } + if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); ok && respErr.StatusCode == http.StatusNotFound { + return false, nil + } + return false, getErr +} + func voiceAgentEndpoint(projectEndpoint string, agentName string, apiMode voiceAgentAPIMode) string { trimmedEndpoint := strings.TrimRight(projectEndpoint, "/") if apiMode == voiceAgentAPIModeLegacy { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 69921a0328c..bd4d87fa290 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net" + "net/http" "os" "path/filepath" "strings" @@ -748,6 +749,32 @@ func TestValidateVoiceAgentDeployResponse(t *testing.T) { }) } +func TestShouldUpdateVoiceAgent(t *testing.T) { + t.Run("remote found updates", func(t *testing.T) { + update, err := shouldUpdateVoiceAgent(&agent_api.AgentObject{Name: "voice"}, nil) + require.NoError(t, err) + require.True(t, update) + }) + + t.Run("remote nil creates", func(t *testing.T) { + update, err := shouldUpdateVoiceAgent(nil, nil) + require.NoError(t, err) + require.False(t, update) + }) + + t.Run("not found creates", func(t *testing.T) { + update, err := shouldUpdateVoiceAgent(nil, &azcore.ResponseError{StatusCode: http.StatusNotFound}) + require.NoError(t, err) + require.False(t, update) + }) + + t.Run("other get error returns error", func(t *testing.T) { + update, err := shouldUpdateVoiceAgent(nil, &azcore.ResponseError{StatusCode: http.StatusInternalServerError}) + require.Error(t, err) + require.False(t, update) + }) +} + func createSymlinkOrSkip(t *testing.T, oldname, newname string) { t.Helper()