Skip to content

fix(mosaico): route on the latest user turn of a forwarded conversation#2561

Merged
ofir-frd merged 7 commits into
mainfrom
fix/mosaico-dispatch-conversation-intent
Jul 26, 2026
Merged

fix(mosaico): route on the latest user turn of a forwarded conversation#2561
ofir-frd merged 7 commits into
mainfrom
fix/mosaico-dispatch-conversation-intent

Conversation

@ofir-frd

@ofir-frd ofir-frd commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

The MOSAICO reference agent forwards the whole conversation as one text part per turn, shaped {role}: {content}, which the A2A SDK joins with newlines. Dispatch treated that blob as a single fresh request, which produced five distinct defects. All five were reproduced against the deployed agent.

Worst first: the agent answers about the wrong patch, silently

_extract_diff used a non-greedy re.search, so the oldest fenced diff won. Live, before:

blob = old auth.py diff, then new billing/invoice.py diff, final turn "forget that, describe this new billing patch instead"
TASK_STATE_COMPLETED, description entirely about password checking. No mention of invoice or billing.

A confident, well-formed answer about the wrong code, with nothing signalling the error. After: ### PR Type + "Fixed invoice total calculation to include quantity."

Verb selection ignored what the user asked

_detect_verb looped _VALID_VERBS = ("review", "improve", "describe", "ask") and returned the first entry found anywhere, so selection was by tuple position. This was never multi-turn-specific — single-turn requests broke too:

input before after
describe this PR describe describe
Now describe it instead, do not review review describe
can you improve this? do not review review improve
do not review, describe this review describe

Since pr-agent's own output contains Estimated effort to review:, one review made every later turn sticky — leaving 3 of 4 advertised skills unreachable.

Three more

  • A stale PR URL in history took unconditional precedence over a fresh inline diff. Live: TASK_STATE_FAILED "could not fetch a public diff for .../pull/999999999" with a perfectly good diff in the current turn. Fires on any private repo URL too, not just a 404.
  • _diff_prose returned text[:m.start()], discarding everything after the first raw diff marker — so the user's latest turn never reached the matcher.
  • A raw diff opening a forwarded turn arrives as user: diff --git ..., so parse_unified_diff returned [] and the run empty-fell-back to "no output produced". Turn-splitting fixes this for free.
  • _DIFF_BODY_LINE_RE did not recognise git's extended header (rename from/to, similarity index, new/deleted file mode, binary), so the first such line ended body mode and the index/---/+++ lines after it leaked into the prose the verb matcher reads. A patch containing rename to improve.py overrode a user's explicit "review this" and routed to improve; a ? in a leaked path routed to ask.

Approach

Blob detection is deliberately conservative: the text must both open with a role label and carry at least two. Otherwise it is treated as one segment and behaves exactly as before. Verb resolves from the latest user turn by position in the text; agent turns are never consulted for intent. Context (diff or URL) comes from the newest turn supplying one, agent turns included — a coding agent may be the party that produced the patch.

Two details were taken from the running reference agent rather than assumed: turn content is multi-line, so only the first line of a turn carries a label; and labels are user/agent, not user/assistant. Matching only assistant would have been a no-op in production.

Negation is handled adjacently (negator within two words of the verb). Wider handling is intent classification and does not belong in a regex router. Guarded against false positives: there is no bug, review this and i do not have time, review this both still route to review.

Verification

1485 → 1533 passed, 1 skipped, 1 xfailed. 48 tests added, no existing test modified.

Live A/B ran against the deployed stack with only dispatch.py bind-mounted, after confirming the image's copy was byte-identical to main.

Deliberately not fixed

Multiple fenced diffs in a single turn (last wins); prose immediately after a raw patch that begins with a markdown bullet, which is indistinguishable from a deletion line; non-adjacent negation. The /improve HTML-table and diff_provider capability issues are separate and out of scope here.

The MOSAICO reference agent forwards the WHOLE conversation to a solution agent,
one A2A text part per turn shaped "{role}: {content}", which the A2A SDK joins
with newlines. dispatch.py treated that blob as a single fresh request, which
caused four routing defects with one shared root cause: "latest user intent"
was never modelled.

- _extract_diff took the first fenced diff, so a conversation containing a
  corrected re-paste was answered against the superseded patch. This is the
  worst of the four: the reply is confident and well formed, just about the
  wrong code, and nothing signals it.
- The verb was picked by _VALID_VERBS tuple order, so the first entry present
  anywhere in the text won. Even single-turn, "now describe it, do not review"
  resolved to review. Forwarded history made it near-certain: pr-agent's own
  output carries "Estimated effort to review:", which matches the verb pattern
  (the "PR Reviewer Guide" heading does not -- review\b does not match
  "reviewer" -- so that heading lands on review via the default instead).
- _diff_prose kept only the text before the first raw diff marker, discarding
  everything after it, so a request written after a pasted unfenced patch never
  reached the matcher at all.
- A PR URL was looked for across the whole blob before any diff, so a URL that
  cannot be fetched -- a private repo as much as a dead link -- failed the task
  outright and silently discarded a good diff supplied in the current turn.

