Skip to content

Preserve part ordering in conversations API#2866

Merged
tim-inkeep merged 3 commits intomainfrom
bugfix/conversations-api-out-of-order
Mar 27, 2026
Merged

Preserve part ordering in conversations API#2866
tim-inkeep merged 3 commits intomainfrom
bugfix/conversations-api-out-of-order

Conversation

@tim-inkeep
Copy link
Copy Markdown
Contributor

toVercelMessage() was hoisting all text to the front of the parts array and skipping text parts during iteration, breaking the interleaved order of text and data components. This caused conversations loaded from history to show components in a different order than what users saw during streaming. Now iterates content.parts in stored order, falling back to content.text only for legacy messages without parts.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 27, 2026

🦋 Changeset detected

Latest commit: f5ba887

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-api Patch
@inkeep/agents-manage-ui Patch
@inkeep/agents-cli Patch
@inkeep/agents-core Patch
@inkeep/agents-email Patch
@inkeep/agents-mcp Patch
@inkeep/agents-sdk Patch
@inkeep/agents-work-apps Patch
@inkeep/ai-sdk-provider Patch
@inkeep/create-agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 27, 2026 6:30pm
agents-docs Ready Ready Preview, Comment Mar 27, 2026 6:30pm
agents-manage-ui Ready Ready Preview, Comment Mar 27, 2026 6:30pm

Request Review

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Mar 27, 2026

TL;DR — Fixes toVercelMessage() to preserve the interleaved order of text and data-component parts when loading conversations from history, instead of hoisting all text to the front of the array.

Key changes

  • Preserve part ordering in toVercelMessage() — Text parts are now emitted inline during iteration over content.parts instead of being extracted and prepended upfront, so conversations loaded from history match what users saw during streaming.
  • Fall back to content.text for legacy messages — Messages without content.parts still produce a text part via the existing extractText helper, maintaining backward compatibility.
  • Add changeset — Patch-level changeset for @inkeep/agents-api.

Summary | 2 files | 3 commits | base: mainbugfix/conversations-api-out-of-order


Inline text parts instead of hoisting

Before: toVercelMessage() called extractText() first, pushed a single text part, then iterated content.parts but skipped text parts — breaking the interleaved order of text and data components.
After: Text parts are pushed in iteration order alongside data parts; extractText() is only used as a fallback when content.parts is absent.

The root cause was a two-phase approach: extract-then-skip. By moving text handling into the loop body and guarding with if (p.text), parts retain their original sequence. The else if (text) branch after the loop ensures legacy messages (no parts array) still produce output.

conversations.ts · happy-bobcats-open.md

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Clean, correct fix. The old code hoisted all text to position 0 via extractText() and left an empty if (kind === 'text') {} body in the parts loop, silently discarding text parts during iteration. The new code emits text parts inline during iteration, preserving interleaved order with data/file parts. The else if (text) fallback correctly handles legacy messages that only have content.text without a parts array.

One minor note: the openapi snapshot diff includes an unrelated fallbackModels addition because the branch is behind main — that will resolve on rebase/merge.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@github-actions github-actions bot deleted a comment from claude bot Mar 27, 2026
"@inkeep/agents-api": patch
---

Perserve Part Ordering in Conversations API
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Typo: "Perserve" should be "Preserve".

Suggested change
Perserve Part Ordering in Conversations API
Preserve part ordering in conversations API

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(1) Total Issues | Risk: Low

🟡 Minor (1) 🟡

🟡 1) conversations.ts:73-114 Consider adding regression test for interleaved part ordering

Issue: The bug fix correctly preserves interleaved ordering of text and data parts, but there's no dedicated test that verifies this specific behavior. The existing test (conversations.test.ts:475-534) tests text followed by a single data part but doesn't cover the regression scenario: multiple text parts interleaved with data parts maintaining their original order.

Why: Without a regression test, this bug could be reintroduced by future refactoring. The specific code path where text parts are emitted inline during iteration (lines 76-79) would benefit from explicit coverage. However, the fix is straightforward and the logic is clear, making accidental regression less likely.

Fix: Consider adding a test with pattern [text, data, text, data] or [data, text, data] to verify ordering is preserved:

it('should preserve interleaved order of text and data parts', async () => {
  // Create message with parts: [text, data, text]
  // Verify parts array maintains [text, data, text] order
});

Refs:

💭 Consider (2) 💭

