Return what a Duo session produced, and keep each get_duo_session poll cheap

What does this MR do and why?

When a Duo session does work for you — opens a merge request, files an issue, posts a review comment — get_duo_session now hands you the link. Before, it only echoed the session's last chat message, so whatever the session produced was not in the response as data.

That is the change worth reviewing. Four smaller fixes to the same tool ride along:

  • A poll reads one message instead of the whole chat history. The old query always loaded every chat-log row for the session; the full read now happens only when the answer is hidden behind a trailing tool call.
  • The poll hint matches reality: 120 seconds while the session waits for its CI job, 30 once it is running, instead of a flat 30 that was wrong in both directions.
  • One status string. get_duo_session said input required, list_duo_sessions said input_required. Now both say input_required, so a caller can feed one straight into the other.
  • No references to tools that do not exist. The description and docs named trigger_duo_flow and ask_duo_agent.

Plus specs for the 500-character truncation rule, which was real behaviour that nothing asserted.

Closes #627561 (closed), which lists this as seven gaps and eleven acceptance criteria.

References

Closes #627561 (closed). Related: !250316 (merged) (added the tool), !254979 (merged) (start_duo_session), !256739 (merged) (merged since), &21395 (closed).

Two things a reviewer should know before reading the diff

The issue names a tool that does not exist

Its Scope said "Replace all wrong tool names with add_duo_session and send_duo_session_input", and its Why called those "the real tools". The tool shipped as start_duo_session in !254979 (merged), after the issue was written, so implementing that line as written would have swapped three dead names for a fourth. I corrected the issue description and recorded it there.

The epic description (&21395 (closed)) is stale in the same way — it documents trigger_duo_flow, approve_duo_flow and list_duo_agents, none of which exist. Not this MR's job, but someone should fix it.

Two decisions were the issue author's to make, and he is on leave

Amr (@adruid) is out until 2026-12-18, so the two questions the issue left open went to @fdegier. Both answers, and the evidence behind them, are in that same note rather than only here.

Screenshots or screen recordings

A real Developer-flow session on a local GDK, asked to edit a README and open a merge request. It ran in a CI job, opened !170, and finished. The same get_duo_session call against master and against this branch:

Before — structuredContent:

{"workflow_id":996,"status":"finished",
 "web_url":"http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/996"}

After — structuredContent, run again on the same session after the latest commit:

{
  "workflow_id": 996,
  "status": "finished",
  "web_url": "http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/996",
  "created_merge_requests": [
    { "title": "Draft: Add hello from MCP validation to README",
      "url": "http://gdk.test:3000/gitlab-duo/test/-/merge_requests/170" }
  ],
  "created_work_items": [],
  "created_notes": []
}

All three keys are always present, empty array and all. There is no output schema in this MCP server, so the shape of the response is the only contract a caller has, and dropping empty collections made "created nothing" indistinguishable from "this GitLab cannot report it".

Worth being precise about what changed, because this session's agent happened to mention the merge request in its closing prose, so the URL was visible on master too — as free text the model chose to write. What is new is that the link is in created_merge_requests as data, whether or not the agent mentions it. The response text also gains a Merge request created: <url> line.

The other three changes, for completeness:

Before After
Poll hint, queued session Poll again in 30 seconds. Poll again in 120 seconds.
Status of a waiting session "status": "input required" "status": "input_required"
Status of an approval session "status": "tool call approval required" "status": "tool_call_approval_required"

How to set up and validate locally

This needs the full Duo stack, because the point is a session that really creates a merge request: duo-workflow-service, gitlab-ai-gateway and a registered runner. gdk status should show all three.