Turns are now split on role-label lines (conservatively: the text must open with
a label and carry at least two, so single-turn requests are untouched, and a
turn's content stays multi-line). The verb is resolved from the latest user turn
and, within it, by position in the text rather than tuple order, skipping negated
occurrences; agent turns are never consulted for intent. _diff_prose now excises
the patch body and keeps the prose on both sides. The diff/PR to act on is taken
from the newest turn that supplies one -- agent turns included, since a coding
agent may be the one that produced the patch -- so a fresh inline diff beats an
unfetchable URL from history. Within a turn, the last diff and the existing
URL-over-diff precedence both hold.

Splitting turns also un-corrupts a raw diff that opens a forwarded turn: its
"diff --git" header used to arrive prefixed with "user: ", so parse_unified_diff
found no files and the run returned the empty fallback.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Diff prose stripping incomplete ✓ Resolved 🐞 Bug ≡ Correctness
Description
_diff_prose() stops stripping as soon as it encounters a line not matched by _DIFF_BODY_LINE_RE, but
that regex omits valid git diff metadata like "new file mode" and "rename from/to". This can leak
parts of the patch into the prose used for intent detection, potentially flipping routing (e.g., a
leaked '?' causing /ask).
Code

pr_agent/mosaico/dispatch.py[R210-223]

+    # Raw (unfenced) diff: drop the patch BODY but keep the prose on BOTH sides. Keeping only
+    # the text before the first marker discarded everything after it — including the request
+    # itself, which is routinely written after the pasted patch ("<diff>\nnow describe it").
+    kept, in_diff = [], False
+    for line in text.split("\n"):
+        if _DIFF_START_RE.match(line):
+            in_diff = True
+            continue
+        if in_diff:
+            if _DIFF_BODY_LINE_RE.match(line):
+                continue
+            in_diff = False
+        kept.append(line)
+    return "\n".join(kept)
Evidence
_diff_prose uses _DIFF_BODY_LINE_RE as the whitelist for lines to skip while in_diff is True,
but parse_unified_diff explicitly handles metadata lines that are not in that whitelist—meaning
_diff_prose will treat them as “not diff” and start keeping subsequent lines as prose. That prose
is then used for routing decisions by _resolve_verb.

pr_agent/mosaico/dispatch.py[55-57]
pr_agent/mosaico/dispatch.py[210-223]
pr_agent/mosaico/dispatch.py[156-175]
pr_agent/mosaico/dispatch.py[139-142]
pr_agent/mosaico/diff_provider.py[59-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_diff_prose()` is used to remove raw diff bodies so verb/question detection isn't influenced by patch content. The current `_DIFF_BODY_LINE_RE` does not match several common git diff metadata lines (e.g., `new file mode`, `deleted file mode`, `rename from`, `rename to`, `similarity index`, `GIT binary patch`). Encountering any such line ends `in_diff` mode and causes subsequent patch lines to be kept as prose.
### Issue Context
The leaked patch text is fed into `_explicit_verb()` / `_reads_as_question()` via `_resolve_verb()`, so routing can change based on patch contents rather than user intent.
### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[55-57]
- pr_agent/mosaico/dispatch.py[201-223]
- pr_agent/mosaico/dispatch.py[156-175]
- pr_agent/mosaico/diff_provider.py[59-70]
### Implementation notes
Expand `_DIFF_BODY_LINE_RE` to include common diff metadata lines (at least those handled by `parse_unified_diff`, like rename/new/deleted modes), and consider keeping `in_diff=True` until a clear terminator rather than turning it off on the first unknown line.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Diff prose stripping incomplete ✓ Resolved 🐞 Bug ≡ Correctness
Description
_diff_prose() stops stripping as soon as it encounters a line not matched by _DIFF_BODY_LINE_RE, but
that regex omits valid git diff metadata like "new file mode" and "rename from/to". This can leak
parts of the patch into the prose used for intent detection, potentially flipping routing (e.g., a
leaked '?' causing /ask).
Code

pr_agent/mosaico/dispatch.py[R210-223]

+    # Raw (unfenced) diff: drop the patch BODY but keep the prose on BOTH sides. Keeping only
+    # the text before the first marker discarded everything after it — including the request
+    # itself, which is routinely written after the pasted patch ("<diff>\nnow describe it").
+    kept, in_diff = [], False
+    for line in text.split("\n"):
+        if _DIFF_START_RE.match(line):
+            in_diff = True
+            continue
+        if in_diff:
+            if _DIFF_BODY_LINE_RE.match(line):
+                continue
+            in_diff = False
+        kept.append(line)
+    return "\n".join(kept)
Evidence
_diff_prose uses _DIFF_BODY_LINE_RE as the whitelist for lines to skip while in_diff is True,
but parse_unified_diff explicitly handles metadata lines that are not in that whitelist—meaning
_diff_prose will treat them as “not diff” and start keeping subsequent lines as prose. That prose
is then used for routing decisions by _resolve_verb.

pr_agent/mosaico/dispatch.py[55-57]
pr_agent/mosaico/dispatch.py[210-223]
pr_agent/mosaico/dispatch.py[156-175]
pr_agent/mosaico/dispatch.py[139-142]
pr_agent/mosaico/diff_provider.py[59-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_diff_prose()` is used to remove raw diff bodies so verb/question detection isn't influenced by patch content. The current `_DIFF_BODY_LINE_RE` does not match several common git diff metadata lines (e.g., `new file mode`, `deleted file mode`, `rename from`, `rename to`, `similarity index`, `GIT binary patch`). Encountering any such line ends `in_diff` mode and causes subsequent patch lines to be kept as prose.
### Issue Context
The leaked patch text is fed into `_explicit_verb()` / `_reads_as_question()` via `_resolve_verb()`, so routing can change based on patch contents rather than user intent.
### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[55-57]
- pr_agent/mosaico/dispatch.py[201-223]
- pr_agent/mosaico/dispatch.py[156-175]
- pr_agent/mosaico/diff_provider.py[59-70]
### Implementation notes
Expand `_DIFF_BODY_LINE_RE` to include common diff metadata lines (at least those handled by `parse_unified_diff`, like rename/new/deleted modes), and consider keeping `in_diff=True` until a clear terminator rather than turning it off on the first unknown line.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Single-turn blob diff missed 🐞 Bug ≡ Correctness
Description
_split_turns() returns [] unless it finds at least two role-label lines, so a single forwarded turn
like user: diff --git ... is not normalized and route_and_run_result() falls back to using the raw
text. Because _looks_like_diff() only recognizes raw diffs when diff --git/@@ begin the line,
the router can miss the supplied raw diff and return the no-context guidance instead of routing on
the diff.
Code

pr_agent/mosaico/dispatch.py[R88-99]

+def _split_turns(text: str) -> list["_Turn"]:
+    """Conservative on purpose: a blob must open with a role label AND carry at least two."""
+    if not text:
+        return []
+    lines = text.split("\n")
+    starts = [i for i, line in enumerate(lines) if _ROLE_LINE_RE.match(line)]
+    if len(starts) < 2:
+        return []
+    first_content = next((i for i, line in enumerate(lines) if line.strip()), None)
+    if starts[0] != first_content:
+        return []
+    turns = []
Evidence
The code requires >=2 role labels to treat input as a blob; otherwise routing uses the original
text. Raw diff detection relies on ^diff --git / ^@@ patterns, so a user: prefix prevents
detection of an unfenced diff header at column 0.

pr_agent/mosaico/dispatch.py[88-105]
pr_agent/mosaico/dispatch.py[163-167]
pr_agent/mosaico/dispatch.py[383-407]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The router only splits role-labeled blobs when it sees at least two role labels. If MOSAICO (or another caller) sends a single labeled turn (e.g., `user: <raw unified diff>`), `_split_turns` returns `[]`, so routing uses the original text including the `user:` prefix. This prevents `_looks_like_diff` from detecting a raw diff because `diff --git` is no longer at the start of a line.
## Issue Context
This impacts raw (unfenced) diffs specifically; fenced diffs still match because the fence regex is not anchored.
## Fix Focus Areas
- pr_agent/mosaico/dispatch.py[88-105]
- pr_agent/mosaico/dispatch.py[163-167]
- pr_agent/mosaico/dispatch.py[383-405]
## Implementation notes
Adjust normalization so that a *single* leading role label can still be stripped when appropriate (e.g., treat it as one `_Turn`, or strip a single leading role prefix in `route_and_run_result` before diff/URL detection). Add a unit test that routes `user: {RAW_DIFF_BODY}` successfully.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. URL failure blocks inline diff 🐞 Bug ☼ Reliability
Description
In route_and_run_result(), if a segment contains a PR URL and _fetch_public_diff() returns no
diff, the function returns ok=False immediately and never checks whether the same segment also
contains a valid inline diff that could be used instead.
Code

pr_agent/mosaico/dispatch.py[R441-450]

+            pr_url = _find_pr_url(segment)
+            if pr_url:
+                diff_body = await _fetch_public_diff(pr_url)
+                if not diff_body:
+                    return RouteResult(_pr_fetch_failed_fallback(pr_url), ok=False)
+                return await _run_on_diff(diff_body, verb, question, title=pr_url, empty_ok=False)
+
+            # Path (b): a supplied unified diff.
+            if _looks_like_diff(segment):
+                return await _run_on_diff(_extract_diff(segment), verb, question, title="Supplied diff")
Evidence
The URL branch returns on fetch failure before the inline-diff branch for the same segment can run,
so a segment that contains both will fail even if the diff is usable.

pr_agent/mosaico/dispatch.py[435-450]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When a segment contains both a PR URL and an inline diff, the router tries the URL first and returns a failure immediately if fetching yields no diff. That prevents successful routing even though an actionable diff is present in the same segment.
### Issue Context
The intended behavior is “within a turn, URL wins over inline diff”, but this should not mean “URL fetch failure prevents using inline diff.” This is specifically a same-segment precedence/early-return problem.
### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[435-450]
### Suggested fix approach
- In the `pr_url` branch, if `diff_body` is falsy, check `_looks_like_diff(segment)` and route via `_run_on_diff(_extract_diff(segment), ...)` before returning a failure.
- Add a unit test with a single segment containing both an unfetchable PR URL and a valid inline diff, asserting it routes on the supplied diff and does not fail early.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Negated questions misrouted 🐞 Bug ≡ Correctness
Description
_QUESTION_OPENER_RE rejects interrogative openers when immediately followed by “not”, so questions
like “why not …” without a trailing “?” are no longer recognized as questions. Combined with
_NEGATION_RE causing “review” to be treated as negated, this falls back to the default verb “review”
instead of routing to “ask”.
Code

pr_agent/mosaico/dispatch.py[R91-93]

+_QUESTION_OPENER_RE = re.compile(
+    r"\s*(what|why|how|when|where|who|which|is|are|does|do|can|should)\b(?!\s+not\b)", re.IGNORECASE
+)
Evidence
_reads_as_question() returns true only if it sees a ? or _QUESTION_OPENER_RE matches; the
regex explicitly blocks openers followed by not, so why not … without ? is not treated as a
question. _explicit_verb() also skips a verb occurrence when _NEGATION_RE matches immediately
before it, so why not review this can end up with no explicit verb and no “question” signal,
forcing the default verb.

pr_agent/mosaico/dispatch.py[81-93]
pr_agent/mosaico/dispatch.py[137-168]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_reads_as_question()` relies on `_QUESTION_OPENER_RE`, which currently excludes *any* interrogative opener followed by `not`. This makes legitimate questions like `why not …` (without `?`) fail the “question” heuristic; when the only verb in the text is also negated by `_NEGATION_RE`, routing falls back to the default verb (`review`).
## Issue Context
This is a single-turn regression in intent detection: the user’s input can be a genuine question even without a question mark.
## Fix Focus Areas
- pr_agent/mosaico/dispatch.py[81-93]
- pr_agent/mosaico/dispatch.py[154-168]
## Suggested direction
Adjust the negation exclusion to target imperative forms (e.g., `do not`, `does not`, `can not`, `should not`) without blocking question forms like `why not`/`how not`/`what not`. For example, remove the `(?!\s+not\b)` from `_QUESTION_OPENER_RE` and instead explicitly treat leading imperative negation as “not a question” in `_reads_as_question()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
6. False blob splitting 🐞 Bug ≡ Correctness
Description
_split_turns classifies any text that starts with a role label and contains at least two role-label
lines as a forwarded conversation blob. For inputs that coincidentally begin with user: and later
contain agent:/assistant: as plain text (e.g., YAML/config), intent resolution will ignore the
lines placed into the non-user “turn” and can select the wrong verb/question text.
Code

pr_agent/mosaico/dispatch.py[R112-134]

+def _split_turns(text: str) -> list["_Turn"]:
+    """Split a forwarded conversation blob into turns (oldest first). Returns [] when the
+    text is not a blob, so a single-turn request keeps being handled as one whole segment.
+
+    Conservative on purpose — a blob must BOTH start with a role label (the reference agent
+    drops non-data parts, so the joined text always begins with one) AND carry at least two
+    of them. One stray 'user: ...' line inside an ordinary message is not enough."""
+    if not text:
+        return []
+    lines = text.split("\n")
+    starts = [i for i, line in enumerate(lines) if _ROLE_LINE_RE.match(line)]
+    if len(starts) < 2:
+        return []
+    first_content = next((i for i, line in enumerate(lines) if line.strip()), None)
+    if starts[0] != first_content:
+        return []
+    turns = []
+    for n, start in enumerate(starts):
+        end = starts[n + 1] if n + 1 < len(starts) else len(lines)
+        m = _ROLE_LINE_RE.match(lines[start])
+        body = "\n".join([lines[start][m.end():]] + lines[start + 1:end])
+        turns.append(_Turn(m.group(1).lower(), body.strip("\n")))
+    return turns
Evidence
Blob detection is based solely on role-label lines (must start with a role label and have at least
two such lines), which can be satisfied by non-conversation documents. Once split,
route_and_run_result() derives intent only from extracted user turns (user_segments), so text
that gets classified into an agent turn is ignored for verb/question resolution.

pr_agent/mosaico/dispatch.py[71-134]
pr_agent/mosaico/dispatch.py[425-456]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_split_turns()` will treat non-conversation text as a multi-turn forwarded blob when it begins with `user:` and contains another `agent:`/`assistant:` line anywhere later. In such cases, content after the accidental `agent:` label is excluded from `user_segments`, so the latest user instruction (e.g., `describe this`) or question text can be missed.
## Issue Context
This is a heuristic classifier; false positives are possible when users paste configs/logs that happen to use `user:`/`agent:` keys at column 0.
## Fix Focus Areas
- pr_agent/mosaico/dispatch.py[71-134]
- pr_agent/mosaico/dispatch.py[425-456]
## Suggested direction
Make blob detection stricter before splitting, e.g. require a stronger signature such as:
- at least one *agent/assistant* turn that is multi-line (common for forwarded agent output), or
- a minimum of 3 role markers (user/agent/user), or
- an alternating role pattern (user then agent/assistant, repeated).
If the signature isn’t met, return `[]` and fall back to single-segment routing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Indented role diff missed ✓ Resolved 🐞 Bug ≡ Correctness
Description
_ROLE_LINE_RE consumes only one whitespace after the role label, so a turn like "user:  diff --git
..." leaves the diff header indented and can prevent both diff detection and parsing
(parse_unified_diff requires a column-0 "diff --git"). This can make a valid raw diff route as
no-context or produce an empty-parse fallback.
Code

pr_agent/mosaico/dispatch.py[R63-64]

+_ROLE_LINE_RE = re.compile(r"^(user|agent|assistant)[ \t]*:[ \t]?", re.IGNORECASE)
+_USER_ROLES = ("user",)
Evidence
The role-label regex only consumes at most one trailing space/tab, and _split_turns preserves any
remaining leading whitespace in the first content line; raw diff detection/parsing relies on `^diff
--git` at column 0.

pr_agent/mosaico/dispatch.py[47-50]
pr_agent/mosaico/dispatch.py[63-64]
pr_agent/mosaico/dispatch.py[106-119]
pr_agent/mosaico/dispatch.py[185-188]
pr_agent/mosaico/diff_provider.py[40-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Turn splitting leaves leading indentation in the first content line when there are multiple spaces/tabs after the role label (e.g., `user:  diff --git ...`). That indentation can prevent `_looks_like_diff()` from recognizing a raw diff header and can also make `parse_unified_diff()` return `[]` because it expects `diff --git` at column 0.
### Issue Context
This only impacts forwarded conversation blobs (turn-splitting path). The reference agent uses `"{role}: {content}"`, but real content can begin with whitespace (or some forwarders may emit multiple spaces after the colon).
### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[63-64]
- pr_agent/mosaico/dispatch.py[114-119]
- pr_agent/mosaico/dispatch.py[185-188]
### Implementation notes
Prefer a targeted normalization that only affects the role-label separator or raw diff markers, e.g.:
- Change the role label regex to consume all whitespace after `:` (`:[ \t]*`), **or**
- After extracting the first-line remainder, `lstrip()` only when it would reveal a raw diff marker (`diff --git` / `@@`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Indented role diff missed ✓ Resolved 🐞 Bug ≡ Correctness
Description
_ROLE_LINE_RE consumes only one whitespace after the role label, so a turn like "user:  diff --git
..." leaves the diff header indented and can prevent both diff detection and parsing
(parse_unified_diff requires a column-0 "diff --git"). This can make a valid raw diff route as
no-context or produce an empty-parse fallback.
Code

pr_agent/mosaico/dispatch.py[R63-64]

+_ROLE_LINE_RE = re.compile(r"^(user|agent|assistant)[ \t]*:[ \t]?", re.IGNORECASE)
+_USER_ROLES = ("user",)
Evidence
The role-label regex only consumes at most one trailing space/tab, and _split_turns preserves any
remaining leading whitespace in the first content line; raw diff detection/parsing relies on `^diff
--git` at column 0.

pr_agent/mosaico/dispatch.py[47-50]
pr_agent/mosaico/dispatch.py[63-64]
pr_agent/mosaico/dispatch.py[106-119]
pr_agent/mosaico/dispatch.py[185-188]
pr_agent/mosaico/diff_provider.py[40-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Turn splitting leaves leading indentation in the first content line when there are multiple spaces/tabs after the role label (e.g., `user:  diff --git ...`). That indentation can prevent `_looks_like_diff()` from recognizing a raw diff header and can also make `parse_unified_diff()` return `[]` because it expects `diff --git` at column 0.
### Issue Context
This only impacts forwarded conversation blobs (turn-splitting path). The reference agent uses `"{role}: {content}"`, but real content can begin with whitespace (or some forwarders may emit multiple spaces after the colon).
### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[63-64]
- pr_agent/mosaico/dispatch.py[114-119]
- pr_agent/mosaico/dispatch.py[185-188]
### Implementation notes
Prefer a targeted normalization that only affects the role-label separator or raw diff markers, e.g.:
- Change the role label regex to consume all whitespace after `:` (`:[ \t]*`), **or**
- After extracting the first-line remainder, `lstrip()` only when it would reveal a raw diff marker (`diff --git` / `@@`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

  • Author self-review: I have reviewed the code review findings, and addressed the relevant ones.

Qodo Logo

@github-actions github-actions Bot added the bug label Jul 26, 2026
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Fix MOSAICO routing to use latest user turn in forwarded conversations

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Split MOSAICO forwarded conversation blobs into turns and route from latest user intent.
• Prefer newest usable context (diff/PR URL) to avoid acting on stale history.
• Fix verb selection, negation handling, and raw diff prose extraction; add regression tests.
Diagram

graph TD
  IN["Inbound text"] --> SPLIT["Split turns"] --> VERB["Resolve verb (latest user)"] --> CTX{"Newest context has PR URL?"}
  CTX -->|"yes"| FETCH["Fetch public diff"] --> RUN["Run pr-agent on diff"] --> OUT["RouteResult"]
  CTX -->|"no"| DIFF{"Newest context has diff?"}
  DIFF -->|"yes"| EXTRACT["Extract diff (last fence/raw)"] --> RUN
  DIFF -->|"no"| GUIDE["Return guidance/fallback"] --> OUT
  subgraph Legend
    direction LR
    _p["Process"] ~~~ _d{"Decision"}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fix at the source: forward only the latest turn (or structured turns)
  • ➕ Eliminates heuristic parsing of role labels in plain text
  • ➕ Reduces routing ambiguity and regex edge cases
  • ➖ Requires changing the reference agent / A2A wiring, not just pr-agent
  • ➖ Harder to roll out if multiple clients rely on current blob format
2. Use a lightweight intent classifier instead of regex verb routing
  • ➕ More robust to negation and phrasing diversity
  • ➕ Can generalize beyond fixed verb list
  • ➖ Adds model dependency/latency and more failure modes
  • ➖ Still needs correct turn/context selection for multi-turn blobs

Recommendation: Given the deployed constraint that MOSAICO forwards the entire conversation as plain text, the PR’s approach (conservative turn-splitting + latest-user intent + newest-context precedence) is the best local fix. It resolves the silent-wrong-patch failure mode without changing single-turn behavior, and the added tests strongly pin the intended semantics.

Files changed (2) +561 / -32

Bug fix (1) +164 / -29
dispatch.pyRoute using latest user turn and newest diff/URL context in conversation blobs +164/-29

Route using latest user turn and newest diff/URL context in conversation blobs

• Adds conservative parsing of forwarded conversation blobs into role-labeled turns, resolving intent from the latest user turn while allowing context (diff/PR URL) to come from the newest turn that contains it. Fixes verb detection to be positional and negation-aware, prefers the last fenced diff, and updates raw-diff prose extraction to preserve user requests written after pasted patches.

pr_agent/mosaico/dispatch.py

Tests (1) +397 / -3
test_mosaico_router.pyAdd regression tests for multi-turn routing, verb selection, and newest-context precedence +397/-3

Add regression tests for multi-turn routing, verb selection, and newest-context precedence

• Introduces comprehensive tests covering turn splitting, non-blob safeguards, verb resolution from latest user turns, negation/position behavior, raw diff prose preservation, and correct selection of newest diff vs stale PR URLs. Adds realistic fixtures (captured review output and multiple diffs) and end-to-end routing assertions via monkeypatched tool layers.

tests/unittest/test_mosaico_router.py

Comment thread tests/unittest/test_mosaico_router.py Fixed
Comment thread tests/unittest/test_mosaico_router.py Fixed
Flagged by CodeQL on this PR. The import was already unused before this
change; reflowing the import block moved it to a new line, which surfaced
it as a new alert.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Diff prose stripping incomplete 🐞 Bug ≡ Correctness
Description
_diff_prose() stops stripping as soon as it encounters a line not matched by _DIFF_BODY_LINE_RE, but
that regex omits valid git diff metadata like "new file mode" and "rename from/to". This can leak
parts of the patch into the prose used for intent detection, potentially flipping routing (e.g., a
leaked '?' causing /ask).
Code

pr_agent/mosaico/dispatch.py[R210-223]

+    # Raw (unfenced) diff: drop the patch BODY but keep the prose on BOTH sides. Keeping only
+    # the text before the first marker discarded everything after it — including the request
+    # itself, which is routinely written after the pasted patch ("<diff>\nnow describe it").
+    kept, in_diff = [], False
+    for line in text.split("\n"):
+        if _DIFF_START_RE.match(line):
+            in_diff = True
+            continue
+        if in_diff:
+            if _DIFF_BODY_LINE_RE.match(line):
+                continue
+            in_diff = False
+        kept.append(line)
+    return "\n".join(kept)
Evidence
_diff_prose uses _DIFF_BODY_LINE_RE as the whitelist for lines to skip while in_diff is True,
but parse_unified_diff explicitly handles metadata lines that are not in that whitelist—meaning
_diff_prose will treat them as “not diff” and start keeping subsequent lines as prose. That prose
is then used for routing decisions by _resolve_verb.

pr_agent/mosaico/dispatch.py[55-57]
pr_agent/mosaico/dispatch.py[210-223]
pr_agent/mosaico/dispatch.py[156-175]
pr_agent/mosaico/dispatch.py[139-142]
pr_agent/mosaico/diff_provider.py[59-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_diff_prose()` is used to remove raw diff bodies so verb/question detection isn't influenced by patch content. The current `_DIFF_BODY_LINE_RE` does not match several common git diff metadata lines (e.g., `new file mode`, `deleted file mode`, `rename from`, `rename to`, `similarity index`, `GIT binary patch`). Encountering any such line ends `in_diff` mode and causes subsequent patch lines to be kept as prose.

### Issue Context
The leaked patch text is fed into `_explicit_verb()` / `_reads_as_question()` via `_resolve_verb()`, so routing can change based on patch contents rather than user intent.

### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[55-57]
- pr_agent/mosaico/dispatch.py[201-223]
- pr_agent/mosaico/dispatch.py[156-175]
- pr_agent/mosaico/diff_provider.py[59-70]

### Implementation notes
Expand `_DIFF_BODY_LINE_RE` to include common diff metadata lines (at least those handled by `parse_unified_diff`, like rename/new/deleted modes), and consider keeping `in_diff=True` until a clear terminator rather than turning it off on the first unknown line.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Indented role diff missed 🐞 Bug ≡ Correctness
Description
_ROLE_LINE_RE consumes only one whitespace after the role label, so a turn like "user:  diff --git
..." leaves the diff header indented and can prevent both diff detection and parsing
(parse_unified_diff requires a column-0 "diff --git"). This can make a valid raw diff route as
no-context or produce an empty-parse fallback.
Code

pr_agent/mosaico/dispatch.py[R63-64]

+_ROLE_LINE_RE = re.compile(r"^(user|agent|assistant)[ \t]*:[ \t]?", re.IGNORECASE)
+_USER_ROLES = ("user",)
Evidence
The role-label regex only consumes at most one trailing space/tab, and _split_turns preserves any
remaining leading whitespace in the first content line; raw diff detection/parsing relies on `^diff
--git` at column 0.

pr_agent/mosaico/dispatch.py[47-50]
pr_agent/mosaico/dispatch.py[63-64]
pr_agent/mosaico/dispatch.py[106-119]
pr_agent/mosaico/dispatch.py[185-188]
pr_agent/mosaico/diff_provider.py[40-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Turn splitting leaves leading indentation in the first content line when there are multiple spaces/tabs after the role label (e.g., `user:  diff --git ...`). That indentation can prevent `_looks_like_diff()` from recognizing a raw diff header and can also make `parse_unified_diff()` return `[]` because it expects `diff --git` at column 0.

### Issue Context
This only impacts forwarded conversation blobs (turn-splitting path). The reference agent uses `"{role}: {content}"`, but real content can begin with whitespace (or some forwarders may emit multiple spaces after the colon).

### Fix Focus Areas
- pr_agent/mosaico/dispatch.py[63-64]
- pr_agent/mosaico/dispatch.py[114-119]
- pr_agent/mosaico/dispatch.py[185-188]

### Implementation notes
Prefer a targeted normalization that only affects the role-label separator or raw diff markers, e.g.:
- Change the role label regex to consume all whitespace after `:` (`:[ \t]*`), **or**
- After extracting the first-line remainder, `lstrip()` only when it would reveal a raw diff marker (`diff --git` / `@@`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ  1 issues published inline · 2 in summary

  • Author self-review: I have reviewed the code review findings, and addressed the relevant ones.

Qodo Logo

Comment thread pr_agent/mosaico/dispatch.py Outdated
_DIFF_BODY_LINE_RE did not cover the extended header git writes between
'diff --git' and the first hunk (new/deleted file mode, rename/copy
from/to, similarity index, binary). The first such line ended body mode,
so the index/---/+++ lines after it leaked into the prose that picks the
verb, letting the PATCH override what the user asked for: a patch
renaming to improve.py read as an explicit /improve and beat the user's
own 'review this' on position, and a '?' in a leaked path routed to /ask.

Only the whitelist is widened. Body mode still ends at the first line
that is not part of the patch, because that is what preserves a request
written AFTER the pasted patch.

_ROLE_LINE_RE now consumes all whitespace after the colon rather than a
single space, so a turn whose own content starts with a space no longer
leaves 'diff --git' indented, which made parse_unified_diff return [].
@ofir-frd

Copy link
Copy Markdown
Collaborator Author

Thanks Qodo. Both findings had a real core, so both are fixed in 1e18059. One piece of the suggested remediation is deliberately not taken, and one part of finding 2's reasoning does not hold up. Details below.

Finding 1, "Diff prose stripping incomplete" — ACCEPTED

Confirmed, and it is worse than "potentially flipping routing". It flips routing two ways, reproduced against the branch:

input before after
patch with rename to improve.py, user writes review this improve review
new file whose path contains ? ask review

The first is the nastier one: rename to improve.py was read as an explicit /improve and beat the user's own review this on position, so the patch overrode the request. _DIFF_BODY_LINE_RE now covers the extended header git writes between diff --git and the first hunk (old/new mode, new/deleted file mode, similarity/dissimilarity index, rename from/to, copy from/to, binary markers).

Not taken: "consider keeping in_diff=True until a clear terminator rather than turning it off on the first unknown line." That would reintroduce a defect this PR exists to fix. Defect 4 in the description is precisely that _diff_prose used to discard everything after the first diff marker, losing requests written after a pasted patch (<diff> then now describe it), which is a routine shape. Ending body mode at the first non patch line is what preserves that prose. A raw patch has no terminator to key on, so "until a clear terminator" means "to end of text". Only the whitelist is widened; the termination rule is unchanged, and test_prose_after_an_extended_header_patch_still_survives pins it.

Finding 2, "Indented role diff missed" — ACCEPTED, with a correction

The conclusion is right and it is fixed (_ROLE_LINE_RE now consumes all whitespace after the colon, not one space), but the stated mechanism is half wrong.

can prevent both diff detection and parsing

Detection is not prevented. _looks_like_diff also tests _UNIFIED_HUNK_RE, and the @@ line is still at column 0, so detection returns True on the indented input. Only parse_unified_diff fails, returning [], which surfaces as the empty fallback rather than as no context. Narrow, since the production format is {role}: {content} with exactly one space and this needs the user's own content to also start with one, but the fix is a one character regex change and the failure mode is silent, so it is worth taking.

Verification

1526 -> 1533 passed, 1 skipped, 1 xfailed, measured in a clean worktree. Seven tests added, none modified. Also re-ran the full behaviour set after the commit: the four verb cases, the negation false positive guards (there is no bug, review this and i do not have time, review this both still route to review), conservative blob detection, and a differential of pre PR vs post PR _detect_verb over 31 single turn inputs which still shows exactly the two intended positional changes and no other drift.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Thanks Qodo. Both findings had a real core, so both are fixed in 1e18059. One piece of the suggested remediation is deliberately not taken, and one part of finding 2's reasoning does not hold up. Details below.

Finding 1, "Diff prose stripping incomplete" — ACCEPTED

Confirmed, and it is worse than "potentially flipping routing". It flips routing two ways, reproduced against the branch:

input before after
patch with rename to improve.py, user writes review this improve review
new file whose path contains ? ask review

The first is the nastier one: rename to improve.py was read as an explicit /improve and beat the user's own review this on position, so the patch overrode the request. _DIFF_BODY_LINE_RE now covers the extended header git writes between diff --git and the first hunk (old/new mode, new/deleted file mode, similarity/dissimilarity index, rename from/to, copy from/to, binary markers).

Not taken: "consider keeping in_diff=True until a clear terminator rather than turning it off on the first unknown line." That would reintroduce a defect this PR exists to fix. Defect 4 in the description is precisely that _diff_prose used to discard everything after the first diff marker, losing requests written after a pasted patch (<diff> then now describe it), which is a routine shape. Ending body mode at the first non patch line is what preserves that prose. A raw patch has no terminator to key on, so "until a clear terminator" means "to end of text". Only the whitelist is widened; the termination rule is unchanged, and test_prose_after_an_extended_header_patch_still_survives pins it.

Finding 2, "Indented role diff missed" — ACCEPTED, with a correction

The conclusion is right and it is fixed (_ROLE_LINE_RE now consumes all whitespace after the colon, not one space), but the stated mechanism is half wrong.

can prevent both diff detection and parsing

Detection is not prevented. _looks_like_diff also tests _UNIFIED_HUNK_RE, and the @@ line is still at column 0, so detection returns True on the indented input. Only parse_unified_diff fails, returning [], which surfaces as the empty fallback rather than as no context. Narrow, since the production format is {role}: {content} with exactly one space and this needs the user's own content to also start with one, but the fix is a one character regex change and the failure mode is silent, so it is worth taking.

Verification

1526 -> 1533 passed, 1 skipped, 1 xfailed, measured in a clean worktree. Seven tests added, none modified. Also re-ran the full behaviour set after the commit: the four verb cases, the negation false positive guards (there is no bug, review this and i do not have time, review this both still route to review), conservative blob detection, and a differential of pre PR vs post PR _detect_verb over 31 single turn inputs which still shows exactly the two intended positional changes and no other drift.

Thanks for the detailed validation. The corrections are clear: finding 1’s termination behavior should remain unchanged to preserve trailing prose, and finding 2 affects parsing rather than diff detection. The fixes and regression coverage in commit 1e180592 address both findings, and the reported test results look good.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 1e18059

@ofir-frd

Copy link
Copy Markdown
Collaborator Author

Findings 1 and 4 are confirmed resolved in 1e180592. Findings 2 and 3 are rejected. Evidence for each, reproduced against 1e180592.

2. "Negated questions misrouted" — REJECTED

The premise is that why not review this should route to ask. It should not. why not X is a suggestion meaning please do X, not a request for an answer. Current behaviour is already correct:

input routes to correct?
why not review this review yes, the user is asking for a review
why not review this? ask yes, the ? makes it a real question
does not work, review this review yes, not is not adjacent to the verb
do not review review default; no alternative verb was requested

The suggested direction is to drop (?!\s+not\b) from _QUESTION_OPENER_RE. I simulated exactly that:

input with guard guard removed
do not review review ask
why not review this review ask

So the proposed fix regresses do not review into ask, which is the specific defect that guard exists to prevent and which a previous iteration of this branch already hit once. It also makes why not review this worse, not better. The guard is load bearing and stays.

One real imprecision this does leave: why not describe it routes to review rather than describe, because not is adjacent to describe and is read as a prohibition. That is the documented adjacent negation trade off. Separating "why not X" (suggestion) from "do not X" (prohibition) means classifying intent from the opener, which is what this router deliberately does not do. The phrasing is rare and the fallback still produces useful output, so it is not worth the added surface.

3. "False blob splitting" — REJECTED

I could not make this change any outcome for the input class cited. Config shaped text has no diff and no PR URL, so it always lands on path (c), whose guidance is verb independent:

user: admin
agent: my-agent
what does this config do?

→ splits as claimed, and returns PR-Agent requires a PR URL or a supplied diff. Identical to the same text with review this instead. The verb simply does not reach the output.

The only shape where it does diverge additionally requires a pasted diff and a verb placed after the agent: line inside a document that is otherwise a config file:

user: admin
agent: svc
describe this
diff --git a/x.py b/x.py
...

→ verb review where pre PR it was describe. Note the diff is still located and still acted on, because the context scan covers every turn including agent turns. So the worst case is a wrong verb in a contrived shape, not lost context.

Against that, tightening detection risks failing to split a real forwarded blob, which is the production path and the entire reason this PR exists: every multi-turn conversation would go back to the five defects in the description. There is also no robust discriminator available. A real blob is exactly user: ... followed by agent: ..., and so is the YAML. This is the same inherent limitation as a literal user: line appearing inside forwarded agent output, which is a known consequence of the reference agent flattening structured turns into one text part. Fixing it properly belongs upstream in the A2A wiring, as Qodo's own alternative approach 1 notes.

The cost asymmetry decides it: a false split costs a verb on contrived input, a missed split costs correctness on every real conversation. Detection stays conservative.

No code change for either, so CI remains green on 1e180592.

Drops development archaeology (defect numbers, 'used to', how the
behaviour came to be), narration of what the code plainly does, and test
docstrings that only restate the test name.

Keeps, and tightens, the parts whose absence would invite a wrong change:
the forwarded-conversation format, why blob detection is conservative,
that the production role label is 'agent' rather than 'assistant', that
only a turn's first line carries the label, why verbs resolve by position,
why intent comes from user turns but context may come from agent turns,
why negation is adjacent-only, and why the interrogative opener is guarded
against a following 'not'.

Comments only; the executable code is unchanged.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit c2a7a95

Collapses every surviving multi-line comment to one line and drops the
test docstrings, counterfactual reasoning and fixture narration. What
remains: the forwarded-conversation format, the live 'agent' label,
conservative blob detection, positional verb selection, the user-turn
intent / agent-turn context split, adjacent-only negation, the 'not'
guard, and the AGENT_REVIEW_OUTPUT fixture trap.

Comments only; the executable code is unchanged.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 86d90a8

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit be64e6f

@ofir-frd
ofir-frd merged commit 8245b55 into main Jul 26, 2026
6 checks passed
@ofir-frd
ofir-frd deleted the fix/mosaico-dispatch-conversation-intent branch July 26, 2026 15:39
ofir-frd added a commit that referenced this pull request Jul 26, 2026
The bundle pinned 0.40.0, an image built before every MOSAICO fix merged
since: pydantic-settings (#2560), the forwarded-conversation router fix
(#2561), and failure fidelity (#2563). Version tags are immutable, so the
pin has to move for the consortium to get them.

Verified against the published image before opening:
digest sha256:6cf24cd58c0c78bfcaa458e576eb9fb4a1fecb79cadfbfe92d9307486b89015f,
dispatch.py carries _split_turns and propagate_tool_errors, pydantic_settings
imports at 2.14.2, the langfuse_otel callback constructs as LangfuseOtelLogger
where 0.40.0 returned None, and get_version() reports 0.41.0.

The README upgrade example moves to 0.41.0 -> 0.42.0; left alone it would
tell a reader to upgrade to the version they are already running.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants