Skip to content

Commit 5f80dd6

Browse files
Antriksh JainCopilot
andcommitted
feat(azure.ai.agents): add invoke-local secondary to init "everything ready" Next: (P5.1 C7)
Issue Azure#7975 lines 96-103 specify that the init "everything ready" output should surface two commands, not one: Next: azd ai agent run -- start the agent locally azd ai agent invoke --local "Hello!" -- test it in another terminal Pre-C7 the resolver emitted only `azd ai agent run` (plus the trailing `azd deploy` reminder). Users hit the "now what?" wall after the agent bound its port: they had to remember `azd ai agent invoke --local …` and figure out the right payload for their protocol. Source of truth: issue Azure#7975 lines 96-103. Spec example uses the unqualified `azd ai agent invoke --local "Hello!"` form regardless of how many services the project declares. # Change `ResolveAfterInit`'s default branch (everything-ready case) now appends a second `Suggestion`: Command: azd ai agent invoke --local <payload> Description: test it in another terminal The trailing `azd deploy` reminder stays in place (Priority 90, Trailing:true), so the rendered block is three lines on a fresh, fully-provisioned project. Payload selection: - `len(state.Services) == 1` → `defaultInvokePayload(&state.Services[0])` which already maps `ProtocolInvocations` → `'{"message": "Hello!"}'` (JSON envelope) and everything else → `"Hello!"`. So a single-agent invocations-protocol project gets the JSON shape, single-agent responses-protocol gets the literal, matching what `ResolveAfterRun` has done since commit 2.3. - `len(state.Services) != 1` (zero or multi-agent) → literal `"Hello!"`. Multi-agent: the unqualified `azd ai agent invoke --local` doesn't know which agent the user will pick at runtime, so picking one service's protocol arbitrarily would be wrong half the time. The responses-style literal is what the spec example uses. The suppression branches are untouched: - `hasPlaceholders` (case 2) — neither `run` nor `invoke --local` emitted. Running locally with literal `{{NAME}}` values produces a broken agent; the spec gates `run` on placeholder-clear state, and the invoke secondary is paired with `run` so it inherits the gate. - `len(state.MissingManualVars) > 0` (case 1 of the default-cases block) — the manual-vars renderer ships its own `run` follow-up ("start the agent locally once the values above are set"). The invoke-local secondary is NOT added there: the manual-vars example in the spec (lines 119-127) deliberately stops at `run` to keep the "set values → run" call-to-action focused. # Renderer Both init.go (`init.go:1643`) and init_from_code.go (`:148`) call `nextstep.PrintAllNext`, which has NO line cap (uncapped renderer per commit 4.7's G1 fix). The new third suggestion fits cleanly; no truncation risk. `PrintNext`'s `maxRendered = 2` cap is irrelevant here — it's used by mid-flow resolvers (invoke, show) where ≤2 suggestions naturally occur. # Tests resolver_test.go adds `TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary` with five subcases: 1. Zero services → unqualified invoke with responses payload. Pins the spec-mandated unqualified form. Asserts Priority ordering (run < invoke) so the renderer always emits them in the user-expected order. 2. Single-agent responses protocol → `azd ai agent invoke --local "Hello!"`. 3. Single-agent invocations protocol → `azd ai agent invoke --local '{"message": "Hello!"}'`. Locks the protocol-aware shape. 4. Multi-agent (mixed protocols) → invoke stays unqualified with responses payload. Anti-regression: protects against accidentally picking `state.Services[0].Protocol` for a multi-agent project. 5. Placeholders present → neither `run` nor `invoke --local` emitted. Anti-regression: pairs with the existing `TestResolveAfterInit_UnresolvedPlaceholders` table. Existing `TestResolveAfterInit` table cases (happy path, "existing project chosen, all vars set") still pass without modification — they assert `out[0].Command` (still `azd ai agent run`) and `out[len(out)-1].Command` (still `azd deploy`); the new invoke-local slot inserts in the middle and they don't pin block length. # Affected callers `init.go` and `init_from_code.go` print the new line automatically via `PrintAllNext`. `doctor.go:243`'s no-deploy branch flows through `ResolveAfterInit` too — pre-deploy doctor guidance gets the same upgrade. # Verified - gofmt -s -w . (clean) - go vet ./... (clean) - go test ./... -count=1 (all packages green; nextstep 6.5s, cmd 17.3s, doctor 4.9s) - golangci-lint run ./internal/cmd/... (0 issues) - cspell lint internal/cmd/nextstep/**/*.go --config ../../.vscode/cspell.yaml (0 issues) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f2528f1 commit 5f80dd6