Two things that will otherwise waste your time:

  • Use Authorization: Bearer, not PRIVATE-TOKEN. Both work for tools/call, but initialize rejects PRIVATE-TOKEN with 401, which matters as soon as a real MCP client is involved.
  • Every jq filter below ends in // . so that an auth or request failure prints the error instead of a bare null. Without it, an unset $TOKEN looks like an empty success.
  1. Make a token. mcp is a valid PAT scope but no UI offers it, so use rails console:

    PersonalAccessToken.create!(
      user: User.find_by_username('root'), name: 'mcp', scopes: [:mcp],
      expires_at: 7.days.from_now,
      organization: Organizations::Organization.default_organization
    ).token

    Then export it, because every call below reads $TOKEN:

    export TOKEN='glpat-...'
  2. Find a Developer-flow consumer in a project whose repository the flow can push to:

    Ai::Catalog::ItemConsumer.includes(:item).filter_map { |c|
      [c.id, c.project&.full_path, c.item&.name] if c.item&.item_type == 'flow'
    }
    # => [[723, "gitlab-duo/test", "Developer"], [772, "gitlab-duo/spring-3", "Developer"], ...]
  3. Start a session and ask it for a merge request:

    curl -s -X POST http://gdk.test:3000/api/v4/mcp \
      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"start_duo_session",
           "arguments":{"project_id":"gitlab-duo/test","ai_catalog_item_consumer_id":723,
            "goal":"Add a line saying \"hello from MCP validation\" to the end of README.md, commit it on a new branch, and open a merge request for it."}}}' \
      | jq -r '.result.content[0].text // .'

    What I got — note this call went to master on :3000, which is why the hint is 30; on this branch the same call says 120, because a just-started session is created:

    Session 996 started. It runs in a CI job, which can take a few minutes to start. Poll get_duo_session with workflow_id=996 in 30 seconds.
  4. Wait for it. Mine took a few minutes; poll until status is finished:

    curl -s -X POST http://gdk.test:3000/api/v4/mcp \
      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"get_duo_session",
           "arguments":{"workflow_id":996}}}' | jq -c '.result.structuredContent // .'
  5. Confirm the session really did create a merge request, and that the link row exists:

    w = Ai::DuoWorkflows::Workflow.find(996)
    Ai::DuoWorkflows::WorkflowMergeRequest.where(workflow: w).map { |l| [l.link_type, l.merge_request.iid] }
    # => [["created", 170]]
  6. Now the actual test. Serve this branch on a spare port against the same database rather than switching branches under gdk — RAILS_ENV=development bin/rails server -p 3001 -b 127.0.0.1 — and run the step 4 call against both ports. Port 3000 keeps serving master, which gives a true side-by-side. The master response has three keys; this branch adds the three created_* arrays, populated for the merge request.

Driving it from an MCP client instead of curl

curl covers the wire format but not what an MCP client does first, which is initialize — and that is the call that rejects PRIVATE-TOKEN. To point Claude Code at the local GDK, put this in .mcp.json at the project root:

{
  "mcpServers": {
    "gdk-duo": {
      "type": "http",
      "url": "http://127.0.0.1:3001/api/v4/mcp",
      "headers": { "Authorization": "Bearer ${GDK_MCP_TOKEN}" }
    }
  }
}

The token goes in an env var so no secret lands in the file, and it is read at startup, so:

export GDK_MCP_TOKEN="$TOKEN"   # the same token as step 1

Then restart the session. Use port 3000 instead if the branch is checked out in your main GDK.

The handshake, verified on the branch port:

{"protocolVersion":"2025-06-18","serverInfo":{"name":"Official GitLab MCP Server","version":"19.5.0-pre"}}

tools/list shows the tool now describes itself correctly, which is the registration and schema path rather than the response path:

Check a Duo Agent Platform session started by start_duo_session. Running sessions include a
poll_after_seconds hint. Finished and input-required sessions include the latest agent answer.
Any merge request, work item or comment the session created is included once it exists.
Approval-required sessions need send_duo_session_input instead of polling.

Performance

The read that grew without bound is gone from the poll path. But the net cost is not a straight win — returning what the session produced costs more than dropping the history read saves.

The numbers, and why the issue overstated the saving

Measured on a session with 126 blobs across 46 checkpoints, GraphQL executed directly, median of 15 runs.

Query shape Queries Median
lastDuoMessage only 22 66.3 ms
duoMessages only 23 69.1 ms
both, as master asks 25 72.2 ms
lastDuoMessage + the new links 29 95.4 ms

Dropping duoMessages is worth 3 queries and about 6 ms, roughly 8%. That is far less than the issue implies, because at this size the fixed authorization and resolution work dominates and 126 blobs is small.

Returning what the session produced costs 7 queries and about 29 ms when there is a merge request to resolve, and 2 queries when there is not. So:

  • on a session that produced an MR, this branch is slower than master (29 queries / 95.4 ms against 25 / 72.2);
  • on a polling session that has produced nothing yet — the case that repeats every 30 seconds — it is about neutral (24 queries against 25).

That trade seems right, since the cost only appears when there is something worth reporting, but it is the reviewer's call, not mine to bury.

The fallback is the real ceiling

The table above is not the worst case. When the last chat entry is a tool call, the second query runs, and that is a full GraphQL execution of its own. Measured end to end through the tool on throwaway finished sessions with a one-message log — a different basis from the table above, so do not compare the two directly:

