diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index f63bf0bda84..4030ea0cf30 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## 0.1.42-preview (Unreleased) + +- [[#8824]](https://github.com/Azure/azure-dev/pull/8824) Add `activity_protocol` support to `azd ai agent invoke`, alongside the existing `responses` and `invocations` protocols. A plain message is wrapped as a message Activity, `--input-file` sends a complete Activity object, and `--output raw` dumps the response verbatim. + ## 0.1.41-preview (2026-06-19) - [[#8731]](https://github.com/Azure/azure-dev/pull/8731) Improve the post-deploy `Next:` guidance with a stacked layout that puts each command on its own line above its description, adds a blank line between suggestions, and highlights `azd` commands. The new layout applies across deploy, `azd ai agent show`, `init`, and `doctor`. Thanks @therealjohn for the contribution! diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index c8b571d2f31..fde64ac2651 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -27,12 +27,13 @@ const agentEndpointHint = "run `azd ai agent show` to see the agent endpoint URL // // [1] project name (URL-escaped), // [2] agent name (URL-escaped), -// [3] protocol tail ("invocations" or "openai/responses"). +// [3] protocol tail ("invocations", "activity", or "openai/responses"). // // The "openai/v1/responses" tail is also accepted and rebuilt to the canonical // query-parameter form when invoked. var agentEndpointPathRegex = regexp.MustCompile( - `^/api/projects/([^/]+)/agents/([^/]+)/endpoint/protocols/(invocations|openai/v1/responses|openai/responses)/?$`, + `^/api/projects/([^/]+)/agents/([^/]+)/endpoint/protocols/` + + `(invocations|activity|openai/v1/responses|openai/responses)/?$`, ) // parsedAgentEndpoint describes a deployed agent invocation endpoint. @@ -134,6 +135,8 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { switch protocolTail { case "invocations": protocol = agent_api.AgentProtocolInvocations + case "activity": + protocol = agent_api.AgentProtocolActivityProtocol case "openai/responses", "openai/v1/responses": protocol = agent_api.AgentProtocolResponses } @@ -187,3 +190,26 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str } return invURL } + +// buildActivityURL builds the Foundry "activity" protocol URL for an agent. +// When sid is non-empty, an agent_session_id query parameter is appended +// (URL-encoded) so the request routes to the same agent session, mirroring the +// invocations protocol. +// +// NOTE: The activity protocol wire format (path tail `protocols/activity`, +// Activity request body, and response shape) follows the same path convention +// as the responses/invocations endpoints. This is centralized here so it can be +// adjusted in one place if the Foundry contract differs. +func buildActivityURL(projectEndpoint, agentName, apiVersion, sid string) string { + if apiVersion == "" { + apiVersion = DefaultAgentAPIVersion + } + actURL := fmt.Sprintf( + "%s/agents/%s/endpoint/protocols/activity?api-version=%s", + projectEndpoint, agentName, url.QueryEscape(apiVersion), + ) + if sid != "" { + actURL += "&agent_session_id=" + url.QueryEscape(sid) + } + return actURL +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index a281033ab21..87f960ca507 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -59,6 +59,21 @@ func TestParseAgentEndpoint(t *testing.T) { wantAgent: "hello", wantProto: agent_api.AgentProtocolInvocations, }, + { + name: "activity with api-version", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/activity?api-version=v1", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "hello", + wantProto: agent_api.AgentProtocolActivityProtocol, + wantAPIVer: "v1", + }, + { + name: "activity without api-version", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/activity", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "hello", + wantProto: agent_api.AgentProtocolActivityProtocol, + }, { name: "empty url", raw: "", @@ -246,6 +261,37 @@ func TestBuildInvocationsURL(t *testing.T) { }) } +// TestBuildActivityURL verifies the activity URL builder targets the +// .../protocols/activity tail, applies the default api-version, and URL-encodes +// both the api-version and any session id. +func TestBuildActivityURL(t *testing.T) { + t.Parallel() + const projectEndpoint = "https://acct.services.ai.azure.com/api/projects/proj" + + t.Run("no session id, default api-version", func(t *testing.T) { + got := buildActivityURL(projectEndpoint, "hello", "", "") + want := projectEndpoint + "/agents/hello/endpoint/protocols/activity?api-version=" + + DefaultAgentAPIVersion + if got != want { + t.Errorf("buildActivityURL = %q, want %q", got, want) + } + }) + + t.Run("session id is escaped", func(t *testing.T) { + got := buildActivityURL(projectEndpoint, "hello", "v1", "a b/c?d&e") + if !strings.Contains(got, "agent_session_id=a+b%2Fc%3Fd%26e") { + t.Errorf("buildActivityURL did not escape session id: %q", got) + } + }) + + t.Run("api-version is escaped", func(t *testing.T) { + got := buildActivityURL(projectEndpoint, "hello", "weird value&x=1", "") + if !strings.Contains(got, "api-version=weird+value%26x%3D1") { + t.Errorf("buildActivityURL did not escape api-version: %q", got) + } + }) +} + // TestResolveRemoteContext_EphemeralMode exercises the ephemeral branch of // resolveRemoteContext (--agent-endpoint path). It pins the api-version // fallback (default applied when the URL omits the parameter) and the diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index ec0941cca04..021032f1d44 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -894,7 +894,7 @@ func protocolFromAgentYaml( "agent.yaml declares only non-invocable protocols: %s", strings.Join(names, ", "), ), - "azd can only invoke agents using the responses or invocations protocols", + "azd can only invoke agents using the responses, invocations, or activity protocols", ) case 1: // Exactly one invocable protocol — but if the agent declares diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go index e8405196ed3..ce71f5b003b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go @@ -208,6 +208,11 @@ func TestProtocolFromAgentYaml(t *testing.T) { yaml: "protocols:\n - protocol: invocations\n version: \"1.0\"\n", wantProto: "invocations", }, + { + name: "single protocol activity_protocol", + yaml: "protocols:\n - protocol: activity_protocol\n version: \"1.0\"\n", + wantProto: "activity_protocol", + }, { name: "no file", noFile: true, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 9efd991596f..8af99f24b1e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -83,7 +83,8 @@ agent name and the second is the message. Use --input-file/-f to send the contents of a file as the request body instead of a positional message argument. This is useful for structured -or large payloads with the invocations protocol. +or large payloads with the invocations protocol, or for sending a complete +Activity object with the activity protocol. Use --local to target a locally running agent (started via 'azd ai agent run') instead of Foundry. @@ -111,6 +112,12 @@ suppressed in raw mode.`, # Invoke using a specific protocol azd ai agent invoke --protocol invocations "Hello!" + # Invoke over the activity protocol (wraps the message as a message Activity) + azd ai agent invoke --protocol activity_protocol "Hello!" + + # Invoke the activity protocol with a complete Activity object from a file + azd ai agent invoke --protocol activity_protocol -f activity.json + # Invoke with a file as the request body azd ai agent invoke -f request.json @@ -203,7 +210,7 @@ suppressed in raw mode.`, return exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("unsupported protocol %q for invocation", flags.protocol), - "supported protocols are: responses, invocations", + "supported protocols are: responses, invocations, activity_protocol", ) } } @@ -214,7 +221,8 @@ suppressed in raw mode.`, cmd.Flags().BoolVarP(&flags.local, "local", "l", false, "Invoke on localhost instead of Foundry") cmd.Flags().StringVarP(&flags.inputFile, "input-file", "f", "", "Path to a file whose contents are sent as the request body") - cmd.Flags().StringVarP(&flags.protocol, "protocol", "p", "", "Protocol to use: responses (default) or invocations") + cmd.Flags().StringVarP(&flags.protocol, "protocol", "p", "", + "Protocol to use: responses (default), invocations, or activity_protocol") cmd.Flags().IntVar(&flags.port, "port", DefaultPort, "Local server port") cmd.Flags().IntVarP( &flags.timeout, @@ -360,16 +368,22 @@ func (a *InvokeAction) Run(ctx context.Context) error { switch protocol { case agent_api.AgentProtocolInvocations: return a.invocationsLocal(ctx) + case agent_api.AgentProtocolActivityProtocol: + return a.activityLocal(ctx) default: return a.responsesLocal(ctx) } } // Remote: route by protocol. - if protocol == agent_api.AgentProtocolInvocations { + switch protocol { + case agent_api.AgentProtocolInvocations: return a.invocationsRemote(ctx) + case agent_api.AgentProtocolActivityProtocol: + return a.activityRemote(ctx) + default: + return a.responsesRemote(ctx) } - return a.responsesRemote(ctx) } // emitInvokeSuccessNextStep prints the resolver-driven Next: block after a diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_activity.go new file mode 100644 index 00000000000..b161307e60a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_activity.go @@ -0,0 +1,392 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// activityMessageType is the Activity `type` used when wrapping a plain text +// message into a minimal Activity Protocol request body. See +// https://learn.microsoft.com/microsoft-365/agents-sdk/activity-protocol. +const activityMessageType = "message" + +// buildActivityRequestBody returns the JSON request body for an activity-protocol +// invoke and a human-readable label describing it. +// +// When the user supplied --input-file, its bytes are forwarded verbatim — the +// file is assumed to already contain a complete Activity object, mirroring the +// invocations protocol's opaque body pass-through. Otherwise the plain message +// is wrapped into a minimal `message` Activity ({"type":"message","text":...}). +// +// Session routing is handled out-of-band via the agent_session_id query +// parameter (see buildActivityURL / activityLocal), matching the invocations +// protocol, so no session/conversation id is injected into the body here. +func (a *InvokeAction) buildActivityRequestBody() ([]byte, string, error) { + body, label, err := a.resolveBody() + if err != nil { + return nil, "", err + } + if a.flags.inputFile != "" { + // User provided a full Activity payload; forward it unchanged. + return body, label, nil + } + + activity := map[string]any{ + "type": activityMessageType, + "text": string(body), + } + payload, err := json.Marshal(activity) + if err != nil { + return nil, "", fmt.Errorf("failed to marshal activity request: %w", err) + } + return payload, label, nil +} + +// activityLocal invokes a locally running agent (started via 'azd ai agent run') +// over the activity protocol (POST http://localhost:{port}/activity). +func (a *InvokeAction) activityLocal(ctx context.Context) error { + port := a.flags.port + + body, bodyLabel, err := a.buildActivityRequestBody() + if err != nil { + return err + } + + var azdClient *azdext.AzdClient + if c, err := azdext.NewAzdClient(); err == nil { + azdClient = c + defer azdClient.Close() + } + + agentName := resolveLocalAgentName(ctx, azdClient, a.flags.name, a.noPrompt) + // The session-storage key intentionally uses DefaultPort (not a.flags.port), + // matching invocationsLocal: keying on a stable port lets local sessions + // persist across invokes regardless of which --port the user passes. + agentKey := buildLocalAgentKey(DefaultPort, agentName, "", resolveProjectPath(ctx, azdClient)) + + // Resolve local session ID (generated locally, not server-assigned). + var sid string + if azdClient != nil { + sid, err = resolveStoredID( + ctx, azdClient, agentKey, a.flags.session, a.flags.newSession, "sessions", true, + ) + if err != nil { + log.Printf("invoke local: failed to resolve session ID: %v", err) + } + } + + // Note: unlike invocationsLocal, the activity protocol has no defined + // OpenAPI discovery endpoint, so no spec is fetched/cached here. + + raw := a.flags.outputFmt == outputRaw + if !raw { + fmt.Printf("Target: localhost:%d (local, activity protocol)\n", port) + fmt.Printf("Input: %s\n", bodyLabel) + printSessionStatus("Session: ", sid) + fmt.Println() + } + + actURL := fmt.Sprintf("http://localhost:%d/activity", port) + if sid != "" { + actURL += "?agent_session_id=" + url.QueryEscape(sid) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, actURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", contentTypeForBody(body)) + if raw { + // Disable Go's transparent gzip handling so the dumped headers and + // body match what the server actually sent on the wire. + req.Header.Set("Accept-Encoding", "identity") + } + + client := &http.Client{Timeout: a.httpTimeout()} + invokeStart := time.Now() + resp, err := client.Do(req) //nolint:gosec // G704: URL targets localhost with user-configured port + if err != nil { + return fmt.Errorf( + "could not connect to localhost:%d -- is the agent running? Start it with: azd ai agent run", + port, + ) + } + ttfb := time.Since(invokeStart) + defer resp.Body.Close() + + if err := handleActivityResponse(resp, agentName, raw); err != nil { + if !raw && resp.StatusCode >= 400 { + a.emitInvokeFailureNextStep(nextstep.InvokeLocal, agentName, "") + } + return err + } + totalDuration := time.Since(invokeStart) + if !raw { + printInvokeTiming(os.Stdout, totalDuration, ttfb) + a.emitInvokeSuccessNextStep(nextstep.InvokeLocal, agentName) + } + return nil +} + +// activityRemote sends the user's message to Foundry using the activity protocol +// (POST /agents/{name}/endpoint/protocols/activity). Memory is bound to the +// agent session, like the invocations protocol. +func (a *InvokeAction) activityRemote(ctx context.Context) error { + rc, err := a.resolveRemoteContext(ctx) + if err != nil { + return err + } + if rc.azdClient != nil { + defer rc.azdClient.Close() + } + + agentKey := rc.agentKey + if agentKey == "" && rc.azdClient != nil { + log.Printf("warning: agent endpoint not available, session state will not be persisted") + } + + if a.flags.newConversation { + fmt.Fprintln(os.Stderr, + "note: --new-conversation has no effect for the activity protocol "+ + "(memory is bound to the session; use --new-session to reset).") + } + + body, bodyLabel, err := a.buildActivityRequestBody() + if err != nil { + return err + } + + // Acquire the bearer token after body validation so a local input error + // (e.g., unreadable --input-file) does not pay an unnecessary auth round-trip + // and is surfaced before any auth failure. + rc.bearerToken, err = a.acquireBearerToken(ctx) + if err != nil { + return err + } + + // Session ID — routes to the same container instance. + sid, err := a.resolveRemoteSessionID(ctx, rc) + if err != nil { + return err + } + + raw := a.flags.outputFmt == outputRaw + if !raw { + fmt.Printf("Agent: %s (remote, activity protocol)\n", rc.name) + fmt.Printf("Input: %s\n", bodyLabel) + if rc.version != "" { + fmt.Printf("Version: %s\n", rc.version) + } + printSessionStatus("Session: ", sid) + fmt.Println() + } + + actURL := buildActivityURL(rc.projectEndpoint, rc.name, rc.apiVersion, sid) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, actURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", contentTypeForBody(body)) + req.Header.Set("Authorization", "Bearer "+rc.bearerToken) + req.Header.Set("Foundry-Features", "HostedAgents=V1Preview") + applyIsolationHeaders(req, &a.flags.isolationHeaderFlags) + if raw { + // Disable Go's transparent gzip handling so the dumped headers and + // body match what the server actually sent on the wire. + req.Header.Set("Accept-Encoding", "identity") + } + + client := &http.Client{Timeout: a.httpTimeout()} + invokeStart := time.Now() + //nolint:gosec // G704: URL is built from a validated Foundry endpoint (env or --agent-endpoint) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("POST %s failed: %w", actURL, err) + } + ttfb := time.Since(invokeStart) + defer resp.Body.Close() + + // Always capture session state from response headers (needed even in raw mode + // so subsequent invokes can reuse the session). Reads headers, not the body. + sessionLabel := "Session: " + if raw { + sessionLabel = "" + } + captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, sessionLabel) + + sessionCode := resp.Header.Get("x-adc-response-details") + if err := handleActivityResponse(resp, rc.name, raw); err != nil { + if !raw && resp.StatusCode >= 400 { + a.emitInvokeFailureNextStep(nextstep.InvokeRemote, rc.nextStepName(), sessionCode) + } + return err + } + totalDuration := time.Since(invokeStart) + if !raw { + printInvokeTiming(os.Stdout, totalDuration, ttfb) + a.emitInvokeSuccessNextStep(nextstep.InvokeRemote, rc.nextStepName()) + } + return nil +} + +// handleActivityResponse renders the response from a POST to an activity-protocol +// endpoint. In raw mode the response is dumped verbatim (status line + headers + +// body). Otherwise the agent's reply text is extracted from the response Activity +// and printed; non-Activity JSON is pretty-printed, and any other body is printed +// verbatim. +func handleActivityResponse(resp *http.Response, agentName string, raw bool) error { + if raw { + if err := writeRawResponse(os.Stdout, resp); err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf( + "POST %s failed with HTTP %d: %s", + activityRequestURL(resp), resp.StatusCode, resp.Status, + ) + } + return nil + } + + if traceID := responseTraceID(resp); traceID != "" { + fmt.Printf("Trace ID: %s\n", traceID) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return fmt.Errorf( + "POST %s failed with HTTP %d: %s\n%s", + activityRequestURL(resp), resp.StatusCode, resp.Status, string(respBody), + ) + } + + // Surface an agent error envelope if present, matching the invocations + // protocol's behavior. + if agentErr := activityErrorFromBody(respBody); agentErr != nil { + return agentErr + } + + printActivityResult(respBody, agentName) + return nil +} + +// activityRequestURL returns the request URL of a response for error messages, +// falling back to a generic path when unavailable. +func activityRequestURL(resp *http.Response) string { + if resp.Request != nil && resp.Request.URL != nil { + return resp.Request.URL.String() + } + return "/activity" +} + +// activityErrorFromBody returns a structured error when the response body is a +// recommended agent error envelope ({"error":{"message":...}}), or nil otherwise. +func activityErrorFromBody(respBody []byte) error { + var result map[string]any + if err := json.Unmarshal(respBody, &result); err != nil { + return nil + } + errObj, ok := result["error"].(map[string]any) + if !ok { + return nil + } + msg, _ := errObj["message"].(string) + errType, _ := errObj["type"].(string) + code, _ := errObj["code"].(string) + label := code + if label == "" { + label = errType + } + if label != "" { + return fmt.Errorf("agent error (%s): %s", label, msg) + } + return fmt.Errorf("agent error: %s", msg) +} + +// printActivityResult prints the agent's reply. It first tries to extract the +// text of the response Activity (or Activities); when no text can be found it +// pretty-prints the JSON body, falling back to the raw body for non-JSON. +func printActivityResult(respBody []byte, agentName string) { + if text := extractActivityText(respBody); text != "" { + fmt.Printf("[%s] %s\n", agentName, text) + return + } + + if json.Valid(respBody) { + var pretty bytes.Buffer + if err := json.Indent(&pretty, respBody, "", " "); err == nil { + fmt.Printf("[%s] %s\n", agentName, pretty.String()) + return + } + } + + fmt.Printf("[%s] %s\n", agentName, strings.TrimSpace(string(respBody))) +} + +// extractActivityText pulls the user-visible message text out of an activity +// protocol response. It accepts the common shapes returned by activity +// endpoints: +// +// - a single Activity object with a top-level "text" +// - an InvokeResponse-style envelope {"body": {"text": ...}} +// - an array of Activities (the message activities' text is joined) +// +// It returns "" when no text can be found, signaling the caller to fall back to +// printing the JSON body. +func extractActivityText(respBody []byte) string { + var asObject map[string]any + if err := json.Unmarshal(respBody, &asObject); err == nil { + if text := activityTextFromObject(asObject); text != "" { + return text + } + return "" + } + + var asArray []map[string]any + if err := json.Unmarshal(respBody, &asArray); err == nil { + var parts []string + for _, item := range asArray { + if text := activityTextFromObject(item); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n") + } + + return "" +} + +// activityTextFromObject extracts the message text from a single Activity-shaped +// map, checking the top-level "text" first and then an InvokeResponse "body". +func activityTextFromObject(obj map[string]any) string { + if text, ok := obj["text"].(string); ok && strings.TrimSpace(text) != "" { + return text + } + if body, ok := obj["body"].(map[string]any); ok { + if text, ok := body["text"].(string); ok && strings.TrimSpace(text) != "" { + return text + } + } + return "" +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_activity_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_activity_test.go new file mode 100644 index 00000000000..48ab2adefdb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_activity_test.go @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" +) + +// TestBuildActivityRequestBody_WrapsMessage verifies that a plain message +// argument is wrapped into a minimal message Activity. +func TestBuildActivityRequestBody_WrapsMessage(t *testing.T) { + t.Parallel() + + a := &InvokeAction{flags: &invokeFlags{message: "Hello!"}} + body, label, err := a.buildActivityRequestBody() + if err != nil { + t.Fatalf("buildActivityRequestBody: %v", err) + } + if label != `"Hello!"` { + t.Errorf("label = %q, want %q", label, `"Hello!"`) + } + + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("body is not valid JSON: %v (%s)", err, string(body)) + } + if got["type"] != activityMessageType { + t.Errorf("type = %v, want %q", got["type"], activityMessageType) + } + if got["text"] != "Hello!" { + t.Errorf("text = %v, want %q", got["text"], "Hello!") + } +} + +// TestBuildActivityRequestBody_PassesFileVerbatim verifies that --input-file +// contents are forwarded unchanged (the file is assumed to be a full Activity). +func TestBuildActivityRequestBody_PassesFileVerbatim(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "activity.json") + content := `{"type":"message","text":"from file","channelId":"directline"}` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + a := &InvokeAction{flags: &invokeFlags{inputFile: path}} + body, label, err := a.buildActivityRequestBody() + if err != nil { + t.Fatalf("buildActivityRequestBody: %v", err) + } + if string(body) != content { + t.Errorf("body = %q, want verbatim %q", string(body), content) + } + if label == "" { + t.Error("expected a non-empty body label for file input") + } +} + +func TestExtractActivityText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + want string + }{ + {name: "single message activity", body: `{"type":"message","text":"hi there"}`, want: "hi there"}, + {name: "invoke response body.text", body: `{"body":{"text":"nested reply"}}`, want: "nested reply"}, + {name: "array of activities", body: `[{"text":"one"},{"text":"two"}]`, want: "one\ntwo"}, + {name: "array skips empty text", body: `[{"type":"typing"},{"text":"only"}]`, want: "only"}, + {name: "object without text", body: `{"type":"event","name":"ping"}`, want: ""}, + {name: "whitespace-only text ignored", body: `{"text":" "}`, want: ""}, + {name: "plain json string", body: `"just a string"`, want: ""}, + {name: "invalid json", body: `not json`, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := extractActivityText([]byte(tt.body)); got != tt.want { + t.Errorf("extractActivityText(%s) = %q, want %q", tt.body, got, tt.want) + } + }) + } +} + +func TestActivityErrorFromBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantErr bool + wantInMsg string + }{ + {name: "error with code", body: `{"error":{"message":"boom","code":"E1"}}`, wantErr: true, wantInMsg: "agent error (E1): boom"}, + {name: "error with type only", body: `{"error":{"message":"boom","type":"BadThing"}}`, wantErr: true, wantInMsg: "agent error (BadThing): boom"}, + {name: "error without label", body: `{"error":{"message":"boom"}}`, wantErr: true, wantInMsg: "agent error: boom"}, + {name: "no error field", body: `{"text":"all good"}`, wantErr: false}, + {name: "invalid json", body: `not json`, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := activityErrorFromBody([]byte(tt.body)) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tt.wantInMsg != "" && err.Error() != tt.wantInMsg { + t.Errorf("error = %q, want %q", err.Error(), tt.wantInMsg) + } + return + } + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + }) + } +} + +func TestActivityRequestURL(t *testing.T) { + t.Parallel() + + t.Run("uses request URL when present", func(t *testing.T) { + u, _ := url.Parse("https://acct.services.ai.azure.com/api/projects/proj/agents/a/endpoint/protocols/activity") + resp := &http.Response{Request: &http.Request{URL: u}} + if got := activityRequestURL(resp); got != u.String() { + t.Errorf("activityRequestURL = %q, want %q", got, u.String()) + } + }) + + t.Run("falls back when no request URL", func(t *testing.T) { + if got := activityRequestURL(&http.Response{}); got != "/activity" { + t.Errorf("activityRequestURL = %q, want %q", got, "/activity") + } + }) +} 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 7d9d3ae3e22..577bad97d61 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 @@ -22,19 +22,20 @@ const ( ) // InvocableProtocols returns the set of protocols that azd can invoke directly. -// A2A and activity_protocol are deployment-only — they cannot be used for local -// or remote invocation through azd. +// A2A is deployment-only — it cannot be used for local or remote invocation +// through azd. func InvocableProtocols() []AgentProtocol { return []AgentProtocol{ AgentProtocolResponses, AgentProtocolInvocations, + AgentProtocolActivityProtocol, } } // IsInvocable reports whether the protocol can be used for invocation through azd. func (p AgentProtocol) IsInvocable() bool { switch p { - case AgentProtocolResponses, AgentProtocolInvocations: + case AgentProtocolResponses, AgentProtocolInvocations, AgentProtocolActivityProtocol: return true default: return false diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go index 0b53b9f12f6..db68a96778a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models_test.go @@ -1154,7 +1154,7 @@ func TestIsInvocable(t *testing.T) { {AgentProtocolResponses, true}, {AgentProtocolInvocations, true}, {AgentProtocolA2A, false}, - {AgentProtocolActivityProtocol, false}, + {AgentProtocolActivityProtocol, true}, {AgentProtocol("unknown"), false}, }