Skip to content

Commit

Permalink
[Backport M72] feat(webview): add support for <think> tags in Chat Me…
Browse files Browse the repository at this point in the history
…ssage (#7209)

RE: https://linear.app/sourcegraph/issue/CODY-4785

This PR adds a simple hack to support rendering &lt;think&gt; tags in
the ChatMessageContent component.

The &lt;think&gt; content is displayed in a collapsible details element,
allowing users to view the AI&#39;s internal thought process.

The MarkdownFromCody component is also updated to allow the
&lt;think&gt; element.


## Test plan



Ask Cody &quot;how many files are there in the codebase? Enclose your
thoughts inside &lt;think&gt; tags before answering&quot;

- Verify that &lt;think&gt; tags are properly extracted and displayed in
the ChatMessageContent component
- Ensure that the collapsible details element functions as expected,
allowing users to view the think content
- Confirm that the MarkdownFromCody component correctly renders the
&lt;think&gt; element

Example:



https://github.com/user-attachments/assets/0a5cff8f-1b08-48e4-9cf9-4fd6d13ef05a

 <br> Backport 710ac73 from #6845

Co-authored-by: Beatrix <[email protected]>
  • Loading branch information
sourcegraph-release-bot and abeatrix authored Feb 24, 2025
1 parent 99ff04a commit bd9fee0
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'

import { CompletionsResponseBuilder } from './CompletionsResponseBuilder'

interface CompletionsResponseTestCase {
name: string
url: string
steps: {
deltaText?: string
completion?: string
thinking?: string
expected: string
}[]
}

describe('CompletionsResponseBuilder', () => {
const testCases: CompletionsResponseTestCase[] = [
{
name: 'API v1 - Full replacement mode',
url: 'https://sourcegraph.com/.api/completions/stream?api-version=1',
steps: [
{
completion: 'Direct',
expected: 'Direct',
},
{
completion: ' Response',
expected: ' Response',
},
],
},
{
name: 'API v2 - Incremental mode',
url: 'https://sourcegraph.com/.api/completions/stream?api-version=2',
steps: [
{
deltaText: undefined,
expected: '',
},
{
deltaText: undefined,
expected: '',
},
{
deltaText: 'Starting response',
expected: 'Starting response',
},
],
},
{
name: 'API v2 - Incremental mode with thinking steps',
url: 'https://sourcegraph.com/.api/completions/stream?api-version=2',
steps: [
{
thinking: 'Analyzing...',
expected: '<think>Analyzing...</think>\n',
},
{
thinking: 'Refining...',
expected: '<think>Analyzing...Refining...</think>\n',
},
{
deltaText: 'Better response',
expected: '<think>Analyzing...Refining...</think>\nBetter response',
},
],
},
{
name: 'API v8 - Incremental mode with thinking steps',
url: 'https://sourcegraph.com/.api/completions/stream?api-version=8',
steps: [
{
thinking: 'Step 1...',
deltaText: 'Hello',
expected: '<think>Step 1...</think>\nHello',
},
{
thinking: 'Step 2...',
deltaText: ' World',
expected: '<think>Step 1...Step 2...</think>\nHello World',
},
],
},
]

for (const testCase of testCases) {
describe(testCase.name, () => {
it('processes completion steps correctly', () => {
const builder = CompletionsResponseBuilder.fromUrl(testCase.url)
for (const step of testCase.steps) {
builder.nextThinking(step.thinking ?? undefined)
const result = builder.nextCompletion(step.completion, step.deltaText)
expect(result).toBe(step.expected)
}
})
})
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,39 @@ export class CompletionsResponseBuilder {
public totalCompletion = ''
constructor(public readonly apiVersion: number) {}
public nextCompletion(completion: string | undefined, deltaText: string | undefined): string {
const thinkingText = this.getThinkingText()
if (this.apiVersion >= 2) {
this.totalCompletion += deltaText ?? ''
return this.totalCompletion
} else {
this.totalCompletion = completion ?? ''
}
this.totalCompletion = completion ?? ''
return this.totalCompletion
return thinkingText + this.totalCompletion
}

private readonly thinkingBuffer: string[] = []
/**
* Processes and accumulates thinking steps during the completion stream.
* Thinking steps must start at the beginning of completion and are enclosed in <think> tags.
* When the completion starts streaming, the previous <think> tag is closed.
*
* @param deltaThinking - The incremental thinking text to be added
* @returns The formatted thinking text wrapped in XML tags
*/
public nextThinking(deltaThinking?: string): string {
if (deltaThinking) {
this.thinkingBuffer.push(deltaThinking)
}
return this.getThinkingText()
}
/**
* Generates the formatted thinking text by combining all thinking steps.
* Wraps the combined thinking text in <think> tags and adds a newline if content exists.
*
* @returns Formatted thinking text with XML tags, or empty string if no thinking steps exist
*/
private getThinkingText(): string {
const thinking = this.thinkingBuffer.join('')
return thinking ? `<think>${thinking}</think>\n` : ''
}

public static fromUrl(url: string): CompletionsResponseBuilder {
Expand Down
4 changes: 4 additions & 0 deletions lib/shared/src/sourcegraph-api/completions/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function parseJSON<T>(data: string): T | Error {
export interface CompletionData {
completion?: string
deltaText?: string
delta_thinking?: string
stopReason?: string
}

Expand All @@ -56,6 +57,9 @@ function parseEventData(
if (isError(data)) {
return data
}
// Process the delta_thinking and deltaText separately.
// The thinking text will be added to the completion text.
builder.nextThinking(data.delta_thinking)
// Internally, don't handle delta text yet and there's limited value
// in passing around deltas anyways so we concatenate them here.
const completion = builder.nextCompletion(data.completion, data.deltaText)
Expand Down
1 change: 1 addition & 0 deletions lib/shared/src/sourcegraph-api/completions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface PromptTokensDetails {

export interface CompletionResponse {
completion: string
thinking?: string
stopReason?: string
}

Expand Down
38 changes: 36 additions & 2 deletions vscode/webviews/chat/ChatMessageContent/ChatMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import { clsx } from 'clsx'
import { LoaderIcon, PlusIcon } from 'lucide-react'
import type { FixupTaskID } from '../../../src/non-stop/FixupTask'
import { CodyTaskState } from '../../../src/non-stop/state'
import { type ClientActionListener, useClientActionListener } from '../../client/clientState'
Expand All @@ -12,7 +13,7 @@ import type { PriorHumanMessageInfo } from '../cells/messageCell/assistant/Assis
import styles from './ChatMessageContent.module.css'
import { GuardrailsStatusController } from './GuardRailStatusController'
import { createButtons, createButtonsExperimentalUI } from './create-buttons'
import { getCodeBlockId, getFileName } from './utils'
import { extractThinkContent, getCodeBlockId, getFileName } from './utils'

export interface CodeBlockActionsProps {
copyButtonOnSubmit: (text: string, event?: 'Keydown' | 'Button') => void
Expand Down Expand Up @@ -204,10 +205,43 @@ export const ChatMessageContent: React.FunctionComponent<ChatMessageContentProps
smartApplyStates,
])

const { displayContent, thinkContent, isThinking } = useMemo(
() => extractThinkContent(displayMarkdown),
[displayMarkdown]
)

return (
<div ref={rootRef} data-testid="chat-message-content">
{thinkContent.length > 0 && (
<details
open
className="tw-container tw-mb-7 tw-border tw-border-gray-500/20 dark:tw-border-gray-600/40 tw-rounded-lg tw-overflow-hidden tw-backdrop-blur-sm hover:tw-bg-gray-200/50 dark:hover:tw-bg-gray-700/50"
title="Thinking & Reasoning Space"
>
<summary
className={clsx(
'tw-flex tw-items-center tw-gap-2 tw-px-3 tw-py-2 tw-bg-gray-100/50 dark:tw-bg-gray-800/80 tw-cursor-pointer tw-select-none tw-transition-colors',
{
'tw-animate-pulse': isThinking,
}
)}
>
{isThinking ? (
<LoaderIcon size={16} className="tw-animate-spin tw-text-muted-foreground" />
) : (
<PlusIcon size={16} className="tw-text-muted-foreground" />
)}
<span className="tw-font-medium tw-text-gray-600 dark:tw-text-gray-300">
{isThinking ? 'Thinking...' : 'Thought Process'}
</span>
</summary>
<div className="tw-px-4 tw-py-3 tw-mx-4 tw-text-sm tw-prose dark:tw-prose-invert tw-max-w-none tw-leading-relaxed tw-text-base/7 tw-text-muted-foreground">
{thinkContent}
</div>
</details>
)}
<MarkdownFromCody className={clsx(styles.content, className)}>
{displayMarkdown}
{displayContent}
</MarkdownFromCody>
</div>
)
Expand Down
70 changes: 70 additions & 0 deletions vscode/webviews/chat/ChatMessageContent/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { extractThinkContent } from './utils'

describe('extractThinkContent', () => {
it('extracts content from complete think tags at the start', () => {
const input = '<think>Planning steps</think>Here is the code'
const result = extractThinkContent(input)

expect(result).toEqual({
displayContent: 'Here is the code',
thinkContent: 'Planning steps',
isThinking: false,
})
})

it('ignores think tags that do not start at the beginning', () => {
const input = 'Code here<think>Step 2</think>More code'
const result = extractThinkContent(input)

expect(result).toEqual({
displayContent: 'Code here<think>Step 2</think>More code',
thinkContent: '',
isThinking: false,
})
})

it('handles unclosed think tag at the start', () => {
const input = '<think>Incomplete thought'
const result = extractThinkContent(input)

expect(result).toEqual({
displayContent: '',
thinkContent: 'Incomplete thought',
isThinking: true,
})
})

it('ignores unclosed think tag not at the start', () => {
const input = 'Middle<think>Incomplete'
const result = extractThinkContent(input)

expect(result).toEqual({
displayContent: 'Middle<think>Incomplete',
thinkContent: '',
isThinking: false,
})
})

it('returns empty think content for input without think tags', () => {
const input = 'Regular content without think tags'
const result = extractThinkContent(input)

expect(result).toEqual({
displayContent: 'Regular content without think tags',
thinkContent: '',
isThinking: false,
})
})

it('keeps isThinking true when think tag is closed but no content follows', () => {
const input = '<think>Completed thought</think>'
const result = extractThinkContent(input)

expect(result).toEqual({
displayContent: '',
thinkContent: 'Completed thought',
isThinking: true,
})
})
})
56 changes: 56 additions & 0 deletions vscode/webviews/chat/ChatMessageContent/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,59 @@ export function getCodeBlockId(contents: string, fileName?: string): string {
}
return SHA256(input).toString()
}

interface StreamingContent {
displayContent: string
thinkContent: string
isThinking: boolean
}

/**
* Extracts content enclosed in `<think>` tags from the beginning of a string.
* This function processes text that may contain special thinking content markers.
*
* @param content - The input string that may contain thinking content
* @returns A StreamingContent object with three properties:
* - displayContent: The portion of the input string that should be displayed to the user
* (excludes content in think tags at the start)
* - thinkContent: The content found inside the think tags, if any
* - isThinking: A boolean indicating whether we're in "thinking" mode:
* true when there's either an unclosed think tag or
* a complete think tag with no content after it
*
* Note: Only think tags at the start of the content are processed.
* Think tags appearing later in the content are left as-is in displayContent.
*/
const lengthOfThinkTag = '<think>'.length
export function extractThinkContent(content: string): StreamingContent {
// Match think tags at the start of the content
const thinkRegex = /^<think>([\s\S]*?)<\/think>/
const match = content.match(thinkRegex)

// Check if content starts with a think tag
const startsWithThink = content.startsWith('<think>')

let thinkContent = ''
let displayContent = content
let isThinking = false

if (match) {
// We found a complete think tag at the start
thinkContent = match[1].trim()
displayContent = content.slice(match[0].length)

// If there's no content after the think tag, we're still in thinking mode
isThinking = displayContent.trim() === ''
} else if (startsWithThink) {
// We have an unclosed think tag at the start
thinkContent = content.slice(lengthOfThinkTag) // length of '<think>'
displayContent = ''
isThinking = true
}

return {
displayContent,
thinkContent,
isThinking,
}
}
1 change: 1 addition & 0 deletions vscode/webviews/components/MarkdownFromCody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ALLOWED_ELEMENTS = [
'h5',
'h6',
'br',
'think',
]

function defaultUrlProcessor(url: string): string {
Expand Down
4 changes: 4 additions & 0 deletions vscode/webviews/tailwind.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export default {
background: 'var(--vscode-statusBarItem-offlineBackground)',
foreground: 'var(--vscode-statusBarItem-offlineForeground)',
},
code: {
background: 'var(--code-background)',
foreground: 'var(--code-foreground)',
},
sourcegraph: {
blue: '#00CBEC',
purple: '#A112FF',
Expand Down

0 comments on commit bd9fee0

Please sign in to comment.