Last chat entry Queries Median
the agent's answer 23 77.8 ms
a tool call, so the history read fires 37 144.9 ms

Both return the same answer, which is the point of the fallback. It happens at most once per session, at the end, rather than on every poll.

What the change actually buys

The unbounded read is no longer on the poll path:

Field Blob SQL
lastDuoMessage ORDER BY "id" DESC LIMIT 1 — one row
duoMessages ORDER BY "id" ASC, no limit — all 126 rows

Note that a query count cannot see this: the growing read is one query returning more rows. An earlier version of the spec compared counts across a growing session and passed on master as well, so it has been replaced — see below.

So AC 6 ("the cost of one poll does not grow with the length of the session") is met in the sense that the growing component is gone. It is not met in the sense of the poll becoming dramatically cheaper today.

For agents

Why the history read is gated on status, not dropped

The issue offered two options for Gap 4: drop duoMessages and accept that a trailing empty or tool-only turn hides the answer, or keep it and bound how much history the tool reads.

The second is not available as written. duoMessages takes no arguments, and the cost sits in Workflow#channel_message_history, which walks the ancestor chain and loads the blobs before anything the tool could slice. Bounding it means adding a limit argument to the GraphQL field and pushing it into ChannelValuesReconstructor.

The first loses real answers: four specs that predate this MR cover exactly the case where the last chat entry is a tool call or an empty assistant turn and the answer is further back. So latest_agent_message keeps a fallback, gated on ANSWER_STATUSES — finished, input_required, both approval statuses, paused. A created or running poll never reads the history.

The second query is a separate unregistered document, so it builds its variables with build_variables_for_version and reads its result under MESSAGE_HISTORY_OPERATION_NAME rather than borrowing the main query's operation_name. Both were borrowed originally, which is correct today and would silently break a future version.

A failure in that second read returns HISTORY_UNREADABLE rather than falling through to "Session finished, but no final answer was produced", which would be both wrong and final-sounding. The response stays successful because the status, web_url and any created artifacts come from the first query and are still valid. Note this differs from master, where the history read lives in the main query and any failure in it fails the whole call through process_result.

The spec that guards AC 6

'does not read the chat history while the session runs' asserts that Ai::DuoWorkflows::WorkflowCheckpointEventPresenter.batch_duo_messages is never reached during a running poll. It fails on master with received: 1 time and passes here, which is the property worth pinning.

It replaces a version that compared query counts across a growing session. That one passed on master too, for two reasons: the history read is a single query returning more rows, so a count cannot see it grow; and in specs incremental_blob_gate.for_graphql? is off, so duoMessages comes from the checkpoint JSON with no blob SQL at all. The spy holds on both sides of that gate, because the legacy path also goes through batch_duo_messages before branching.

WorkflowMergeRequest and WorkflowWorkItem enumerate source: 0, created: 1; WorkflowNote enumerates created: 1, triggered: 2, where triggered is the @GitLabDuo mention that summoned the flow. Only CREATED is requested in all three, and 'ignores the merge request, work item and comment the session was started from' fails if any of those filters is dropped.

A note has no title, so its label is a 160-character preview of the body, matching list_duo_sessions' goal preview.

DuoWorkflowMergeRequestLink, DuoWorkflowWorkItemLink and DuoWorkflowNoteLink all inherit WorkflowLinkBaseType and expose no id, so the require-selections lint rule wants id on the artifact but not on the link nodes themselves.

Why an unreadable artifact must look like nothing

GraphQL redacts an artifact the caller cannot read to nil, and the if artifact guard drops it, so the array comes back empty. That is deliberate and must stay: a count or a flag there would tell a compliance reviewer that merge requests exist in a project they cannot see. 'omits the merge request rather than reporting that one exists' covers it through the path that can actually happen — a Guest with the read_agent_artifacts custom role reaching the session through WorkflowPolicy's compliance rule, with no access to the private project's merge requests. That needs custom_roles and project_level_compliance_dashboard licensed, since EE::ProjectPolicy prevents read_agent_artifacts without the latter.

Truncation

running truncates at 500; finished, input_required, both approval statuses and paused do not. That was already true and had no spec, which is why the issue could not tell whether it was deliberate. There are five now. MESSAGE_LIMIT replaces the inline 500.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

🤖 Generated with Claude Code

Edited by Fred de Gier

Merge request reports

Loading
Loading