Skip to content

Commit 4775e06

Browse files
Antriksh JainCopilot
andcommitted
feat(azure.ai.agents): scaffold nextstep package and isTerminal helper
Add the foundation for context-aware `Next:` guidance described in PR Azure#8057: - New `internal/cmd/nextstep` package with `Suggestion`, `State`, `ServiceState`, `AuthState` types and a format-agnostic `PrintNext` writer that aligns commands on the longest entry and caps output at one primary + one secondary line. - Add an `isTerminal(fd uintptr) bool` helper in `internal/cmd/helpers.go` wrapping `golang.org/x/term`; promote that module from indirect to direct in `go.mod`. - Register `nextstep` in the repo cspell dictionary. No callers yet; resolvers, state assembly, and command wiring land in subsequent commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0cb88fb commit 4775e06

7 files changed

Lines changed: 323 additions & 1 deletion

File tree

cli/azd/.vscode/cspell.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ words:
4242
- opencode
4343
- grpcbroker
4444
- msiexec
45+
- nextstep
4546
- nosec
4647
- npx
4748
- oneof

cli/azd/extensions/azure.ai.agents/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ require (
2828
gopkg.in/yaml.v3 v3.0.1
2929
)
3030

31+
require golang.org/x/term v0.41.0
32+
3133
require (
3234
dario.cat/mergo v1.0.2 // indirect
3335
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
@@ -107,7 +109,6 @@ require (
107109
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
108110
golang.org/x/net v0.52.0 // indirect
109111
golang.org/x/sys v0.42.0 // indirect
110-
golang.org/x/term v0.41.0 // indirect
111112
golang.org/x/text v0.35.0 // indirect
112113
golang.org/x/time v0.14.0 // indirect
113114
)

cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
2626
"github.com/google/uuid"
2727
"go.yaml.in/yaml/v3"
28+
"golang.org/x/term"
2829
)
2930

3031
const (
@@ -835,3 +836,10 @@ func multiProtocolError(
835836
),
836837
)
837838
}
839+
840+
// isTerminal reports whether fd refers to an interactive terminal.
841+
// Used to gate human-only output such as the next-step guidance block.
842+
func isTerminal(fd uintptr) bool {
843+
//nolint:gosec // file descriptors fit in int on all supported platforms
844+
return term.IsTerminal(int(fd))
845+
}

cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,20 @@ func TestProtocolFromAgentYaml(t *testing.T) {
299299
})
300300
}
301301
}
302+
303+
func TestIsTerminal_NonTTY(t *testing.T) {
304+
t.Parallel()
305+
306+
r, w, err := os.Pipe()
307+
if err != nil {
308+
t.Fatalf("os.Pipe: %v", err)
309+
}
310+
t.Cleanup(func() {
311+
_ = r.Close()
312+
_ = w.Close()
313+
})
314+
315+
if isTerminal(r.Fd()) {
316+
t.Errorf("isTerminal(pipe read end) = true, want false")
317+
}
318+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package nextstep
5+
6+
import (
7+
"io"
8+
"slices"
9+
"strings"
10+
)
11+
12+
const (
13+
// primaryPrefix is the leading label of the first suggestion line.
14+
primaryPrefix = "Next: "
15+
// continuationPrefix indents subsequent lines so commands align under
16+
// the first command. Width == len(primaryPrefix).
17+
continuationPrefix = " "
18+
// commandSeparator separates the (possibly padded) command from its
19+
// description. Two-space gap + "-- " per the design spec.
20+
commandSeparator = " -- "
21+
// maxRendered caps the block at one primary + one optional secondary
22+
// line ("more than two lines drowns out command output").
23+
maxRendered = 2
24+
)
25+
26+
// PrintNext writes a "Next:" guidance block to w. Suggestions are sorted
27+
// ascending by Priority (stable; ties preserve input order) and then
28+
// truncated to a primary + optional secondary line. Empty input produces
29+
// no output and no write.
30+
//
31+
// PrintNext does not inspect TTY state or output-format flags — those
32+
// decisions live at the call site so the same renderer can serve both
33+
// interactive stdout writes and string capture for tests / JSON envelopes.
34+
func PrintNext(w io.Writer, suggestions []Suggestion) error {
35+
block := renderBlock(suggestions)
36+
if block == "" {
37+
return nil
38+
}
39+
_, err := io.WriteString(w, block)
40+
return err
41+
}
42+
43+
// renderBlock returns the formatted "Next:" block (with a leading blank
44+
// line and trailing newline) or an empty string when there is nothing to
45+
// render.
46+
func renderBlock(suggestions []Suggestion) string {
47+
if len(suggestions) == 0 {
48+
return ""
49+
}
50+
51+
sorted := slices.Clone(suggestions)
52+
slices.SortStableFunc(sorted, func(a, b Suggestion) int {
53+
return a.Priority - b.Priority
54+
})
55+
if len(sorted) > maxRendered {
56+
sorted = sorted[:maxRendered]
57+
}
58+
59+
cmdWidth := 0
60+
for _, s := range sorted {
61+
if n := len(s.Command); n > cmdWidth {
62+
cmdWidth = n
63+
}
64+
}
65+
66+
var b strings.Builder
67+
// Leading blank line separates the block from preceding output.
68+
b.WriteByte('\n')
69+
for i, s := range sorted {
70+
if i == 0 {
71+
b.WriteString(primaryPrefix)
72+
} else {
73+
b.WriteString(continuationPrefix)
74+
}
75+
b.WriteString(s.Command)
76+
if pad := cmdWidth - len(s.Command); pad > 0 {
77+
b.WriteString(strings.Repeat(" ", pad))
78+
}
79+
b.WriteString(commandSeparator)
80+
b.WriteString(s.Description)
81+
b.WriteByte('\n')
82+
}
83+
return b.String()
84+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package nextstep
5+
6+
import (
7+
"bytes"
8+
"io"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestPrintNext(t *testing.T) {
16+
t.Parallel()
17+
18+
tests := []struct {
19+
name string
20+
suggestions []Suggestion
21+
want string
22+
}{
23+
{
24+
name: "empty input produces no output",
25+
suggestions: nil,
26+
want: "",
27+
},
28+
{
29+
name: "single suggestion renders one line with two-space gap",
30+
suggestions: []Suggestion{
31+
{Command: "azd provision", Description: "set up Foundry"},
32+
},
33+
want: "\nNext: azd provision -- set up Foundry\n",
34+
},
35+
{
36+
name: "two suggestions align on longest command",
37+
// Longest command "azd ai agent invoke 'hi'" is 24 chars.
38+
// "azd ai agent show echo" (22) pads with 2 trailing spaces, then the
39+
// two-space separator + "-- " (commandSeparator = " -- ") so the gap
40+
// between "echo" and "--" totals 4 spaces; the second line has no pad
41+
// so its gap is exactly the 2-space separator.
42+
suggestions: []Suggestion{
43+
{Command: "azd ai agent show echo", Description: "verify status"},
44+
{Command: "azd ai agent invoke 'hi'", Description: "test it"},
45+
},
46+
want: "\n" +
47+
"Next: azd ai agent show echo -- verify status\n" +
48+
" azd ai agent invoke 'hi' -- test it\n",
49+
},
50+
{
51+
name: "more than two suggestions are truncated by priority",
52+
suggestions: []Suggestion{
53+
{Command: "c", Description: "third", Priority: 30},
54+
{Command: "a", Description: "first", Priority: 10},
55+
{Command: "b", Description: "second", Priority: 20},
56+
},
57+
want: "\n" +
58+
"Next: a -- first\n" +
59+
" b -- second\n",
60+
},
61+
{
62+
name: "stable sort preserves input order on equal priorities",
63+
suggestions: []Suggestion{
64+
{Command: "first", Description: "f"},
65+
{Command: "second", Description: "s"},
66+
},
67+
want: "\n" +
68+
"Next: first -- f\n" +
69+
" second -- s\n",
70+
},
71+
}
72+
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
t.Parallel()
76+
77+
var buf bytes.Buffer
78+
require.NoError(t, PrintNext(&buf, tt.suggestions))
79+
assert.Equal(t, tt.want, buf.String())
80+
})
81+
}
82+
}
83+
84+
// failingWriter returns an error on first Write; used to verify PrintNext
85+
// propagates I/O errors from the underlying writer.
86+
type failingWriter struct{}
87+
88+
func (failingWriter) Write(_ []byte) (int, error) {
89+
return 0, io.ErrUnexpectedEOF
90+
}
91+
92+
func TestPrintNext_PropagatesWriteError(t *testing.T) {
93+
t.Parallel()
94+
95+
err := PrintNext(failingWriter{}, []Suggestion{{Command: "x", Description: "y"}})
96+
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
97+
}
98+
99+
func TestPrintNext_EmptyInputSkipsWrite(t *testing.T) {
100+
t.Parallel()
101+
102+
// failingWriter would error if Write were called; nil suggestions
103+
// must short-circuit before any write.
104+
require.NoError(t, PrintNext(failingWriter{}, nil))
105+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
// Package nextstep computes and renders the context-aware "Next:" guidance
5+
// block that azure.ai.agents commands surface at the end of successful (and
6+
// some failing) runs.
7+
//
8+
// The package is split into three concerns:
9+
//
10+
// - State assembly (state.go) — collects everything resolvers may need
11+
// into a single immutable snapshot; partial state never silences
12+
// guidance.
13+
// - Resolvers (resolver.go) — pure functions over *State that return a
14+
// ranked []Suggestion for each command's exit path.
15+
// - Formatters (format.go) — render []Suggestion either to a writer
16+
// (PrintNext) or as a string suitable for embedding in an artifact's
17+
// Metadata["note"] (FormatNextForNote).
18+
//
19+
// Output discipline lives at the call sites: the package never writes to
20+
// os.Stdout directly and never inspects --output flags. Callers gate on
21+
// the isTerminal helper / output mode and choose the writer or JSON
22+
// envelope field accordingly.
23+
package nextstep
24+
25+
// Suggestion is a single line of next-step guidance: a command to run plus
26+
// a one-line description. Suggestions are sorted ascending by Priority
27+
// before rendering (lower = earlier; ties preserve input order).
28+
type Suggestion struct {
29+
Command string
30+
Description string
31+
Priority int
32+
}
33+
34+
// AuthState captures whether a doctor-style auth probe has been run and
35+
// what it found. AuthUnknown (the zero value) means the probe was not run;
36+
// resolvers treat that as "skip auth-conditional advice" rather than
37+
// emitting login-prompt noise on every successful command.
38+
type AuthState int
39+
40+
const (
41+
// AuthUnknown indicates the auth probe was not run for this state.
42+
AuthUnknown AuthState = iota
43+
// AuthAuthed indicates the probe confirmed a usable token.
44+
AuthAuthed
45+
// AuthUnauthed indicates the probe confirmed login is needed.
46+
AuthUnauthed
47+
)
48+
49+
// State is the snapshot resolvers operate on. AssembleState builds one per
50+
// call; there is no shared singleton or cross-command cache. Fields
51+
// marked optional below are populated only by the resolver paths that
52+
// need them — see field docs.
53+
type State struct {
54+
// HasProjectEndpoint reports whether AZURE_AI_PROJECT_ENDPOINT is set
55+
// (and non-empty) in the active azd environment.
56+
HasProjectEndpoint bool
57+
58+
// MissingInfraVars names ${...} references in agent.yaml that map to
59+
// Bicep outputs not yet present in the azd environment (i.e.,
60+
// provision is needed or has been skipped). Named so the resolver can
61+
// surface an actionable hint.
62+
MissingInfraVars []string
63+
64+
// MissingManualVars names ${...} references that map to user-supplied
65+
// variables which are not set in the azd environment.
66+
MissingManualVars []string
67+
68+
// Services is the per-service snapshot derived from azure.yaml plus
69+
// the azd environment (for IsDeployed).
70+
Services []ServiceState
71+
72+
// AgentStatus is the remote agent version status as reported by the
73+
// Foundry API (e.g., "Active", "Creating", "Failed"). Empty when the
74+
// caller did not probe the remote API.
75+
AgentStatus string
76+
77+
// HasOpenAPI reports whether OpenAPIPayload has been populated. The
78+
// payload is populated only when AssembleState is called from a path
79+
// that contacts the agent (e.g., `run`, `doctor`).
80+
HasOpenAPI bool
81+
82+
// OpenAPIPayload is a sample request payload extracted from the
83+
// agent's OpenAPI spec, suitable for an `azd ai agent invoke '...'`
84+
// example. Empty when HasOpenAPI is false.
85+
OpenAPIPayload string
86+
87+
// IsAuthenticated is populated only by the full-sweep `doctor` path.
88+
// Every other resolver receives AuthUnknown and treats
89+
// auth-conditional suggestions as "skip" rather than "tell user to
90+
// log in".
91+
IsAuthenticated AuthState
92+
}
93+
94+
// ServiceState mirrors one entry from the project's services map, plus a
95+
// deployment marker derived from azd environment variables. IsDeployed is
96+
// true when AGENT_<KEY>_VERSION is non-empty in the active environment,
97+
// where <KEY> is the service name upper-cased with hyphens replaced by
98+
// underscores — the convention used by the deploy-time env-var writer in
99+
// project/service_target_agent.go.
100+
type ServiceState struct {
101+
Name string
102+
Host string
103+
Protocol string
104+
RelativePath string
105+
IsDeployed bool
106+
}

0 commit comments

Comments
 (0)