Skip to content

Commit fb19392

Browse files
Antriksh JainCopilot
andcommitted
feat(azure.ai.agents): add FormatNextForNote renderer for artifact embeds
Phase 3 foundation. Adds a string-returning sibling to PrintNext suitable for embedding a Next: block inside an artifact's Metadata["note"]. No production caller yet; the deploy-hook wire-up lands in commit 3.2. Shape decisions: * No truncation. The artifact note is a contained region (not interleaved with command output), so it should surface every suggestion. PrintNext's maxRendered=2 cap remains in force for the interactive stdout case. * No leading or trailing newline. The artifact renderer prepends its own line break; an embedded "\n...\n" would double-space the output. * Lines 2+ are pre-indented by 4 spaces so the command column stays aligned with line 1 when core azd's artifact renderer (cli/azd/pkg/project/artifact.go:128-130) is called with the typical caller indent of two spaces. The renderer indents line 1 of the note but lines 2+ are flush-left; pre-indenting compensates. Strategy delta D21 — drop currentIndentation from the API. The plan's signature took currentIndentation as a parameter, but the extension cannot know what value core azd's renderer will pass at display time (callers above us choose the indent). Hard-coding 4 spaces matches the two-space caller indent used everywhere in the existing tree. Under deeper or shallower caller indents the lines drift slightly but the note remains readable. Refactor: extracted renderRows(suggestions, limit) from renderBlock so PrintNext (limit=maxRendered) and FormatNextForNote (limit=0, no cap) share the partition-then-render core. Renamed the local variable away from the Go builtin cap. Behavior of renderBlock / PrintNext is unchanged — verified by the existing 7 TestPrintNext subtests. Tests added in format_test.go: - empty input returns "" - single suggestion: no leading or trailing newline - multi-line: line 2 pre-indented by 4 spaces - uncapped: third suggestion preserved (would be dropped by PrintNext) - trailing entry survives ordering - TestFormatNextForNote_HostArtifactAlignment: round-trips a synthetic artifact render to lock the column-alignment contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 793af29 commit fb19392

2 files changed

Lines changed: 143 additions & 10 deletions

File tree

cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format.go

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,53 @@ func PrintNext(w io.Writer, suggestions []Suggestion) error {
4040
return err
4141
}
4242

43+
// FormatNextForNote renders a "Next:" block as a string suitable for
44+
// embedding in an artifact's Metadata["note"]. Unlike PrintNext it does
45+
// not truncate the block (the artifact note is a contained region, not
46+
// interleaved with command output) and does not include a leading or
47+
// trailing newline (the artifact renderer adds its own line break).
48+
//
49+
// Lines 2+ are pre-indented by 4 spaces so the command column stays
50+
// aligned with line 1 when core azd's artifact renderer (which only
51+
// indents the first line of the note) is called with the typical caller
52+
// indent of two spaces — see cli/azd/pkg/project/artifact.go, which
53+
// writes "\n%s %s" with the caller's indent on line 1 only. Under
54+
// deeper or shallower caller indents the lines drift slightly but the
55+
// note remains readable in both cases.
56+
//
57+
// Empty input returns an empty string.
58+
func FormatNextForNote(suggestions []Suggestion) string {
59+
body := renderRows(suggestions, 0)
60+
if body == "" {
61+
return ""
62+
}
63+
return strings.ReplaceAll(strings.TrimSuffix(body, "\n"), "\n", "\n ")
64+
}
65+
4366
// renderBlock returns the formatted "Next:" block (with a leading blank
4467
// line and trailing newline) or an empty string when there is nothing to
45-
// render.
68+
// render. The block is capped at maxRendered visible lines.
69+
func renderBlock(suggestions []Suggestion) string {
70+
body := renderRows(suggestions, maxRendered)
71+
if body == "" {
72+
return ""
73+
}
74+
// Leading blank line separates the block from preceding output.
75+
return "\n" + body
76+
}
77+
78+
// renderRows returns the formatted suggestion lines (one per line,
79+
// terminated with "\n") with no leading blank line. limit caps the
80+
// number of visible suggestions; limit <= 0 means render every
81+
// suggestion.
4682
//
4783
// Truncation is partitioned: at most one Suggestion.Trailing entry is
4884
// reserved for the final visible slot, with remaining slots filled by
4985
// primary (non-trailing) entries in ascending Priority order. The
5086
// trailing reservation lets resolvers emit follow-up nudges (e.g., the
5187
// post-action `azd deploy` line) without having those nudges silently
52-
// dropped when primary suggestions outnumber maxRendered.
53-
func renderBlock(suggestions []Suggestion) string {
88+
// dropped when primary suggestions outnumber the cap.
89+
func renderRows(suggestions []Suggestion, limit int) string {
5490
if len(suggestions) == 0 {
5591
return ""
5692
}
@@ -76,20 +112,25 @@ func renderBlock(suggestions []Suggestion) string {
76112
}
77113

78114
var rendered []Suggestion
79-
if trailing != nil {
80-
budget := maxRendered - 1
115+
if limit > 0 && trailing != nil {
116+
budget := limit - 1
81117
if budget < 0 {
82118
budget = 0
83119
}
84120
if len(primary) > budget {
85121
primary = primary[:budget]
86122
}
87123
rendered = append(primary, *trailing)
88-
} else {
89-
if len(primary) > maxRendered {
90-
primary = primary[:maxRendered]
124+
} else if limit > 0 {
125+
if len(primary) > limit {
126+
primary = primary[:limit]
91127
}
92128
rendered = primary
129+
} else {
130+
rendered = primary
131+
if trailing != nil {
132+
rendered = append(rendered, *trailing)
133+
}
93134
}
94135

95136
if len(rendered) == 0 {
@@ -104,8 +145,6 @@ func renderBlock(suggestions []Suggestion) string {
104145
}
105146

106147
var b strings.Builder
107-
// Leading blank line separates the block from preceding output.
108-
b.WriteByte('\n')
109148
for i, s := range rendered {
110149
if i == 0 {
111150
b.WriteString(primaryPrefix)

cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,97 @@ func TestPrintNext_EmptyInputSkipsWrite(t *testing.T) {
143143
// must short-circuit before any write.
144144
require.NoError(t, PrintNext(failingWriter{}, nil))
145145
}
146+
147+
func TestFormatNextForNote(t *testing.T) {
148+
t.Parallel()
149+
150+
tests := []struct {
151+
name string
152+
suggestions []Suggestion
153+
want string
154+
}{
155+
{
156+
name: "empty input produces empty string",
157+
suggestions: nil,
158+
want: "",
159+
},
160+
{
161+
name: "single suggestion has no leading newline and no trailing newline",
162+
suggestions: []Suggestion{
163+
{Command: "azd ai agent invoke 'hello'", Description: "send a test request", Priority: 10},
164+
},
165+
want: "Next: azd ai agent invoke 'hello' -- send a test request",
166+
},
167+
{
168+
name: "multi-line block pre-indents lines 2+ with 4 spaces",
169+
suggestions: []Suggestion{
170+
{Command: "azd ai agent show", Description: "verify deployment", Priority: 10},
171+
{Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11},
172+
},
173+
want: "Next: azd ai agent show -- verify deployment\n" +
174+
" azd ai agent invoke 'hi' -- send a request",
175+
},
176+
{
177+
name: "uncapped — third suggestion is preserved (unlike PrintNext)",
178+
suggestions: []Suggestion{
179+
{Command: "azd ai agent show", Description: "verify deployment", Priority: 10},
180+
{Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11},
181+
{Command: "see ./agent/README.md", Description: "more sample requests", Priority: 12},
182+
},
183+
want: "Next: azd ai agent show -- verify deployment\n" +
184+
" azd ai agent invoke 'hi' -- send a request\n" +
185+
" see ./agent/README.md -- more sample requests",
186+
},
187+
{
188+
name: "trailing entry surfaces even when not the lowest priority",
189+
suggestions: []Suggestion{
190+
{Command: "azd ai agent show", Description: "verify deployment", Priority: 10},
191+
{Command: "azd deploy", Description: "redeploy after changes", Priority: 90, Trailing: true},
192+
{Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11},
193+
},
194+
want: "Next: azd ai agent show -- verify deployment\n" +
195+
" azd ai agent invoke 'hi' -- send a request\n" +
196+
" azd deploy -- redeploy after changes",
197+
},
198+
}
199+
200+
for _, tc := range tests {
201+
t.Run(tc.name, func(t *testing.T) {
202+
t.Parallel()
203+
got := FormatNextForNote(tc.suggestions)
204+
assert.Equal(t, tc.want, got)
205+
})
206+
}
207+
}
208+
209+
// TestFormatNextForNote_HostArtifactAlignment verifies the 4-space
210+
// pre-indent matches the alignment core azd's artifact renderer produces
211+
// when called with the typical caller indent (currentIndentation == " ").
212+
// Core azd's artifact.go writes the note as:
213+
//
214+
// {indent}- {label}: ...
215+
// {indent} {note} <- only line 1 of the note gets the
216+
// indent+" " prefix; lines 2+ are
217+
// flush-left in the output stream.
218+
//
219+
// FormatNextForNote pre-indents lines 2+ by 4 spaces, which equals
220+
// indent(" ") + " " — i.e. the columns align so the rendered "Next:"
221+
// header on line 1 sits directly above the continuation indent on line 2.
222+
func TestFormatNextForNote_HostArtifactAlignment(t *testing.T) {
223+
t.Parallel()
224+
225+
note := FormatNextForNote([]Suggestion{
226+
{Command: "azd ai agent show", Description: "verify deployment", Priority: 10},
227+
{Command: "azd ai agent invoke 'hi'", Description: "send a request", Priority: 11},
228+
})
229+
230+
// Simulate core azd's render: " - label: location\n " + note + "\n".
231+
const callerIndent = " "
232+
rendered := callerIndent + "- endpoint: https://example/agents/foo/endpoint\n" +
233+
callerIndent + " " + note + "\n"
234+
235+
want := " - endpoint: https://example/agents/foo/endpoint\n" +
236+
" Next: azd ai agent show -- verify deployment\n" +
237+
" azd ai agent invoke 'hi' -- send a request\n"
238+
assert.Equal(t, want, rendered)
239+
}

0 commit comments

Comments
 (0)