Skip to content

fix(composer): expand template expressions in elicitation messages#4313

Open
saschabuehrle wants to merge 2 commits intostacklok:mainfrom
saschabuehrle:fix/issue-4312
Open

fix(composer): expand template expressions in elicitation messages#4313
saschabuehrle wants to merge 2 commits intostacklok:mainfrom
saschabuehrle:fix/issue-4312

Conversation

@saschabuehrle
Copy link

Bug

#4312 — Template expressions like {{.params.owner}} in elicitation step message fields are passed through as raw placeholders instead of being expanded.

Fix

Adds template expansion for step.Elicitation.Message in executeElicitationStep() before passing it to RequestElicitation(). This mirrors how executeToolStep() already expands step.Arguments through 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

  • Added TestWorkflowEngine_ElicitationMessageTemplateExpansion integration 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

Copy link
Contributor

@jerm-dro jerm-dro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 637 to 654
// 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +530 to +587

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")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@saschabuehrle
Copy link
Author

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.

@github-actions github-actions bot added the size/XS Extra small PR: < 100 lines changed label Mar 23, 2026
@codecov
Copy link

codecov bot commented Mar 23, 2026

Codecov Report

❌ Patch coverage is 50.00000% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.47%. Comparing base (0108b11) to head (0b280a1).
⚠️ Report is 18 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/composer/workflow_engine.go 50.00% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4313      +/-   ##
==========================================
- Coverage   68.77%   68.47%   -0.30%     
==========================================
  Files         473      478       +5     
  Lines       47919    48499     +580     
==========================================
+ Hits        32955    33211     +256     
- Misses      12299    12409     +110     
- Partials     2665     2879     +214     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@saschabuehrle
Copy link
Author

Pushed an update for both points.

The step config stays immutable now (message expansion happens on a copied elicitation config), and the integration coverage is table-driven with both templated and plain message cases.

Greetings, saschabuehrle

@github-actions github-actions bot added size/XS Extra small PR: < 100 lines changed and removed size/XS Extra small PR: < 100 lines changed labels Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants