fix(composer): expand template expressions in elicitation messages#4313
fix(composer): expand template expressions in elicitation messages#4313saschabuehrle wants to merge 1 commit intostacklok:mainfrom
Conversation
jerm-dro
left a comment
There was a problem hiding this comment.
Thanks for picking this up! Two requests before merging: avoid mutating the step definition (use an immutable assignment pattern), and restructure the test as table-driven with a few more cases. Details in the inline comments.
| // Expand template expressions in elicitation message (e.g. {{.params.owner}}) | ||
| if step.Elicitation.Message != "" { | ||
| wrapper := map[string]any{"message": step.Elicitation.Message} | ||
| expanded, expandErr := e.templateExpander.Expand(ctx, wrapper, workflowCtx) | ||
| if expandErr != nil { | ||
| err := fmt.Errorf("%w: failed to expand elicitation message for step %s: %v", | ||
| ErrTemplateExpansion, step.ID, expandErr) | ||
| workflowCtx.RecordStepFailure(step.ID, err) | ||
| return err | ||
| } | ||
| if msg, ok := expanded["message"].(string); ok { | ||
| step.Elicitation.Message = msg | ||
| } | ||
| } | ||
|
|
||
| // Request elicitation (synchronous - blocks until response or timeout) | ||
| // Per MCP 2025-06-18: SDK handles JSON-RPC ID correlation internally | ||
| response, err := e.elicitationHandler.RequestElicitation(ctx, workflowCtx.WorkflowID, step.ID, step.Elicitation) |
There was a problem hiding this comment.
This mutates step.Elicitation.Message in place. Since step is a pointer to the workflow definition, the original template could be lost after expansion — e.g., if workflows are re-executed or steps retried, the template wouldn't be available for re-expansion.
Compare with executeToolStep (line 404) which stores the expanded result in a local expandedArgs variable and never modifies step.Arguments.
Suggest constructing the config immutably via an immediately-invoked function, and passing the result to RequestElicitation:
// Expand template expressions in elicitation message (e.g. {{.params.owner}})
elicitCfg, err := func() (*ElicitationConfig, error) {
if step.Elicitation.Message == "" {
return step.Elicitation, nil
}
wrapper := map[string]any{"message": step.Elicitation.Message}
expanded, expandErr := e.templateExpander.Expand(ctx, wrapper, workflowCtx)
if expandErr != nil {
return nil, fmt.Errorf("%w: failed to expand elicitation message for step %s: %v",
ErrTemplateExpansion, step.ID, expandErr)
}
if msg, ok := expanded["message"].(string); ok {
cfgCopy := *step.Elicitation
cfgCopy.Message = msg
return &cfgCopy, nil
}
return step.Elicitation, nil
}()
if err != nil {
workflowCtx.RecordStepFailure(step.ID, err)
return err
}
response, err := e.elicitationHandler.RequestElicitation(ctx, workflowCtx.WorkflowID, step.ID, elicitCfg)|
|
||
| func TestWorkflowEngine_ElicitationMessageTemplateExpansion(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| te := newTestEngine(t) | ||
| mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) | ||
|
|
||
| // Capture the elicitation request to verify the message was expanded | ||
| var capturedReq mcp.ElicitationRequest | ||
| mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).DoAndReturn( | ||
| func(_ context.Context, req mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { | ||
| capturedReq = req | ||
| return &mcp.ElicitationResult{ | ||
| ElicitationResponse: mcp.ElicitationResponse{ | ||
| Action: mcp.ElicitationResponseActionAccept, | ||
| Content: map[string]any{"confirmed": true}, | ||
| }, | ||
| }, nil | ||
| }, | ||
| ) | ||
|
|
||
| handler := NewDefaultElicitationHandler(mockSDK) | ||
| stateStore := NewInMemoryStateStore(1*time.Minute, 1*time.Hour) | ||
| engine := NewWorkflowEngine(te.Router, te.Backend, handler, stateStore, nil, nil) | ||
|
|
||
| workflow := &WorkflowDefinition{ | ||
| Name: "template-elicit", | ||
| Steps: []WorkflowStep{ | ||
| { | ||
| ID: "ask", | ||
| Type: StepTypeElicitation, | ||
| Elicitation: &ElicitationConfig{ | ||
| Message: "Deploy {{.params.repo}} to {{.params.env}}?", | ||
| Schema: map[string]any{ | ||
| "type": "object", | ||
| "properties": map[string]any{ | ||
| "confirmed": map[string]any{"type": "boolean"}, | ||
| }, | ||
| }, | ||
| Timeout: 1 * time.Minute, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| params := map[string]any{ | ||
| "repo": "acme/widget", | ||
| "env": "production", | ||
| } | ||
|
|
||
| result, err := engine.ExecuteWorkflow(context.Background(), workflow, params) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, WorkflowStatusCompleted, result.Status) | ||
|
|
||
| // Verify that template placeholders were expanded in the message | ||
| assert.Equal(t, "Deploy acme/widget to production?", capturedReq.Params.Message, | ||
| "elicitation message should have template expressions expanded") | ||
| } |
There was a problem hiding this comment.
Could you restructure this as a table-driven test? The happy path is well covered, but there are a few more cases worth exercising. Something like:
func TestWorkflowEngine_ElicitationMessageTemplateExpansion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
params map[string]any
expectedMessage string
expectError bool
}{
{
name: "template expressions expanded",
message: "Deploy {{.params.repo}} to {{.params.env}}?",
params: map[string]any{"repo": "acme/widget", "env": "production"},
expectedMessage: "Deploy acme/widget to production?",
},
{
name: "empty message skips expansion",
message: "",
params: map[string]any{"repo": "acme/widget"},
expectedMessage: "",
},
{
name: "no template expressions passes through unchanged",
message: "Are you sure?",
params: map[string]any{},
expectedMessage: "Are you sure?",
},
{
name: "malformed template returns error",
message: "Deploy {{.params.repo to env",
params: map[string]any{"repo": "acme/widget"},
expectError: true,
},
}
// ...
}This matches the table-driven style used elsewhere in this file (e.g., TestDefaultElicitationHandler_SDKErrorHandling).
|
Thanks for the review — I’ve gone through the requested changes and queued follow-up updates. I’ll push the revision in a dedicated follow-up pass. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4313 +/- ##
==========================================
- Coverage 68.77% 68.69% -0.08%
==========================================
Files 473 473
Lines 47919 47957 +38
==========================================
- Hits 32955 32943 -12
- Misses 12299 12300 +1
- Partials 2665 2714 +49 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Bug
#4312 — Template expressions like
{{.params.owner}}in elicitation stepmessagefields are passed through as raw placeholders instead of being expanded.Fix
Adds template expansion for
step.Elicitation.MessageinexecuteElicitationStep()before passing it toRequestElicitation(). This mirrors howexecuteToolStep()already expandsstep.Argumentsthrough the template expander (line ~404).The expansion wraps the message string in a temporary map to reuse the existing public
TemplateExpander.Expand()interface, keeping the change minimal and consistent with the established expansion pattern.Testing
TestWorkflowEngine_ElicitationMessageTemplateExpansionintegration test that verifies{{.params.repo}}and{{.params.env}}placeholders in an elicitation message are expanded to their resolved values before reaching the SDK elicitation requester.Happy to address any feedback.
Greetings, saschabuehrle