💭 1) conversations.ts:112-114 Test coverage for legacy messages without parts array

Issue: The else if (text) fallback handles legacy messages with only content.text (no parts array), but this path lacks explicit test coverage.
Why: Important for backward compatibility, though the code is simple and unlikely to break.
Fix: Add a test with a message that only has content: { text: '...' } (no parts array).

💭 2) conversations.ts:73-79 Test edge case: data part appearing before text

Issue: No test verifies ordering when a data/file part appears before any text part (e.g., [data, text]).
Why: Edge case where the old bug would have incorrectly placed text before data.
Fix: Add test with data-first part ordering pattern.

🕐 Pending Recommendations (1)


💡 APPROVE WITH SUGGESTIONS

Summary: Clean, correct bugfix! The code change properly preserves interleaved part ordering by emitting text parts inline during iteration instead of hoisting them to position 0. The else if (text) fallback correctly handles legacy messages. Pullfrog's review and summary were accurate. The only actionable item is the typo fix already flagged. Test coverage suggestions are optional improvements — the fix itself is solid and ships well without blocking on tests. 🎉

Discarded (0)

No findings were discarded.

Reviewers (2)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 1 0 0 0 0 1 0
pr-review-tests 3 1 2 0 0 0 0
Total 4 1 2 0 0 1 0

@github-actions github-actions bot deleted a comment from claude bot Mar 27, 2026
@amikofalvy amikofalvy closed this Mar 27, 2026
@amikofalvy amikofalvy reopened this Mar 27, 2026
@tim-inkeep tim-inkeep added this pull request to the merge queue Mar 27, 2026
amikofalvy added a commit that referenced this pull request Mar 27, 2026
Bot commits pushed with the default GITHUB_TOKEN (github-actions[bot])
don't trigger workflow runs due to GitHub's infinite loop protection.
This left PRs stuck waiting for required checks that never ran.

Now uses the inkeep-internal-ci GitHub App token for OpenAPI snapshot
commits (ci.yml) and auto-format commits (auto-format.yml), matching
the pattern already used in release.yml. App token commits trigger
downstream workflows.

Fixes: PR #2866 stuck 30min waiting on CI after OpenAPI snapshot commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merged via the queue into main with commit 9728814 Mar 27, 2026
27 of 28 checks passed
@tim-inkeep tim-inkeep deleted the bugfix/conversations-api-out-of-order branch March 27, 2026 19:15
github-merge-queue bot pushed a commit that referenced this pull request Mar 27, 2026
Bot commits pushed with the default GITHUB_TOKEN (github-actions[bot])
don't trigger workflow runs due to GitHub's infinite loop protection.
This left PRs stuck waiting for required checks that never ran.

Now uses the inkeep-internal-ci GitHub App token for OpenAPI snapshot
commits (ci.yml) and auto-format commits (auto-format.yml), matching
the pattern already used in release.yml. App token commits trigger
downstream workflows.

Fixes: PR #2866 stuck 30min waiting on CI after OpenAPI snapshot commit.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@itoqa
Copy link
Copy Markdown

itoqa bot commented Mar 27, 2026

Ito Test Report ❌

18 test cases ran. 1 failed, 17 passed.

Overall, 17 of 18 tests passed, confirming that conversation list/detail APIs are largely stable and secure: user scoping and cross-user isolation held, invalid/missing auth and unsupported format inputs were rejected without leakage, Vercel response envelopes and deterministic part ordering were preserved (including under concurrency, pagination extremes, UI/mobile replay), legacy/backward-compat mappings remained intact, malformed data/file parts were handled safely, and injection-like payloads stayed inert. The single significant issue was a medium-severity regression in Vercel message conversion where messages with content.text plus an empty content.parts array return no synthesized text part, creating a content/parts mismatch that can hide visible text in replay clients.