2 files changed

Lines changed: 123 additions & 3 deletions

File tree

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,20 @@ const (
6868
// manual-vars example. The run follow-up is suppressed when
6969
// UnresolvedPlaceholders are also present, since literal
7070
// `{{NAME}}` values would still break the local agent.
71-
// - Otherwise → `azd ai agent run`
72-
// Skipped when only UnresolvedPlaceholders are present, because
73-
// running locally with literal `{{NAME}}` values is broken too.
71+
// - Otherwise → `azd ai agent run` + `azd ai agent invoke
72+
// --local <payload>` secondary
73+
// Spec: issue #7975 lines 96-103. The invoke-local secondary
74+
// lets the user test the agent in another terminal once it's
75+
// running. Payload is protocol-aware when the project has
76+
// exactly one service in state (the unqualified `invoke --local`
77+
// resolves to that service). For multi-agent projects the
78+
// payload defaults to the responses-style `"Hello!"` and the
79+
// command is left unqualified — the user picks the target at
80+
// runtime via the interactive prompt or `--service` flag, the
81+
// same shape the spec example uses.
82+
// Both lines are skipped when only UnresolvedPlaceholders are
83+
// present, because running locally with literal `{{NAME}}`
84+
// values is broken.
7485
//
7586
// All paths append the static "When ready to deploy to Azure…" tail.
7687
func ResolveAfterInit(state *State) []Suggestion {
@@ -149,6 +160,27 @@ func ResolveAfterInit(state *State) []Suggestion {
149160
Description: "start the agent locally",
150161
Priority: priority,
151162
})
163+
priority++
164+
// Invoke-local secondary (issue #7975 lines 99-100). The
165+
// spec's "everything ready" example shows the user a second
166+
// command to try once the agent is running:
167+
// azd ai agent invoke --local "Hello!" -- test it in another terminal
168+
// Single-agent projects get a protocol-aware payload (matches
169+
// the protocol the agent's `/invocations` or `/responses`
170+
// endpoint expects). Multi-agent projects fall back to the
171+
// responses-style "Hello!" literal because the unqualified
172+
// command shape doesn't know which service the user will
173+
// pick at runtime — mirroring the spec example which also
174+
// uses the unqualified form.
175+
invokePayload := invokeResponsesPayload
176+
if len(state.Services) == 1 {
177+
invokePayload = defaultInvokePayload(&state.Services[0])
178+
}
179+
out = append(out, Suggestion{
180+
Command: fmt.Sprintf("azd ai agent invoke --local %s", invokePayload),
181+
Description: "test it in another terminal",
182+
Priority: priority,
183+
})
152184
}
153185

