Skip to content

Commit 33e24c6

Browse files
refactor(web): normalize Ask user message text extraction (#1371)
* refactor(web): normalize Ask user message text extraction Introduce a shared `getUserMessageText` helper in the chat utils and use it everywhere the user's typed text is read from an `SBChatMessage`. Previously several call sites assumed the text lived at `parts[0]`, which silently drops the user's text if any non-text part (e.g. a file attachment) is ever first. The helper finds the first text part instead, making extraction robust to non-text parts. This is behavior-preserving today (user messages currently have a single leading text part) and is a prerequisite for chat file attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add changelog entry for #1371 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0fdf0b3 commit 33e24c6

7 files changed

Lines changed: 93 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)
12+
- Refactored Ask Sourcebot user message text extraction into a shared helper that robustly handles non-text message parts. [#1371](https://github.com/sourcebot-dev/sourcebot/pull/1371)
1213

1314
### Added
1415
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

packages/web/src/app/(app)/chat/[id]/page.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { __unsafePrisma } from '@/prisma';
1414
import { ChatVisibility } from '@sourcebot/db';
1515
import { Metadata } from 'next';
1616
import { SBChatMessage } from '@/features/chat/types';
17+
import { getUserMessageText } from '@/features/chat/utils';
1718
import { env } from '@sourcebot/shared';
1819
import { hasEntitlement } from '@/lib/entitlements';
1920
import { ChatEntitlementMessage } from '@/features/chat/components/chatEntitlementMessage';
@@ -54,11 +55,11 @@ export const generateMetadata = async ({ params }: PageProps): Promise<Metadata>
5455

5556
let description = 'A chat on Sourcebot';
5657
if (firstUserMessage) {
57-
const textPart = firstUserMessage.parts.find(p => p.type === 'text');
58-
if (textPart && textPart.type === 'text') {
59-
description = textPart.text.length > 160
60-
? textPart.text.substring(0, 160).trim() + '...'
61-
: textPart.text;
58+
const text = getUserMessageText(firstUserMessage);
59+
if (text) {
60+
description = text.length > 160
61+
? text.substring(0, 160).trim() + '...'
62+
: text;
6263
}
6364
}
6465

packages/web/src/ee/features/chat/agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { randomUUID } from "crypto";
2222
import _dedent from "dedent";
2323
import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "@/features/chat/constants";
2424
import { Source } from "@/features/chat/types";
25-
import { addLineNumbers, fileReferenceToString, getAnswerPartFromAssistantMessage, getTurnProgressState } from "@/features/chat/utils";
25+
import { addLineNumbers, fileReferenceToString, getAnswerPartFromAssistantMessage, getTurnProgressState, getUserMessageText } from "@/features/chat/utils";
2626
import { createTools } from "./tools";
2727
import { getConnectedMcpClients } from "@/ee/features/chat/mcp/mcpClientFactory";
2828
import { getMcpTools, McpToolsResult } from "@/ee/features/chat/mcp/mcpToolSets";
@@ -107,7 +107,7 @@ export const createMessageStream = async ({
107107
if (message.role === 'user') {
108108
return {
109109
role: 'user',
110-
content: message.parts[0].type === 'text' ? message.parts[0].text : '',
110+
content: getUserMessageText(message),
111111
};
112112
}
113113

packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
55
import { Separator } from '@/components/ui/separator';
66
import { CustomSlateEditor } from '@/features/chat/customSlateEditor';
77
import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types';
8-
import { createUIMessage, getAllMentionElements, getTurnProgressState, resetEditor, slateContentToString } from '@/features/chat/utils';
8+
import { createUIMessage, getAllMentionElements, getTurnProgressState, getUserMessageText, resetEditor, slateContentToString } from '@/features/chat/utils';
99
import { useChat } from '@ai-sdk/react';
1010
import { CreateUIMessage, DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
1111
import { ArrowDownIcon, CopyIcon } from 'lucide-react';
@@ -204,16 +204,16 @@ export const ChatThread = ({
204204
} satisfies AdditionalChatRequestParams,
205205
});
206206

207+
const userMessageText = getUserMessageText(message);
207208
if (
208209
messages.length === 0 &&
209-
message.parts.length > 0 &&
210-
message.parts[0].type === 'text'
210+
userMessageText.length > 0
211211
) {
212212
generateAndUpdateChatNameFromMessage(
213213
{
214214
chatId,
215215
languageModelId: selectedLanguageModel.model,
216-
message: message.parts[0].text,
216+
message: userMessageText,
217217
},
218218
).then((response) => {
219219
if (isServiceError(response)) {

packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRe
88
import scrollIntoView from 'scroll-into-view-if-needed';
99
import { Reference, referenceSchema, SBChatMessage, Source } from "@/features/chat/types";
1010
import { useExtractReferences } from '../../useExtractReferences';
11-
import { getAnswerPartFromAssistantMessage, getLastStepParts, groupMessageIntoSteps, isSBChatToolPart, repairReferences, tryResolveFileReference } from '@/features/chat/utils';
11+
import { getAnswerPartFromAssistantMessage, getLastStepParts, getUserMessageText, groupMessageIntoSteps, isSBChatToolPart, repairReferences, tryResolveFileReference } from '@/features/chat/utils';
1212
import { AnswerCard } from './answerCard';
1313
import { DetailsCard } from './detailsCard';
1414
import { ApprovalRequestedToolPart, ToolApprovalBanner } from './toolApprovalBanner';
@@ -49,7 +49,7 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
4949
const userHasManuallyExpanded = useRef(false);
5050

5151
const userQuestion = useMemo(() => {
52-
return userMessage.parts.length > 0 && userMessage.parts[0].type === 'text' ? userMessage.parts[0].text : '';
52+
return getUserMessageText(userMessage);
5353
}, [userMessage]);
5454

5555
// Take the assistant message and repair any references that are not properly formatted.

packages/web/src/features/chat/utils.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test, describe, vi } from 'vitest'
2-
import { createUIMessage, fileReferenceToString, getAnswerPartFromAssistantMessage, getLastStepParts, getTurnProgressState, groupMessageIntoSteps, repairReferences } from './utils'
2+
import { createUIMessage, fileReferenceToString, getAnswerPartFromAssistantMessage, getLastStepParts, getTurnProgressState, getUserMessageText, groupMessageIntoSteps, repairReferences } from './utils'
33
import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants';
44
import { SBChatMessage, SBChatMessagePart } from './types';
55

@@ -537,6 +537,75 @@ test('getAnswerPartFromAssistantMessage returns undefined when turn is in progre
537537
expect(result).toBeUndefined();
538538
});
539539

540+
describe('getUserMessageText', () => {
541+
test('returns the text when the text part is first', () => {
542+
const message: SBChatMessage = {
543+
role: 'user',
544+
parts: [
545+
{
546+
type: 'text',
547+
text: 'Hello, world!',
548+
},
549+
],
550+
} as SBChatMessage;
551+
552+
expect(getUserMessageText(message)).toBe('Hello, world!');
553+
});
554+
555+
test('returns the text when a non-text part precedes the text part', () => {
556+
const message: SBChatMessage = {
557+
role: 'user',
558+
parts: [
559+
{
560+
type: 'data-source',
561+
data: {
562+
type: 'file',
563+
path: 'auth.ts',
564+
repo: 'github.com/sourcebot-dev/sourcebot',
565+
name: 'auth.ts',
566+
revision: 'main',
567+
},
568+
},
569+
{
570+
type: 'text',
571+
text: 'Explain this file',
572+
},
573+
],
574+
} as SBChatMessage;
575+
576+
expect(getUserMessageText(message)).toBe('Explain this file');
577+
});
578+
579+
test('returns an empty string when there is no text part', () => {
580+
const message: SBChatMessage = {
581+
role: 'user',
582+
parts: [
583+
{
584+
type: 'data-source',
585+
data: {
586+
type: 'file',
587+
path: 'auth.ts',
588+
repo: 'github.com/sourcebot-dev/sourcebot',
589+
name: 'auth.ts',
590+
revision: 'main',
591+
},
592+
},
593+
],
594+
} as SBChatMessage;
595+
596+
expect(getUserMessageText(message)).toBe('');
597+
});
598+
599+
test('returns an empty string when there are no parts', () => {
600+
const message: SBChatMessage = {
601+
role: 'user',
602+
parts: [],
603+
} as unknown as SBChatMessage;
604+
605+
expect(getUserMessageText(message)).toBe('');
606+
});
607+
});
608+
540609
test('repairReferences fixes missing colon after @file', () => {
541610
const input = 'See the function in @file{github.com/sourcebot-dev/sourcebot::auth.ts} for details.';
542611
const expected = 'See the function in @file:{github.com/sourcebot-dev/sourcebot::auth.ts} for details.';

packages/web/src/features/chat/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,14 @@ export const repairReferences = (text: string): string => {
397397
.replace(/`(@file:\{[^`]+)`\}/g, '$1}');
398398
};
399399

400+
// Extracts the user's text from a message by finding the first text part.
401+
// User messages may contain non-text parts (e.g., file attachments), so we
402+
// cannot assume the text is always at index 0. Accepts anything carrying
403+
// `parts` so it works for both persisted and freshly created messages.
404+
export const getUserMessageText = (message: Pick<SBChatMessage, 'parts'>): string => {
405+
return message.parts.find((part) => part.type === 'text')?.text ?? '';
406+
}
407+
400408
// Attempts to find the part of the assistant's message
401409
// that contains the answer.
402410
export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isTurnInProgress: boolean): TextUIPart | undefined => {

0 commit comments

Comments
 (0)