Skip to content

fix(channels): safely parse non-string user message content - #43

Closed
arpan7sarkar wants to merge 1 commit into
CopilotKit:mainfrom
arpan7sarkar:fix/safe-user-message-content-parsing
Closed

fix(channels): safely parse non-string user message content#43
arpan7sarkar wants to merge 1 commit into
CopilotKit:mainfrom
arpan7sarkar:fix/safe-user-message-content-parsing

Conversation

@arpan7sarkar

@arpan7sarkar arpan7sarkar commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Problem

In toVisibleChatItems (app/src/components/channels/chat-messages.ts), non-string message.content was assumed to always be an array. If message.content was undefined, null, or an unexpected object format, calling .filter() threw an
unhandled TypeError that crashed the React chat transcript.

Solution

  • Added an Array.isArray(message.content) check before filtering array parts.
  • Added an isTextPart type guard to safely extract text from valid { type: "text", text: string } items and ignore null/undefined/malformed elements.
  • Added comprehensive unit tests in app/tests/chat-messages.test.ts.

Verification

  • bun test app/tests/chat-messages.test.ts (7/7 passed)
  • bun run typecheck (0 errors)
  • bunx biome check (0 errors)

closes #44

Guard user message content parsing with Array.isArray to prevent runtime TypeError exceptions when message content is undefined, null, or non-array objects.
@davidmckayv

Copy link
Copy Markdown
Contributor

Closing this, with thanks for the tests — they're well-aimed and worth keeping. The reason: it patches the symptom in one projection (chat-messages.ts) and trades a loud crash for a silent one — a malformed turn resolves to empty and disappears from the transcript with no placeholder and no warning, which for a record people read back is worse than a visible failure. The real hole is upstream: app/src/lib/copilot/thread-messages.ts casts arbitrary server JSON with stored as Message[] and hands it to setMessages. Fixing it there with MessageSchema.safeParse (already exported by @ag-ui/core) covers all four consumers at once and lets a dropped turn be counted and shown rather than vanish. If you want to take that on, your test file is the right starting point.

Hotragn added a commit to Hotragn/openbot that referenced this pull request Aug 23, 2026
Restoring a thread ended `stored as Message[]` — a cast, not a check — so whatever
the history store held reached `setMessages` and then every projection that draws
a transcript. A turn in another shape took the conversation down rather than
itself: a tool call written `{id, name, args}` instead of AG-UI's
`{id, type: "function", function: …}` reached a renderer that dereferenced
`toolCall.function.arguments`, and one bad turn made a thread unopenable.

Each turn is now parsed with `MessageSchema` from `@ag-ui/core` and one that does
not parse is left out. Checked where history enters the app rather than in a
projection, because there are several projections and one history.

A dropped turn is counted and shown. `readThreadMessages` returns the count
beside the messages and the conversation draws a line naming it, because a turn
that quietly disappears from a record people read back is worse than a visible
failure — it reads as a message nobody sent. That was the objection that closed
the earlier attempt at this, and it is the part worth getting right.

The original object is returned rather than `parsed.data`. Zod strips keys a
schema does not name, so handing back the parsed copy would turn a validation
step into a silent rewrite of every message that passed. The parse decides
whether a turn is well formed, not what it contains.

Failing closed to an open composer is unchanged: an unreadable history still lets
somebody type. Dropping the bad turns and keeping the good ones is strictly more
than casting everything was.

This is the browser half of what CopilotKit#199 reports as its third problem. The other
half — server-side run validation rejecting a whole run with `400 invalid_literal,
expected "function"`, which makes the thread unusable — is a separate change and
is not addressed here.

Diagnosis and the original test cases are Arpan Sarkar's, from CopilotKit#43; the shapes
asserted here are the ones that file found.

Closes CopilotKit#44
davidmckayv pushed a commit to Hotragn/openbot that referenced this pull request Aug 24, 2026
Restoring a thread ended `stored as Message[]` — a cast, not a check — so whatever
the history store held reached `setMessages` and then every projection that draws
a transcript. A turn in another shape took the conversation down rather than
itself: a tool call written `{id, name, args}` instead of AG-UI's
`{id, type: "function", function: …}` reached a renderer that dereferenced
`toolCall.function.arguments`, and one bad turn made a thread unopenable.

Each turn is now parsed with `MessageSchema` from `@ag-ui/core` and one that does
not parse is left out. Checked where history enters the app rather than in a
projection, because there are several projections and one history.

A dropped turn is counted and shown. `readThreadMessages` returns the count
beside the messages and the conversation draws a line naming it, because a turn
that quietly disappears from a record people read back is worse than a visible
failure — it reads as a message nobody sent. That was the objection that closed
the earlier attempt at this, and it is the part worth getting right.

The original object is returned rather than `parsed.data`. Zod strips keys a
schema does not name, so handing back the parsed copy would turn a validation
step into a silent rewrite of every message that passed. The parse decides
whether a turn is well formed, not what it contains.

Failing closed to an open composer is unchanged: an unreadable history still lets
somebody type. Dropping the bad turns and keeping the good ones is strictly more
than casting everything was.

This is the browser half of what CopilotKit#199 reports as its third problem. The other
half — server-side run validation rejecting a whole run with `400 invalid_literal,
expected "function"`, which makes the thread unusable — is a separate change and
is not addressed here.

Diagnosis and the original test cases are Arpan Sarkar's, from CopilotKit#43; the shapes
asserted here are the ones that file found.

Closes CopilotKit#44
davidmckayv pushed a commit that referenced this pull request Aug 24, 2026
Restoring a thread ended `stored as Message[]` — a cast, not a check — so whatever
the history store held reached `setMessages` and then every projection that draws
a transcript. A turn in another shape took the conversation down rather than
itself: a tool call written `{id, name, args}` instead of AG-UI's
`{id, type: "function", function: …}` reached a renderer that dereferenced
`toolCall.function.arguments`, and one bad turn made a thread unopenable.

Each turn is now parsed with `MessageSchema` from `@ag-ui/core` and one that does
not parse is left out. Checked where history enters the app rather than in a
projection, because there are several projections and one history.

A dropped turn is counted and shown. `readThreadMessages` returns the count
beside the messages and the conversation draws a line naming it, because a turn
that quietly disappears from a record people read back is worse than a visible
failure — it reads as a message nobody sent. That was the objection that closed
the earlier attempt at this, and it is the part worth getting right.

The original object is returned rather than `parsed.data`. Zod strips keys a
schema does not name, so handing back the parsed copy would turn a validation
step into a silent rewrite of every message that passed. The parse decides
whether a turn is well formed, not what it contains.

Failing closed to an open composer is unchanged: an unreadable history still lets
somebody type. Dropping the bad turns and keeping the good ones is strictly more
than casting everything was.

This is the browser half of what #199 reports as its third problem. The other
half — server-side run validation rejecting a whole run with `400 invalid_literal,
expected "function"`, which makes the thread unusable — is a separate change and
is not addressed here.

Diagnosis and the original test cases are Arpan Sarkar's, from #43; the shapes
asserted here are the ones that file found.

Closes #44
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.

Unsafe user message content parsing causes React transcript crashes

2 participants