154186
out = append(out, Suggestion{

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,94 @@ func TestResolveAfterInit_ManualVarsSingleEmitsEnrichedShape(t *testing.T) {
213213
assert.True(t, out[2].Trailing)
214214
}
215215

216+
// TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary locks
217+
// the spec-mandated two-line "everything ready" shape from issue #7975
218+
// lines 96-103: after `azd ai agent run`, append
219+
// `azd ai agent invoke --local <payload>` so the user knows what to
220+
// try in another terminal. Also verifies protocol-aware payload selection
221+
// (single-service state) and the priority ordering (run before invoke).
222+
func TestResolveAfterInit_EverythingReady_EmitsInvokeLocalSecondary(t *testing.T) {
223+
t.Parallel()
224+
225+
t.Run("zero services → unqualified invoke with responses payload", func(t *testing.T) {
226+
t.Parallel()
227+
state := &State{HasProjectEndpoint: true}
228+
out := ResolveAfterInit(state)
229+
// run + invoke --local + trailing.
230+
require.Len(t, out, 3)
231+
assert.Equal(t, "azd ai agent run", out[0].Command)
232+
assert.Equal(t, "start the agent locally", out[0].Description)
233+
assert.Equal(t, `azd ai agent invoke --local "Hello!"`, out[1].Command)
234+
assert.Equal(t, "test it in another terminal", out[1].Description)
235+
assert.Less(t, out[0].Priority, out[1].Priority,
236+
"run must precede invoke --local; the renderer sorts by Priority")
237+
assert.Equal(t, "azd deploy", out[2].Command)
238+
assert.True(t, out[2].Trailing)
239+
})
240+
241+
t.Run("single-agent responses protocol → invoke uses \"Hello!\"", func(t *testing.T) {
242+
t.Parallel()
243+
state := &State{
244+
HasProjectEndpoint: true,
245+
Services: []ServiceState{{Name: "echo", Protocol: ProtocolResponses}},
246+
}
247+
out := ResolveAfterInit(state)
248+
require.Len(t, out, 3)
249+
assert.Equal(t, "azd ai agent run", out[0].Command)
250+
assert.Equal(t, `azd ai agent invoke --local "Hello!"`, out[1].Command)
251+
})
252+
253+
t.Run("single-agent invocations protocol → invoke uses JSON envelope", func(t *testing.T) {
254+
t.Parallel()
255+
state := &State{
256+
HasProjectEndpoint: true,
257+
Services: []ServiceState{{Name: "echo", Protocol: ProtocolInvocations}},
258+
}
259+
out := ResolveAfterInit(state)
260+
require.Len(t, out, 3)
261+
assert.Equal(t, "azd ai agent run", out[0].Command)
262+
assert.Equal(t, `azd ai agent invoke --local '{"message": "Hello!"}'`, out[1].Command)
263+
})
264+
265+
t.Run("multi-agent → invoke stays unqualified, defaults to responses payload", func(t *testing.T) {
266+
t.Parallel()
267+
state := &State{
268+
HasProjectEndpoint: true,
269+
Services: []ServiceState{
270+
{Name: "alpha", Protocol: ProtocolInvocations},
271+
{Name: "beta", Protocol: ProtocolResponses},
272+
},
273+
}
274+
out := ResolveAfterInit(state)
275+
require.Len(t, out, 3)
276+
assert.Equal(t, "azd ai agent run", out[0].Command)
277+
// Multi-agent: the unqualified `invoke --local` doesn't know
278+
// which service the user will pick at runtime, so use the
279+
// safest generic payload (responses-style "Hello!") instead
280+
// of picking one service's protocol arbitrarily.
281+
assert.Equal(t, `azd ai agent invoke --local "Hello!"`, out[1].Command)
282+
})
283+
284+
t.Run("placeholders present → invoke-local secondary suppressed (with run)", func(t *testing.T) {
285+
// Placeholders block local run entirely — the spec's default
286+
// branch is gated on !hasPlaceholders, so neither `run` nor
287+
// the invoke-local follow-up should appear when literal
288+
// {{NAME}} values would land in the running container.
289+
t.Parallel()
290+
state := &State{
291+
HasProjectEndpoint: true,
292+
UnresolvedPlaceholders: []string{"FOO"},
293+
}
294+
out := ResolveAfterInit(state)
295+
for _, s := range out {
296+
assert.NotContains(t, s.Command, "azd ai agent invoke --local",
297+
"invoke --local must not be emitted while placeholders are unresolved")
298+
assert.NotEqual(t, "azd ai agent run", s.Command,
299+
"azd ai agent run must not be emitted while placeholders are unresolved")
300+
}
301+
})
302+
}
303+
216304
// TestResolveAfterInit_ToolboxReproRendersAllCategories locks the full
217305
// regression for the toolbox-sample bug end-to-end: the state contains
218306
// BOTH an unresolved manifest placeholder AND a missing manual env var,

0 commit comments

Comments
 (0)