Skip to content

fix(router): correct text extraction and empty/regex handling in fixture matching#301

Merged
jpr5 merged 3 commits into
mainfrom
fix/multimodal-split-user-message-matching
Jul 16, 2026
Merged

fix(router): correct text extraction and empty/regex handling in fixture matching#301
jpr5 merged 3 commits into
mainfrom
fix/multimodal-split-user-message-matching

Conversation

@ranst91

@ranst91 ranst91 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Three related fixes to how the fixture matcher reads and compares message text. This started as ran's fix for split multimodal turns; while we were in the same predicate branches we cleaned up two adjacent bugs, so the PR now covers text extraction and empty/regex handling in fixture matching as one unit.

What's fixed

1. Multimodal turns split into separate messages (ran's original fix)
When a provider serializes one multimodal user turn as a text message followed by an attachment-only message (observed with MS Agent Framework's agent_framework_openai image path), the trailing text-less message shadowed the real prompt, so every such fixture missed. getLastUserText now walks back to the nearest user message that actually carries text.

2. Fixtures keyed on empty content couldn't match
The predicate guards treated a present-but-empty body ("") the same as an absent one, so userMessage: "" and inputText: "" could never fire. The guards now distinguish truly-absent (null / undefined → skip) from present-but-empty ("" → allowed to match). systemMessage is deliberately left unchanged: getSystemText can't tell absent from empty at the request level, so allowing "" there would turn systemMessage: "" into a catch-all that fires on every no-system request. That reasoning is commented inline next to the guard.

3. Matching a regex mutated the caller's RegExp
.test() advances lastIndex on /g and /y regexes. Fixtures hold caller-owned RegExp objects, so testing them in place clobbered the caller's positional state, and a /g matcher reused across match calls would match intermittently. Added regexTest(), which tests a fresh clone (same source + flags) and never touches the caller's object.

Testing

Red-green for each behavior change: the empty-content and lastIndex cases fail on the pre-fix code and pass after. Full suite green (4400+ tests), lint / typecheck / build clean. A fixtures audit confirms no shipped fixture relied on the old empty-matcher behavior, so there's no match-outcome regression.

Merge note

No version bump in this PR — the release bump is handled separately.

Deferred (pre-existing, out of scope)

Reviewers noted a byUniquePosition diagnostic-label quirk in selectByTurnIndex and a few untested pre-existing branches (Tier-4 turn-index path, non-image endpoint-compat, getTextContent non-string filtering). All are pre-existing and the served-fixture behavior is correct; tracked for a separate follow-up.

@pkg-pr-new

pkg-pr-new Bot commented Jul 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@301

commit: ae638da

…parate user messages

Some SDKs (e.g. Microsoft Agent Framework's agent_framework_openai image
path) serialize a single multimodal user turn (prompt text + attachment)
into TWO consecutive user messages — a text-only one followed by an
attachment-only one (content: [{type: 'image_url', ...}]). The trailing
attachment-only message has no extractable text, so the naive last-user-
message lookup returned null and — since no fixture can key on empty text —
every such multimodal fixture missed (404/503).

getLastUserText now skips trailing user messages that carry no text and
uses the nearest preceding user message that does. This is deliberately
narrow: it only skips text-LESS user messages, so a genuine multi-message
user turn (each message has text) is unaffected and still matched on its
final message. Adds two router tests (image split + a text-bearing trailing
message stays the match target).
@jpr5
jpr5 force-pushed the fix/multimodal-split-user-message-matching branch from 69fab57 to ca55f47 Compare July 16, 2026 05:24
jpr5 added 2 commits July 15, 2026 23:07
The userMessage and inputText matcher branches discarded the
null-vs-"" distinction at two layers, so a fixture keyed on an empty
body could never fire even though such fixtures load fine:

  - getLastUserText skipped a user message whose text extracted to ""
    (the `&& text !== ""` clause), conflating a present-but-empty text
    turn with a text-less (attachment-only) split turn. It now skips
    only the genuinely text-LESS (`null`) case — matching what its own
    doc-comment already claimed — so `content: ""` is returned as "".
  - The userMessage guard `if (!text) continue;` became
    `if (text === null) continue;` so an empty-string body flows into
    the exact / substring / regex match forms (all of which handle ""
    correctly: exact "" === "", substring "".includes("") === true,
    /^$/.test("") === true).
  - The inputText guard `if (!embeddingInput) continue;` became
    `if (embeddingInput === undefined) continue;` — embeddingInput is
    `string | undefined`, undefined = absent, "" = a genuinely-empty
    embedding request.

systemMessage is intentionally NOT changed. getSystemText returns ""
for BOTH "a present but empty system message" AND "no system message
at all", so it exposes no absent-vs-empty distinction at the request
level; allowing "" through would turn a `systemMessage: ""` / `/^$/`
fixture into a catch-all firing on every no-system-message request.
There is no shipped-fixture demand for it, so the falsy guard stays,
with a comment explaining the asymmetry and how to add it later.

Call sites checked (grep for the extractor guards):
  - userMessage guard   src/router.ts (matcher) — FIXED
  - systemMessage guard  src/router.ts (matcher) — UNCHANGED (see above)
  - inputText guard     src/router.ts (matcher) — FIXED
  - model branch        reads effective.model (required, defaulted ""),
                        no null-vs-"" semantics — untouched
  - getLastUserText only caller is the userMessage branch

Fixtures audit (fixtures/ docs/fixtures/ examples/) finds zero
empty-string userMessage/systemMessage/inputText matchers, so no
shipped fixture changes behavior.
The four RegExp matcher branches each did
`match.<field>.lastIndex = 0; match.<field>.test(text)` in place. For a
`/g` or `/y` regex, `RegExp.prototype.test` advances `lastIndex` as a
side effect, so a successful match left the caller's own RegExp object
with a non-zero `lastIndex` — clobbering a positional scan the caller
was running with the same object, and making a `/g` regex reused across
fixtures / successive matchFixture calls match intermittently.

Introduce a `regexTest(re, text)` helper that tests a fresh
`new RegExp(re.source, re.flags)` clone: the caller's object is never
touched, every test starts at index 0, and all flag semantics
(i/m/s/u and the harmless-for-a-single-test g/y) are preserved.

Four sites replaced (reset-line dropped, .test → regexTest):
  - userMessage    src/router.ts
  - systemMessage  src/router.ts (sm narrowed to RegExp in the else branch)
  - inputText      src/router.ts
  - model          src/router.ts

Call-site check: `grep lastIndex src/router.ts` now shows only the
helper's doc-comment reference — no code sets or reads
`match.<field>.lastIndex` after matching, so the clone is the sole
mediator and no other path depended on the in-place reset.
@jpr5 jpr5 changed the title fix(router): match the prompt when a multimodal turn is split into separate user messages fix(router): correct text extraction and empty/regex handling in fixture matching Jul 16, 2026
@jpr5
jpr5 merged commit c3bb865 into main Jul 16, 2026
23 checks passed
@jpr5
jpr5 deleted the fix/multimodal-split-user-message-matching branch July 16, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants