diff --git a/internal/api/handlers_write_devloop_params_test.go b/internal/api/handlers_write_devloop_params_test.go index 08e93694..dd886dba 100644 --- a/internal/api/handlers_write_devloop_params_test.go +++ b/internal/api/handlers_write_devloop_params_test.go @@ -3,6 +3,7 @@ package api_test import ( "net/http" "sort" + "strings" "testing" "github.com/mctlhq/mctl-api/internal/auth" @@ -60,6 +61,20 @@ var devLoopSubmissions = []struct { "execution_request_id": "xr_0000", }, }, + { + // dev_loop.py human-input continuation (mctlhq/mctl-agents#473): the + // pinned, correlated investigate step plus the accepted answers. + name: "investigate, human-input continuation", + operation: "mctl-agents-investigate", + params: map[string]string{ + "issue_url": "https://github.com/mctlhq/mctl-api/issues/372", + "agent_image": pinImageByTag, + "agent_version": "issue-investigator@1.54.0", + "temporal_workflow_id": "dev-loop-mctlhq-mctl-agents-494", + "temporal_run_id": "00000000-0000-0000-0000-000000000000", + "human_input_responses": `[{"request_id":"hir-0123456789abcdef","request_hash":"sha256:` + strings.Repeat("a", 64) + `","value":"library B","received_at":"2026-10-04T10:00:00Z"}]`, + }, + }, { // dev_loop.py investigate step, a published version with a digest. name: "investigate, pinned by digest", @@ -218,9 +233,9 @@ var devLoopParamsPendingDeclaration = []struct { param string value string }{ - // dev_loop.py human-input continuation; not declared by - // cwft-mctl-agents-investigate (mctlhq/mctl-api#372 item 3). - {"mctl-agents-investigate", "human_input_responses", `[{"request_id":"hir-0000000000000000"}]`}, + // human_input_responses (mctl-api#372 item 3) moved to devLoopSubmissions + // above once cwft-mctl-agents-investigate declared it + // (mctlhq/mctl-gitops#1581). // Loop identity (mctlhq/mctl-agents#461, #451) moved to devLoopSubmissions // above: temporal_workflow_id, temporal_run_id and execution_request_id // are now declared on all three DevLoop operations @@ -321,3 +336,30 @@ func TestExecuteOperation_UncorrelatedCallerSendsNoCorrelationParams(t *testing. }) } } + +// human_input_responses is opaque to mctl-api, but its shape is pinned: only +// a JSON array reaches the investigator, and an empty value is an absence, +// so a run without answers keeps the CWFT's exact argv (mctlhq/mctl-gitops#1581). +func TestExecuteOperation_HumanInputResponsesShape(t *testing.T) { + const issue = "https://github.com/mctlhq/mctl-api/issues/372" + t.Run("non-array rejected", func(t *testing.T) { + router, exec := newTestRouter(t) + w := postAs(t, router, "/api/v1/operations/mctl-agents-investigate/execute", map[string]string{ + "issue_url": issue, "human_input_responses": `{"request_id":"hir-0123456789abcdef"}`, + }, auth.NewServiceUser()) + assertStatus(t, w, http.StatusBadRequest) + if len(exec.submittedParams) != 0 { + t.Fatal("a rejected submission reached the executor") + } + }) + t.Run("empty omitted", func(t *testing.T) { + router, exec := newTestRouter(t) + w := postAs(t, router, "/api/v1/operations/mctl-agents-investigate/execute", map[string]string{ + "issue_url": issue, "human_input_responses": "", + }, auth.NewServiceUser()) + assertStatus(t, w, http.StatusAccepted) + if _, ok := exec.submittedParams[len(exec.submittedParams)-1]["human_input_responses"]; ok { + t.Error("an empty human_input_responses reached the executor; it must be omitted") + } + }) +} diff --git a/internal/operations/registry.go b/internal/operations/registry.go index f24d6f08..71054671 100644 --- a/internal/operations/registry.go +++ b/internal/operations/registry.go @@ -806,9 +806,16 @@ var builtinOperations = []Operation{ // DevLoop correlation identifiers (mctlhq/mctl-api#426, // mctlhq/.github#50): dev_loop.py's investigate step sends these; // cwft-mctl-agents-investigate declares all three. - // human_input_responses is deliberately NOT declared here: the CWFT - // does not declare it (mctl-api#372 item 3), so it stays in - // devLoopParamsPendingDeclaration. + // Human-input continuation (mctl-api#372 item 3, mctlhq/mctl-agents#473): + // dev_loop.py sends the accepted answers as a JSON array once a + // human answered a sealed question. cwft-mctl-agents-investigate + // declares it since mctlhq/mctl-gitops#1581. Opaque here: the + // investigator validates every entry (ids, hashes, bounded values) + // before any clone or model call; this only pins the array shape. + // OmitWhenEmpty so a run without answers keeps its exact argv. + {Name: "human_input_responses", Type: "string", Required: false, OmitWhenEmpty: true, + Description: "Optional. JSON array of accepted human-input answers for a DevLoop continuation (mctl-agents#473). Set only by the DevLoop; omit otherwise.", + Pattern: `(?s)^\[.*\]$`}, }, append(agentPinParams("issue-investigator"), devLoopCorrelationParams("temporal_workflow_id", "temporal_run_id", "execution_request_id")...)...), }, diff --git a/internal/operations/registry_cwft_params_test.go b/internal/operations/registry_cwft_params_test.go index edc03761..0350b736 100644 --- a/internal/operations/registry_cwft_params_test.go +++ b/internal/operations/registry_cwft_params_test.go @@ -32,6 +32,7 @@ var cwftDeclaredParams = map[string][]string{ "issue_url", "agent_image", "agent_version", "work_item_id", "execution_id", "temporal_workflow_id", "temporal_run_id", "execution_request_id", + "human_input_responses", }, "mctl-agents-implement": { "service", "slug", "force", "max_proposals", "agent_image", "agent_version",