❌ Failed (1)
Category Summary Screenshot
Edge 🟠 content.text is not converted into a text part when content.parts is present but empty, creating replay visibility risk. EDGE-3
🟠 Empty parts array with populated content.text does not silently lose visible text
  • What failed: The API returns content text but leaves parts empty, instead of emitting a text part for replay consumers that render from parts.
  • Impact: Conversation replay clients that rely on parts can hide visible message text for this payload shape, causing incorrect history rendering and user-visible content loss.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Seed a message with content containing text and an empty parts array: { text: "Text should survive", parts: [] }.
    2. Request GET /run/v1/conversations/{conversationId}?format=vercel.
    3. Inspect the returned message and observe that content contains text but parts is an empty array.
  • Code analysis: I reviewed the production mapper in agents-api/src/domains/run/routes/conversations.ts. The converter only falls back to synthetic text parts when msg.content.parts is absent, not when it is an empty array, while still returning content from extractText, creating a content/parts mismatch.
  • Why this is likely a bug: The if (msg.content.parts) branch treats [] as present and skips the text fallback, so a valid visible text payload can be returned without any replayable text part in production responses.

Relevant code:

agents-api/src/domains/run/routes/conversations.ts (lines 52-60)

function extractText(content: MessageContent): string {
  if (content.text) return content.text;
  if (content.parts) {
    return content.parts
      .filter((p) => getPartKind(p) === 'text' && p.text)
      .map((p) => p.text as string)
      .join('');
  }
  return '';
}

agents-api/src/domains/run/routes/conversations.ts (lines 73-114)

if (msg.content.parts) {
  for (const p of msg.content.parts) {
    const kind = getPartKind(p);
    if (kind === 'text') {
      if (p.text) {
        parts.push({ type: 'text', text: p.text });
      }
    } else if (kind === 'data') {
      let parsed = p.data;
      if (typeof parsed === 'string') {
        try {
          parsed = JSON.parse(parsed);
        } catch {
          // keep as string
        }
      }
      const isArtifact =
        parsed &&
        typeof parsed === 'object' &&
        (parsed as Record<string, unknown>).artifactId &&
        (parsed as Record<string, unknown>).toolCallId;
      parts.push({ type: isArtifact ? 'data-artifact' : 'data-component', data: parsed });
    } else if (kind === 'file') {
      const url = typeof p.data === 'string' ? p.data : undefined;
      if (!url) {
        logger.warn({ part: p }, 'File part missing data, skipping');
        continue;
      }
      const meta = p.metadata as Record<string, unknown> | undefined;
      const mediaType = typeof meta?.mimeType === 'string' ? meta.mimeType : undefined;
      const filename = typeof meta?.filename === 'string' ? meta.filename : undefined;
      parts.push({
        type: 'file',
        url,
        ...(mediaType && { mediaType }),
        ...(filename && { filename }),
      });
    }
  }
} else if (text) {
  parts.push({ type: 'text', text });
}
✅ Passed (17)
Category Summary Screenshot
Adversarial Verified missing or malformed auth/app headers returned non-success responses with no conversation data exposed. ADV-1
Adversarial Cross-user conversation probing is blocked with not-found behavior and no metadata leakage in the verified route logic. ADV-2
Adversarial Unsupported format values were rejected with 400 responses while valid format=vercel remained successful. ADV-3
Adversarial Repeated concurrent detail fetches remained deterministic with stable part order and successful responses. ADV-4
Adversarial Injection payloads remained inert text/data in API and UI rendering with no script execution behavior observed. ADV-5
Edge Malformed JSON payloads in data parts were preserved as strings without breaking response rendering. EDGE-1
Edge Legacy type-based parts mapped correctly to Vercel-compatible text and data-component parts. EDGE-2
Edge Malformed file part without URL is skipped and surrounding text parts remain in original order. EDGE-4
Edge Null conversation title correctly falls back to the first user-visible message text in list and detail responses. EDGE-5
Edge Pagination stayed coherent at limit=200 and out-of-range pages returned empty data without endpoint errors. EDGE-6
Logic Interleaved text, data-component, and file parts were returned in stored order with values preserved. LOGIC-1
Logic Legacy text-only messages still returned content and a single synthesized text part. LOGIC-2
Logic Tool invocation output remained present after content-part mapping in conversation detail responses. LOGIC-3
Logic Replay ordering behavior is deterministic in source and validated by route logic; no product ordering defect was found in this run. LOGIC-4
Mobile Mobile/tablet replay rendered interleaved parts in the expected A → data-component → B → file → C sequence with readable layout. MOBILE-1
Happy-path Verified authenticated list endpoint returned only User A conversations and excluded User B records. ROUTE-1
Happy-path Verified conversation detail endpoint returned 200 with expected Vercel envelope fields, normalized roles, and content/parts. ROUTE-2

Commit: f5ba887

View Full Run


Tell us how we did: Give Ito Feedback

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