Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,23 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str
return invURL
}

func buildInvocationRetrievalURL(projectEndpoint, agentName, invocationID, apiVersion, sid string) string {
if apiVersion == "" {
apiVersion = DefaultAgentAPIVersion
}
base := fmt.Sprintf(
"%s/agents/%s/endpoint/protocols/invocations/%s",
projectEndpoint,
agentName,
url.PathEscape(invocationID),
)
query := url.Values{"api-version": []string{apiVersion}}
if sid != "" {
query.Set("agent_session_id", sid)
}
return base + "?" + query.Encode()
}

// buildA2AInvokeURL builds the Foundry "a2a" 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, matching the invocations protocol.
Expand Down
89 changes: 65 additions & 24 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,11 @@ Use --resumable with the Responses protocol to start work that continues running
the service if this command disconnects. The command remains attached until the work
finishes. Add --no-wait to detach as soon as the service acknowledges the background
work. Use --resume to reconnect to saved work, --steer with input to revise active work
or start the next resumable turn after completion, and --cancel to cancel saved work. In
multi-agent projects, pass the agent name positionally. Resumable operations are remote-only,
do not support raw output, and cannot be combined with --timeout.`,
or start the next resumable turn after completion, and --cancel to cancel saved work. For
remote Invocations agents, message-free --resume performs one best-effort GET of the latest
saved invocation without polling or interpreting its lifecycle. In multi-agent projects,
pass the agent name positionally. Saved-work operations are remote-only, do not support raw
output, and cannot be combined with --timeout.`,
Example: ` # Invoke the remote agent on Foundry (auto-detects agent from azure.yaml)
azd ai agent invoke "Hello!"

Expand Down Expand Up @@ -165,7 +167,7 @@ do not support raw output, and cannot be combined with --timeout.`,
# while remaining attached until it finishes
azd ai agent invoke --resumable "Run the long task"

# Start resumable work and detach after the service acknowledges it
# Start background work and detach after the service acknowledges it
azd ai agent invoke --resumable --no-wait "Run the long task"

# Resume, steer, or cancel saved resumable work
Expand All @@ -177,6 +179,9 @@ do not support raw output, and cannot be combined with --timeout.`,
azd ai agent invoke my-agent --resume
azd ai agent invoke my-agent --cancel

# Retrieve the latest saved Invocation once (no polling or replay guarantee)
azd ai agent invoke my-agent --protocol invocations --resume

# Start a new session (discard conversation history)
azd ai agent invoke --new-session "Hello!"

Expand Down Expand Up @@ -279,15 +284,15 @@ do not support raw output, and cannot be combined with --timeout.`,
if flags.local {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"resumable operations are supported only for remote Responses agents",
"remove --local and use a deployed Responses agent",
"saved-work operations are supported only for remote agents",
"remove --local and use a deployed agent",
)
}
if flags.outputFmt == outputRaw {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--output raw is not supported with resumable operations",
"remove --output raw so azd can manage the Response identity and cursor",
"--output raw is not supported with saved-work operations",
"remove --output raw so azd can manage saved operation state",
)
}
}
Expand Down Expand Up @@ -348,7 +353,12 @@ do not support raw output, and cannot be combined with --timeout.`,
"Start resumable work that continues in the service if the command disconnects; remain attached until it finishes",
)
cmd.Flags().BoolVar(&flags.noWait, "no-wait", false, "Detach after the service acknowledges the resumable work")
cmd.Flags().BoolVar(&flags.resume, "resume", false, "Reconnect to saved background work")
cmd.Flags().BoolVar(
&flags.resume,
"resume",
false,
"Reconnect to saved background work or retrieve the latest saved Invocation once",
)
cmd.Flags().BoolVar(
&flags.steer,
"steer",
Expand Down Expand Up @@ -445,7 +455,7 @@ func validateInvokeOperationFlags(cmd *cobra.Command, flags *invokeFlags) error
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"--resume and --cancel do not accept a message or --input-file",
"remove the input to reconnect to or cancel the saved Response",
"remove the input; --resume reconnects to saved work and --cancel cancels saved Responses",
)
}

Expand All @@ -464,7 +474,7 @@ func validateInvokeOperationFlags(cmd *cobra.Command, flags *invokeFlags) error
return exterrors.Validation(
exterrors.CodeConflictingArguments,
"--timeout is not supported with --resume, --steer, or --cancel",
"remove --timeout; attached background work has no overall timeout",
"remove --timeout; saved-work operations manage request timing internally",
)
}
}
Expand Down Expand Up @@ -579,11 +589,15 @@ func (a *InvokeAction) Run(ctx context.Context) error {
// intends to prevent.
if (a.flags.resumable || a.flags.resume || a.flags.steer || a.flags.cancel) &&
protocol != agent_api.AgentProtocolResponses {
return exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("resumable operations are not supported with the %s protocol", protocol),
"use a deployed Responses agent or remove the resumable operation",
)
invocationsGet := protocol == agent_api.AgentProtocolInvocations && a.flags.resume &&
!a.flags.resumable && !a.flags.steer && !a.flags.cancel
if !invocationsGet {
Comment thread
m5i-work marked this conversation as resolved.
return exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("resumable operations are not supported with the %s protocol", protocol),
"use a deployed Responses agent or remove the resumable operation",
)
}
}

if len(a.clientHeaders) > 0 && protocol == agent_api.AgentProtocolA2A {
Expand All @@ -609,6 +623,9 @@ func (a *InvokeAction) Run(ctx context.Context) error {
// Remote: route by protocol.
switch protocol {
case agent_api.AgentProtocolInvocations:
if a.flags.resume {
return a.invocationsResumeRemote(ctx)
}
return a.invocationsRemote(ctx)
case agent_api.AgentProtocolA2A:
return a.a2aRemote(ctx)
Expand Down Expand Up @@ -1799,15 +1816,25 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error {
ttfb := time.Since(invokeStart)
defer resp.Body.Close()

// Print the invocation ID if the agent returned one. We do not persist it
// to the per-user config: the config store only supports the "sessions"
// and "conversations" maps (see validateStoreField), and invocation IDs
// are not used to drive any subsequent invoke -- they are emitted purely
// for trace correlation.
if !raw {
if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" {
fmt.Printf("Invocation: %s\n", invID)
// Capture the invocation ID for diagnostics and best-effort later retrieval.
// The AgentServer adapter normally returns the ID in a header; asynchronous
// implementations may return it only in the 202 response body.
invocationID := resp.Header.Get("x-agent-invocation-id")
if invocationID == "" && resp.StatusCode == http.StatusAccepted {
responseBody, readErr := io.ReadAll(resp.Body)
Comment thread
m5i-work marked this conversation as resolved.
if readErr != nil {
return fmt.Errorf("read invocation acceptance response: %w", readErr)
}
resp.Body = io.NopCloser(bytes.NewReader(responseBody))
var accepted struct {
InvocationID string `json:"invocation_id"`
}
if json.Unmarshal(responseBody, &accepted) == nil {
invocationID = accepted.InvocationID
}
}
if !raw && invocationID != "" {
fmt.Printf("Invocation: %s\n", invocationID)
}

// Always capture session state from response headers (needed even in raw mode
Expand All @@ -1818,6 +1845,20 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error {
}
captureResponseSession(ctx, rc.azdClient, agentKey, sid, resp, sessionLabel)

if resp.StatusCode < 400 && rc.azdClient != nil && agentKey != "" && invocationID != "" {
effectiveSessionID := sid
if assigned := resp.Header.Get("x-agent-session-id"); assigned != "" {
effectiveSessionID = assigned
}
if err := newInvocationStateStore(rc.azdClient).Save(ctx, agentKey, savedInvocation{
InvocationID: invocationID,
SessionID: effectiveSessionID,
APIVersion: rc.apiVersion,
}); err != nil {
log.Printf("warning: failed to save invocation %s for later retrieval: %v", invocationID, err)
Comment thread
m5i-work marked this conversation as resolved.
}
}

sessionCode := resp.Header.Get("x-adc-response-details")
if err := handleInvocationResponse(
ctx,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,7 @@ func TestInvokeCommandBackgroundValidation(t *testing.T) {
{
name: "rejects local",
args: []string{"--resumable", "--local", "hello"},
want: "supported only for remote Responses agents",
want: "saved-work operations are supported only for remote agents",
},
{
name: "rejects explicit invocations protocol",
Expand Down Expand Up @@ -1170,6 +1170,113 @@ func TestInvokeCommandBackgroundValidation(t *testing.T) {
}
}

func TestInvokeCommandRejectsInvocationsResumeWithInput(t *testing.T) {
t.Parallel()

tests := []struct {
name string
args []string
}{
{
name: "message",
args: []string{"agent", "revised requirements", "--protocol", "invocations", "--resume"},
},
{
name: "input file",
args: []string{"--protocol", "invocations", "--resume", "--input-file", "request.json"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newInvokeCommand(nil)
cmd.SetArgs(tt.args)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

err := cmd.Execute()
localErr, ok := errors.AsType[*azdext.LocalError](err)
require.True(t, ok)
assert.Equal(t, exterrors.CodeInvalidParameter, localErr.Code)
assert.Equal(t, "--resume and --cancel do not accept a message or --input-file", localErr.Message)
assert.Equal(
t,
"remove the input; --resume reconnects to saved work and --cancel cancels saved Responses",
localErr.Suggestion,
)
})
}
}

func TestInvokeCommandRejectsInvocationResumeWithAgentEndpoint(t *testing.T) {
isolateFromAzdDaemon(t)

cmd := newInvokeCommand(nil)
cmd.SetArgs([]string{
"--resume",
"--agent-endpoint",
"https://acct.services.ai.azure.com/api/projects/proj/agents/test-agent/endpoint/protocols/invocations",
})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

err := cmd.Execute()
localErr, ok := errors.AsType[*azdext.LocalError](err)
require.True(t, ok)
assert.Equal(t, exterrors.CodeInvalidParameter, localErr.Code)
assert.Equal(t, "Invocations --resume is not supported with --agent-endpoint", localErr.Message)
assert.Equal(t, "run from an azd project so the saved Invocation can be resolved", localErr.Suggestion)
}

func TestInvokeCommandInvocationResumeDiagnostics(t *testing.T) {
t.Parallel()

tests := []struct {
name string
extCtx *azdext.ExtensionContext
args []string
message string
suggestion string
}{
{
name: "local",
args: []string{"--protocol", "invocations", "--resume", "--local"},
message: "saved-work operations are supported only for remote agents",
suggestion: "remove --local and use a deployed agent",
},
{
name: "raw output",
extCtx: &azdext.ExtensionContext{OutputFormat: outputRaw},
args: []string{"--protocol", "invocations", "--resume"},
message: "--output raw is not supported with saved-work operations",
suggestion: "remove --output raw so azd can manage saved operation state",
},
{
name: "timeout",
args: []string{"--protocol", "invocations", "--resume", "--timeout", "1"},
message: "--timeout is not supported with --resume, --steer, or --cancel",
suggestion: "remove --timeout; saved-work operations manage request timing internally",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newInvokeCommand(tt.extCtx)
cmd.SetArgs(tt.args)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

err := cmd.Execute()
localErr, ok := errors.AsType[*azdext.LocalError](err)
require.True(t, ok)
assert.Equal(t, tt.message, localErr.Message)
assert.Equal(t, tt.suggestion, localErr.Suggestion)
})
}
}

func TestInvokeCommandBackgroundEndpointRoutesHostFailure(t *testing.T) {
isolateFromAzdDaemon(t)

Expand Down
Loading
Loading