mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: prevent malformed generated session titles
Session titles need to appear immediately, but the async AI title pass can return fenced or escaped JSON fragments. Keep the first-message title path fast, preserve optimistic client titles during background refresh, and only accept structured title responses that parse cleanly. Constraint: Desktop sessions should stop showing Untitled/New Session after the first user turn. Rejected: Accept short raw model output as a fallback | malformed JSON fragments are short enough to leak into the UI. Confidence: high Scope-risk: moderate Directive: Do not loosen generated title parsing without regression coverage for fenced, escaped, and truncated JSON responses. Tested: bun test src/server/__tests__/title-service.test.ts Tested: bun run check:server Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/stores/sessionStore.test.ts src/pages/EmptySession.test.tsx Tested: agent-browser smoke on http://127.0.0.1:1420/ confirmed sidebar and tab titles update without JSON fragments.
This commit is contained in:
parent
a340e41046
commit
7fbce35be6
21
desktop/src/lib/sessionTitle.ts
Normal file
21
desktop/src/lib/sessionTitle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
const TITLE_MAX_LEN = 50
|
||||
|
||||
const PLACEHOLDER_TITLES = new Set([
|
||||
'',
|
||||
'New Session',
|
||||
'Untitled Session',
|
||||
])
|
||||
|
||||
export function deriveSessionTitle(raw: string): string | null {
|
||||
const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim()
|
||||
const firstSentence = /^(.*?[.!?\u3002\uff01\uff1f])\s/.exec(clean)?.[1] ?? clean
|
||||
const flat = firstSentence.replace(/\s+/g, ' ').trim()
|
||||
if (!flat) return null
|
||||
return flat.length > TITLE_MAX_LEN
|
||||
? `${flat.slice(0, TITLE_MAX_LEN - 1)}\u2026`
|
||||
: flat
|
||||
}
|
||||
|
||||
export function isPlaceholderSessionTitle(title: string | null | undefined): boolean {
|
||||
return PLACEHOLDER_TITLES.has((title ?? '').trim())
|
||||
}
|
||||
@ -180,7 +180,7 @@ describe('EmptySession', () => {
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBe('draft-session')
|
||||
expect(useTabStore.getState().tabs).toEqual([
|
||||
{ sessionId: 'draft-session', title: 'New Session', type: 'session', status: 'idle' },
|
||||
{ sessionId: 'draft-session', title: 'draft question', type: 'session', status: 'idle' },
|
||||
])
|
||||
expect(useSessionStore.getState().sessions[0]).toMatchObject({
|
||||
id: 'draft-session',
|
||||
|
||||
@ -16,6 +16,10 @@ const {
|
||||
resetCompletedTasksMock,
|
||||
refreshTasksMock,
|
||||
notifyDesktopMock,
|
||||
updateTabTitleMock,
|
||||
updateTabStatusMock,
|
||||
updateSessionTitleMock,
|
||||
sessionStoreSnapshot,
|
||||
cliTaskStoreSnapshot,
|
||||
} = vi.hoisted(() => ({
|
||||
sendMock: vi.fn(),
|
||||
@ -31,6 +35,21 @@ const {
|
||||
resetCompletedTasksMock: vi.fn(async () => {}),
|
||||
refreshTasksMock: vi.fn(),
|
||||
notifyDesktopMock: vi.fn(),
|
||||
updateTabTitleMock: vi.fn(),
|
||||
updateTabStatusMock: vi.fn(),
|
||||
updateSessionTitleMock: vi.fn(),
|
||||
sessionStoreSnapshot: {
|
||||
sessions: [] as Array<{
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
messageCount: number
|
||||
projectPath: string
|
||||
workDir: string | null
|
||||
workDirExists: boolean
|
||||
}>,
|
||||
},
|
||||
cliTaskStoreSnapshot: {
|
||||
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
|
||||
sessionId: null as string | null,
|
||||
@ -73,8 +92,8 @@ vi.mock('./teamStore', () => ({
|
||||
vi.mock('./tabStore', () => ({
|
||||
useTabStore: {
|
||||
getState: () => ({
|
||||
updateTabStatus: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
updateTabStatus: updateTabStatusMock,
|
||||
updateTabTitle: updateTabTitleMock,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@ -82,7 +101,8 @@ vi.mock('./tabStore', () => ({
|
||||
vi.mock('./sessionStore', () => ({
|
||||
useSessionStore: {
|
||||
getState: () => ({
|
||||
updateSessionTitle: vi.fn(),
|
||||
sessions: sessionStoreSnapshot.sessions,
|
||||
updateSessionTitle: updateSessionTitleMock,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@ -120,6 +140,10 @@ describe('chatStore history mapping', () => {
|
||||
resetCompletedTasksMock.mockReset()
|
||||
refreshTasksMock.mockReset()
|
||||
notifyDesktopMock.mockReset()
|
||||
updateTabTitleMock.mockReset()
|
||||
updateTabStatusMock.mockReset()
|
||||
updateSessionTitleMock.mockReset()
|
||||
sessionStoreSnapshot.sessions = []
|
||||
cliTaskStoreSnapshot.tasks = []
|
||||
cliTaskStoreSnapshot.sessionId = null
|
||||
useSessionRuntimeStore.setState({ selections: {} })
|
||||
@ -1407,4 +1431,27 @@ describe('chatStore history mapping', () => {
|
||||
|
||||
expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
|
||||
})
|
||||
|
||||
it('optimistically titles a new placeholder session from the first user message', () => {
|
||||
sessionStoreSnapshot.sessions = [{
|
||||
id: TEST_SESSION_ID,
|
||||
title: 'New Session',
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}]
|
||||
|
||||
useChatStore.getState().sendMessage(TEST_SESSION_ID, '开始优化UI')
|
||||
|
||||
expect(updateSessionTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
|
||||
expect(updateTabTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
|
||||
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
|
||||
type: 'user_message',
|
||||
content: '开始优化UI',
|
||||
attachments: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,6 +8,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||
import { notifyDesktop } from '../lib/desktopNotifications'
|
||||
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
||||
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import type { PermissionMode } from '../types/settings'
|
||||
@ -325,6 +326,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
void taskStore.resetCompletedTasks()
|
||||
}
|
||||
|
||||
if (!isMemberSession) {
|
||||
updateOptimisticSessionTitle(sessionId, userFacingContent)
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const session = s.sessions[sessionId] ?? createDefaultSessionState()
|
||||
if (flushTimer) {
|
||||
@ -924,6 +929,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function updateOptimisticSessionTitle(sessionId: string, content: string): void {
|
||||
const title = deriveSessionTitle(content)
|
||||
if (!title) return
|
||||
|
||||
const session = useSessionStore.getState().sessions.find((item) => item.id === sessionId)
|
||||
if (!session || session.messageCount > 0 || !isPlaceholderSessionTitle(session.title)) return
|
||||
|
||||
useSessionStore.getState().updateSessionTitle(sessionId, title)
|
||||
useTabStore.getState().updateTabTitle(sessionId, title)
|
||||
}
|
||||
|
||||
// ─── History mapping helpers (unchanged from original) ─────────
|
||||
|
||||
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
|
||||
|
||||
@ -22,6 +22,14 @@ function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('sessionStore', () => {
|
||||
beforeEach(() => {
|
||||
createMock.mockReset()
|
||||
@ -64,6 +72,45 @@ describe('sessionStore', () => {
|
||||
expect(listMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an optimistic local title when a background refresh still returns a placeholder', async () => {
|
||||
const refresh = createDeferred<{
|
||||
sessions: Array<{
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
messageCount: number
|
||||
projectPath: string
|
||||
workDir: string | null
|
||||
workDirExists: boolean
|
||||
}>
|
||||
total: number
|
||||
}>()
|
||||
createMock.mockResolvedValue({ sessionId: 'session-title-1', workDir: '/workspace/project' })
|
||||
listMock.mockReturnValue(refresh.promise)
|
||||
|
||||
await useSessionStore.getState().createSession('/workspace/project')
|
||||
useSessionStore.getState().updateSessionTitle('session-title-1', '开始优化UI')
|
||||
|
||||
refresh.resolve({
|
||||
sessions: [{
|
||||
id: 'session-title-1',
|
||||
title: 'Untitled Session',
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-07T00:00:01.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
await refresh.promise
|
||||
await delay(0)
|
||||
|
||||
expect(useSessionStore.getState().sessions[0]?.title).toBe('开始优化UI')
|
||||
})
|
||||
|
||||
it('forwards direct branch switch repository options when creating a session', async () => {
|
||||
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
|
||||
listMock.mockResolvedValue({ sessions: [], total: 0 })
|
||||
|
||||
@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import type { SessionListItem } from '../types/session'
|
||||
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
||||
|
||||
type CreateSessionOptions = {
|
||||
repository?: CreateSessionRepositoryOptions
|
||||
@ -36,17 +37,22 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
|
||||
// Deduplicate by session ID — keep the most recently modified entry
|
||||
const byId = new Map<string, SessionListItem>()
|
||||
for (const s of raw) {
|
||||
const existing = byId.get(s.id)
|
||||
if (!existing || new Date(s.modifiedAt) > new Date(existing.modifiedAt)) {
|
||||
byId.set(s.id, s)
|
||||
set((state) => {
|
||||
const currentById = new Map(state.sessions.map((session) => [session.id, session]))
|
||||
// Deduplicate by session ID - keep the most recently modified entry.
|
||||
const byId = new Map<string, SessionListItem>()
|
||||
for (const s of raw) {
|
||||
const current = currentById.get(s.id)
|
||||
const candidate = preserveLocalTitle(current, s)
|
||||
const existing = byId.get(s.id)
|
||||
if (!existing || new Date(candidate.modifiedAt) > new Date(existing.modifiedAt)) {
|
||||
byId.set(s.id, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
const sessions = [...byId.values()]
|
||||
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
|
||||
set({ sessions, availableProjects, isLoading: false })
|
||||
const sessions = [...byId.values()]
|
||||
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
|
||||
return { sessions, availableProjects, isLoading: false }
|
||||
})
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message, isLoading: false })
|
||||
}
|
||||
@ -109,3 +115,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
|
||||
}))
|
||||
|
||||
function preserveLocalTitle(
|
||||
current: SessionListItem | undefined,
|
||||
incoming: SessionListItem,
|
||||
): SessionListItem {
|
||||
if (!current) return incoming
|
||||
if (isPlaceholderSessionTitle(incoming.title) && !isPlaceholderSessionTitle(current.title)) {
|
||||
return { ...incoming, title: current.title }
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
@ -817,6 +817,47 @@ describe('WebSocket Chat Integration', () => {
|
||||
expect(statusMsgs[0].state).toBe('thinking')
|
||||
})
|
||||
|
||||
it('emits the derived session title before the first response completes', async () => {
|
||||
const sessionId = `title-fast-${crypto.randomUUID()}`
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error('Timed out waiting for derived session title'))
|
||||
}, 5000)
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'user_message',
|
||||
content: '开始优化UI',
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (msg.type === 'message_complete') {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`WebSocket error for title session ${sessionId}`))
|
||||
}
|
||||
})
|
||||
|
||||
const titleIndex = messages.findIndex((msg) => msg.type === 'session_title_updated')
|
||||
const completionIndex = messages.findIndex((msg) => msg.type === 'message_complete')
|
||||
expect(titleIndex).toBeGreaterThan(-1)
|
||||
expect(completionIndex).toBeGreaterThan(-1)
|
||||
expect(messages[titleIndex].title).toBe('开始优化UI')
|
||||
expect(titleIndex).toBeLessThan(completionIndex)
|
||||
})
|
||||
|
||||
it('should start desktop sessions with disabled thinking when configured', async () => {
|
||||
const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}`
|
||||
const originalStartSession = conversationService.startSession.bind(conversationService)
|
||||
|
||||
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { generateTitle, saveAiTitle } from '../services/titleService.js'
|
||||
import { generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js'
|
||||
import { sessionService } from '../services/sessionService.js'
|
||||
|
||||
describe('titleService', () => {
|
||||
@ -70,6 +70,20 @@ describe('titleService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('parses JSON title responses wrapped in markdown fences', () => {
|
||||
expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```'))
|
||||
.toBe('Write bash script')
|
||||
})
|
||||
|
||||
test('parses escaped JSON title responses', () => {
|
||||
expect(parseGeneratedTitleText('```json\n{\\"title\\":\\"Write bash script\\"}\n```'))
|
||||
.toBe('Write bash script')
|
||||
})
|
||||
|
||||
test('rejects incomplete JSON title fragments instead of using them as titles', () => {
|
||||
expect(parseGeneratedTitleText('```json\n{\\"title\\":')).toBeNull()
|
||||
})
|
||||
|
||||
test('does not persist automatic titles over a user custom title', async () => {
|
||||
const { sessionId } = await sessionService.createSession(os.tmpdir())
|
||||
await sessionService.renameSession(sessionId, 'My fixed name')
|
||||
|
||||
@ -99,20 +99,78 @@ export async function generateTitle(
|
||||
const text = body.content?.find((b) => b.type === 'text')?.text
|
||||
if (!text) return null
|
||||
|
||||
// Parse JSON response
|
||||
const match = text.match(/\{[^}]*"title"\s*:\s*"([^"]+)"[^}]*\}/)
|
||||
if (match?.[1]) return match[1].trim()
|
||||
|
||||
// Fallback: if model returned plain text instead of JSON
|
||||
const plain = text.trim()
|
||||
if (plain.length > 0 && plain.length <= 60) return plain
|
||||
|
||||
return null
|
||||
return parseGeneratedTitleText(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGeneratedTitleText(text: string): string | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const parsed = parseTitleFromStructuredText(trimmed)
|
||||
if (parsed) return normalizeTitle(parsed)
|
||||
|
||||
if (looksLikeStructuredTitleFragment(trimmed)) return null
|
||||
|
||||
return normalizeTitle(trimmed)
|
||||
}
|
||||
|
||||
function parseTitleFromStructuredText(text: string): string | null {
|
||||
const candidates = new Set<string>([text])
|
||||
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim()
|
||||
if (fenced) candidates.add(fenced)
|
||||
|
||||
const firstBrace = text.indexOf('{')
|
||||
const lastBrace = text.lastIndexOf('}')
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
candidates.add(text.slice(firstBrace, lastBrace + 1))
|
||||
}
|
||||
|
||||
for (const candidate of [...candidates]) {
|
||||
const unescaped = candidate.replace(/\\"/g, '"').replace(/\\n/g, '\n')
|
||||
if (unescaped !== candidate) candidates.add(unescaped)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const title = parseTitleJson(candidate)
|
||||
if (title) return title
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseTitleJson(candidate: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate)
|
||||
if (typeof parsed === 'string') {
|
||||
return parseTitleFromStructuredText(parsed)
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && typeof (parsed as { title?: unknown }).title === 'string') {
|
||||
return (parsed as { title: string }).title
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string): string | null {
|
||||
const clean = title.replace(/\s+/g, ' ').trim()
|
||||
if (!clean || clean.length > 60 || looksLikeStructuredTitleFragment(clean)) return null
|
||||
return clean
|
||||
}
|
||||
|
||||
function looksLikeStructuredTitleFragment(text: string): boolean {
|
||||
return (
|
||||
text.includes('```') ||
|
||||
text.includes('{') ||
|
||||
text.includes('}') ||
|
||||
/\\?"title\\?"\s*:/.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
async function shouldDisableThinkingForTitle(presetId: string): Promise<boolean> {
|
||||
const settings = await new SettingsService().getUserSettings()
|
||||
if (settings.alwaysThinkingEnabled !== false) return false
|
||||
|
||||
@ -53,6 +53,7 @@ const sessionTitleState = new Map<string, {
|
||||
hasCustomTitle: boolean
|
||||
firstUserMessage: string
|
||||
allUserMessages: string[]
|
||||
startedGenerationCounts: Set<number>
|
||||
}>()
|
||||
|
||||
const runtimeOverrides = new Map<string, {
|
||||
@ -271,6 +272,25 @@ async function handleUserMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Track and emit the first placeholder title before CLI startup/streaming.
|
||||
let titleState = sessionTitleState.get(sessionId)
|
||||
if (!titleState) {
|
||||
titleState = {
|
||||
userMessageCount: 0,
|
||||
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
|
||||
firstUserMessage: '',
|
||||
allUserMessages: [],
|
||||
startedGenerationCounts: new Set<number>(),
|
||||
}
|
||||
sessionTitleState.set(sessionId, titleState)
|
||||
}
|
||||
titleState.userMessageCount++
|
||||
titleState.allUserMessages.push(message.content)
|
||||
if (titleState.userMessageCount === 1) {
|
||||
titleState.firstUserMessage = message.content
|
||||
}
|
||||
triggerTitleGeneration(ws, sessionId)
|
||||
|
||||
// 启动 CLI 子进程(如果还没有)
|
||||
try {
|
||||
await ensureCliSessionStarted(ws, sessionId, 'user_message')
|
||||
@ -290,23 +310,6 @@ async function handleUserMessage(
|
||||
return
|
||||
}
|
||||
|
||||
// Track user message for title generation
|
||||
let titleState = sessionTitleState.get(sessionId)
|
||||
if (!titleState) {
|
||||
titleState = {
|
||||
userMessageCount: 0,
|
||||
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
|
||||
firstUserMessage: '',
|
||||
allUserMessages: [],
|
||||
}
|
||||
sessionTitleState.set(sessionId, titleState)
|
||||
}
|
||||
titleState.userMessageCount++
|
||||
titleState.allUserMessages.push(message.content)
|
||||
if (titleState.userMessageCount === 1) {
|
||||
titleState.firstUserMessage = message.content
|
||||
}
|
||||
|
||||
// Register the callback before sending the turn so startup errors are not lost.
|
||||
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
||||
// any pre-turn SDK chatter as fresh chat history.
|
||||
@ -627,6 +630,8 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: s
|
||||
|
||||
// Generate on count 1 (first response) and count 3 (with more context)
|
||||
if (count !== 1 && count !== 3) return
|
||||
if (state.startedGenerationCounts.has(count)) return
|
||||
state.startedGenerationCounts.add(count)
|
||||
|
||||
const text = count === 1
|
||||
? state.firstUserMessage
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user